Skip to main content

majordomus_cli/
synthetic.rs

1//! A synthetic repository: the layer `majordomus init` writes, with as many rules and
2//! documents as a test or a benchmark asks for, in a fresh temporary directory that is
3//! removed on drop. One generator for the property tests, the scaling benchmarks and the
4//! registry benchmarks, so that they measure and test the same shape of repository. Not
5//! a git repository: discovery goes through the filesystem, which is what a benchmark
6//! wants to measure and a property test wants to control.
7
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use crate::capability::{builtin, CapabilityRegistry, Context};
12use crate::discovery::{FileSystem, Sources};
13use crate::git::GitState;
14use crate::index::Index;
15use crate::metadata::KindSchema;
16use crate::repository::Repository;
17use crate::share::Share;
18
19const MANIFEST: &str = "schema: ai-repository/v1\nrepo:\n  path: repo\nlocal:\n  path: local\n  tracked: false\n  implicit_context: false\nsections:\n  policy: repo/policy.yaml\n  profiles: repo/profiles\n  rules: repo/rules\n  prompts: repo/prompts\n  skills: repo/skills\n  workflows: repo/workflows\n  knowledge: repo/knowledge\n  adrs: repo/adrs\n  project: repo/project\n";
20const SOURCES: &str = "version: 1\nsources:\n  - id: policy\n    kind: policy\n    discovery: vcs\n    pathspec: ':(glob).ai/repo/policy.yaml'\n    required: true\n  - id: profile\n    kind: profile\n    discovery: vcs\n    pathspec: ':(glob).ai/repo/profiles/*.yaml'\n    required: true\n  - id: rule\n    kind: rule\n    discovery: vcs\n    pathspec: ':(glob).ai/repo/rules/**/*.md'\n    required: true\n  - id: prompt\n    kind: prompt\n    discovery: vcs\n    pathspec: ':(glob).ai/repo/prompts/*.md'\n    required: false\n  - id: document\n    kind: document\n    discovery: vcs\n    pathspec: ':(glob)docs/*.md'\n    required: false\n";
21
22static SEQ: AtomicU64 = AtomicU64::new(0);
23
24/// How large the repository is.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct Shape {
27    /// Project rules under `.ai/repo/rules/project/`.
28    pub rules: usize,
29    /// Prompts under `.ai/repo/prompts/`.
30    pub prompts: usize,
31    /// Documents under `docs/`.
32    pub documents: usize,
33    /// Lines of body per rule and document.
34    pub body_lines: usize,
35}
36
37impl Default for Shape {
38    fn default() -> Self {
39        Shape {
40            rules: 10,
41            prompts: 2,
42            documents: 5,
43            body_lines: 8,
44        }
45    }
46}
47
48/// A generated repository on disk, removed when dropped.
49#[derive(Debug)]
50pub struct SyntheticRepository {
51    root: PathBuf,
52    /// The shape it was built with.
53    pub shape: Shape,
54}
55
56impl SyntheticRepository {
57    /// Generate a repository of this shape under the system's temporary directory.
58    pub fn new(shape: Shape) -> std::io::Result<Self> {
59        let seq = SEQ.fetch_add(1, Ordering::SeqCst);
60        let root =
61            std::env::temp_dir().join(format!("majordomus-synthetic-{}-{seq}", std::process::id()));
62        std::fs::create_dir_all(&root)?;
63        let repo = SyntheticRepository { root, shape };
64        repo.write(".ai/manifest.yaml", MANIFEST)?;
65        repo.write(
66            ".ai/repo/policy.yaml",
67            "version: 1\ncontext:\n  always_loaded_budget_lines: 150\n",
68        )?;
69        repo.write(".ai/repo/profiles/implementation.yaml", "name: implementation\ndescription: d\ncapability: standard\neffort: medium\nverbosity: concise\npresentation: engineering\ncheckpoint_interval: 15m\n")?;
70        repo.write(".ai/repo/knowledge/sources.yaml", SOURCES)?;
71        for i in 0..shape.rules {
72            repo.write(
73                &format!(".ai/repo/rules/project/rule-{i}.v1.md"),
74                &rule(i, shape.body_lines),
75            )?;
76        }
77        for i in 0..shape.prompts {
78            repo.write(
79                &format!(".ai/repo/prompts/prompt-{i}.md"),
80                &format!("---\nname: prompt-{i}\ndescription: Prompt number {i}.\n---\n\n# Prompt {i}\n\n{{{{CONTEXT}}}}\n"),
81            )?;
82        }
83        for i in 0..shape.documents {
84            let body: String = (0..shape.body_lines)
85                .map(|l| {
86                    format!("Line {l} of document {i}: the quick brown fox names scope and rule.\n")
87                })
88                .collect();
89            repo.write(
90                &format!("docs/DOC_{i}.md"),
91                &format!("# Document {i}\n\n{body}"),
92            )?;
93        }
94        Ok(repo)
95    }
96
97    /// The default shape.
98    pub fn small() -> std::io::Result<Self> {
99        Self::new(Shape::default())
100    }
101
102    /// The repository root.
103    pub fn root(&self) -> &Path {
104        &self.root
105    }
106
107    fn write(&self, rel: &str, content: &str) -> std::io::Result<()> {
108        let p = self.root.join(rel);
109        if let Some(dir) = p.parent() {
110            std::fs::create_dir_all(dir)?;
111        }
112        std::fs::write(p, content)
113    }
114
115    /// Change one rule's body: the repository state, and so every fingerprint, moves.
116    pub fn touch_rule(&self, i: usize, suffix: &str) -> std::io::Result<()> {
117        let rel = format!(".ai/repo/rules/project/rule-{i}.v1.md");
118        let text = std::fs::read_to_string(self.root.join(&rel))?;
119        self.write(&rel, &format!("{text}\n{suffix}\n"))
120    }
121
122    /// The index of this repository, discovered through the filesystem, with the
123    /// distribution's share directory beside the crate.
124    pub fn index(&self) -> crate::error::Result<Index> {
125        let repo = Repository::discover(&self.root)?;
126        let sources = Sources::load(&repo)?;
127        let share = Share::locate(Some(&crate_share()), &self.root)?;
128        let schema = KindSchema::load(&share, &repo)?;
129        let fs = FileSystem {
130            excluded: vec![".git".into(), repo.local_path()],
131        };
132        let scope = crate::scope::Scope::load(&share, &repo)?;
133        Index::build(
134            &repo,
135            &sources,
136            &schema,
137            &fs,
138            GitState::Unavailable {
139                reason: "synthetic repository".into(),
140            },
141            scope,
142        )
143    }
144
145    /// A context over this repository: index, the application's modules, a fresh executor.
146    pub fn context(&self) -> crate::error::Result<std::sync::Arc<Context>> {
147        let index = self.index()?;
148        let registry = CapabilityRegistry::builder()
149            .with_modules(builtin::modules())
150            .with_index(&index)
151            .build()
152            .map_err(|errors| crate::error::Error::Registry { errors })?;
153        Ok(std::sync::Arc::new(Context::new(
154            std::sync::Arc::new(index),
155            std::sync::Arc::new(registry),
156        )))
157    }
158}
159
160/// The sources a plan is discovered through, appended to [`SOURCES`].
161#[cfg(test)]
162const PLAN_SOURCES: &str = "
163  - id: milestone
164    kind: milestone
165    discovery: vcs
166    pathspec: ':(glob).ai/repo/project/milestones/*.yaml'
167    required: false
168  - id: issue
169    kind: issue
170    discovery: vcs
171    pathspec: ':(glob).ai/repo/project/issues/*.yaml'
172    required: false
173";
174
175#[cfg(test)]
176fn plan_milestone_record(id: &str, order: u32, depends_on: &[&str]) -> String {
177    format!(
178        "id: {id}\ntitle: The milestone {id}\nslug: {id}\norder: {order}\npriority: p1\n\
179         problem: \"A problem.\"\noutcome: \"The outcome of {id}.\"\n\
180         depends_on: [{}]\nacceptance_criteria:\n  - It is reached\n\
181         validation:\n  - \"true\"\nevidence_required:\n  - proof\n",
182        depends_on.join(", ")
183    )
184}
185
186#[cfg(test)]
187fn plan_issue_record(id: &str, milestone: &str, scope: &str, depends_on: &[&str]) -> String {
188    format!(
189        "id: {id}\nmilestone: {milestone}\ntitle: The work of {id}\nslug: work-{id}\n\
190         priority: p1\nprofile: implementation\nobjective: \"Do {id}.\"\n\
191         scope:\n  - {scope}\ndepends_on: [{}]\nacceptance_criteria:\n  - {id} is done\n\
192         validation:\n  - \"true\"\nevidence_required:\n  - proof\n",
193        depends_on.join(", ")
194    )
195}
196
197#[cfg(test)]
198impl SyntheticRepository {
199    /// A repository with a plan: `m-first` holds a ready issue, one waiting on it and one
200    /// that shares its scope; `m-second` waits on `m-first` and holds nothing; `m-loop`
201    /// holds two issues that wait on each other.
202    pub(crate) fn planned() -> SyntheticRepository {
203        let repo = SyntheticRepository::small().expect("a synthetic repository");
204        // `devtask.issue` traces an issue through git, and refuses a directory that is not
205        // a git work tree; the index itself is read from the filesystem
206        assert!(std::process::Command::new("git")
207            .arg("-C")
208            .arg(repo.root())
209            .args(["init", "-q"])
210            .status()
211            .expect("git")
212            .success());
213        let write = |rel: &str, body: &str| {
214            let path = repo.root().join(rel);
215            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
216            std::fs::write(path, body).unwrap();
217        };
218        let sources = repo.root().join(".ai/repo/knowledge/sources.yaml");
219        let mut text = std::fs::read_to_string(&sources).unwrap();
220        text.push_str(PLAN_SOURCES);
221        std::fs::write(&sources, text).unwrap();
222        write(
223            ".ai/repo/project/project.yaml",
224            "schema_version: 1\nname: Synthetic\nrepository: example/synthetic\ndefault_branch: master\n",
225        );
226        for (id, order, deps) in [
227            ("m-first", 0, vec![]),
228            ("m-second", 1, vec!["m-first"]),
229            ("m-loop", 2, vec![]),
230        ] {
231            write(
232                &format!(".ai/repo/project/milestones/{id}.yaml"),
233                &plan_milestone_record(id, order, &deps),
234            );
235        }
236        for (id, milestone, scope, deps) in [
237            ("I0001", "m-first", "lib", vec![]),
238            ("I0002", "m-first", "docs", vec!["I0001"]),
239            ("I0003", "m-first", "lib", vec![]),
240            ("I0010", "m-loop", "lib", vec!["I0011"]),
241            ("I0011", "m-loop", "lib", vec!["I0010"]),
242        ] {
243            write(
244                &format!(".ai/repo/project/issues/{id}.yaml"),
245                &plan_issue_record(id, milestone, scope, &deps),
246            );
247        }
248        // an issue that declares what it does not touch and carries the evidence it requires
249        write(
250            ".ai/repo/project/issues/I0021.yaml",
251            "id: I0021\nmilestone: m-loop\ntitle: The evidenced work\nslug: work-I0021\n\
252             priority: p2\nprofile: implementation\nobjective: \"Prove it.\"\n\
253             scope:\n  - share\nnon_scope:\n  - share/elsewhere\n\
254             acceptance_criteria:\n  - It is proven\nvalidation:\n  - \"true\"\n\
255             evidence_required:\n  - proof\nevidence:\n  - covers: proof\n    type: manual\n\
256             \x20   command: \"true\"\n    result: \"it held\"\n",
257        );
258        repo
259    }
260}
261
262impl Drop for SyntheticRepository {
263    fn drop(&mut self) {
264        let _ = std::fs::remove_dir_all(&self.root);
265    }
266}
267
268/// The tool distribution's share directory beside this crate.
269pub fn crate_share() -> PathBuf {
270    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../share")
271}
272
273fn rule(i: usize, body_lines: usize) -> String {
274    let body: String = (0..body_lines)
275        .map(|l| format!("Rationale line {l} of rule {i}: keep the scope and check the state.\n"))
276        .collect();
277    format!("---\nid: project.rule-{i}\nversion: 1\nkind: rule\ntitle: Rule {i}\ndescription: Rule number {i}, in one sentence.\nstatement: Do the thing number {i}.\nstatus: active\nclass: advisory\ndepends_on: []\ntags: [synthetic]\n---\n\n# Rationale\n\n{body}")
278}