Skip to main content

majordomus_cli/
layout.rs

1//! The boundary between the layer's two halves, checked (I2871, I1102).
2//!
3//! I2870 lets a directory's contract declare its layout — the lifecycle of what lives there
4//! and what must never appear — and the manifest's `local:` block declares the local half's.
5//! This module reads those declarations and refuses what contradicts them, naming the path
6//! and the contract. It judges only what is declared: a contract without a layout says
7//! nothing, and nothing is refused under it.
8//!
9//! What a file is, is read rather than guessed from its name:
10//!
11//! - a tracked file under the tracked half that git's own ignore rules match is state that
12//!   does not travel — `cache` or `runtime` — committed by mistake;
13//! - a file under the local half whose bytes are a tracked file of the tracked half, or
14//!   whose front matter declares the schema of a kind `share/kinds.yaml` calls canonical,
15//!   is a copy of authored knowledge.
16//!
17//! ```
18//! use majordomus_cli::layout::{LayoutFinding, LayoutReport};
19//! let report = LayoutReport::of(true, None, 3, Vec::new());
20//! assert!(report.passes, "measured and nothing found");
21//! let found = LayoutReport::of(true, None, 3, vec![LayoutFinding {
22//!     path: ".ai/local/rules/copied.md".into(),
23//!     contract: "manifest local".into(),
24//!     excludes: "canonical".into(),
25//!     reason: "a copy of .ai/repo/rules/copied.md".into(),
26//! }]);
27//! assert!(!found.passes);
28//! assert!(!LayoutReport::of(false, Some("no git".into()), 0, Vec::new()).passes);
29//! ```
30
31use std::collections::{BTreeMap, BTreeSet};
32use std::path::{Path, PathBuf};
33use std::process::Command;
34
35use schemars::JsonSchema;
36use serde::{Deserialize, Serialize};
37
38use crate::index::Index;
39use crate::metadata::KindSchema;
40use crate::repository::{Lifecycle, Repository};
41
42/// The largest local file whose head is read for a declared schema; larger files are
43/// compared by their bytes only.
44const HEAD_BYTES: u64 = 256 * 1024;
45
46/// One misplaced artifact: a path found in a half whose declared layout excludes it, with the
47/// contract that excludes it and why, so a refusal names what to move and where it belongs.
48///
49/// It travels as the JSON of the `layout.check` capability, so its member names are the
50/// wire contract every projection reads:
51///
52/// ```
53/// use majordomus_cli::layout::LayoutFinding;
54/// let finding = LayoutFinding {
55///     path: ".ai/local/notes/rule.md".into(),
56///     contract: "manifest local".into(),
57///     excludes: "canonical".into(),
58///     reason: "a copy of .ai/repo/rules/rule.md".into(),
59/// };
60/// let json = serde_json::to_value(&finding).unwrap();
61/// assert_eq!(json["contract"], "manifest local");
62/// assert_eq!(json["excludes"], "canonical");
63/// let back: LayoutFinding = serde_json::from_value(json).unwrap();
64/// assert_eq!(back, finding);
65/// ```
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67pub struct LayoutFinding {
68    /// The path, repository-relative.
69    pub path: String,
70    /// The contract it violates: a context document's id, or `manifest local`.
71    pub contract: String,
72    /// What the contract excludes that this path is.
73    pub excludes: String,
74    /// What the path was found to be, and how.
75    pub reason: String,
76}
77
78/// Findings are listed by path, the contract breaking the tie, as everywhere else.
79impl crate::order::Ordered for LayoutFinding {
80    fn order_key(&self) -> crate::order::OrderKey<'_> {
81        crate::order::OrderKey::plain(&self.path, &self.contract)
82    }
83}
84
85/// The answer of the layout check: whether the halves were read, how many paths were
86/// judged, and every path a contract excludes. A report that could not read the halves
87/// never passes, so silence about a tree nobody measured cannot read as a clean tree.
88///
89/// ```
90/// use majordomus_cli::layout::LayoutReport;
91/// let clean = LayoutReport::of(true, None, 12, Vec::new());
92/// assert!(clean.passes);
93/// assert_eq!(clean.checked, 12);
94/// let unread = LayoutReport::of(false, Some("git could not start".into()), 0, Vec::new());
95/// assert!(!unread.passes, "nothing found because nothing was read");
96/// assert_eq!(unread.reason.as_deref(), Some("git could not start"));
97/// ```
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
99pub struct LayoutReport {
100    /// Whether the halves could be read at all. `false` is not a pass.
101    pub measured: bool,
102    /// Why they could not, when they could not.
103    pub reason: Option<String>,
104    /// How many paths were judged.
105    pub checked: usize,
106    /// Every path a contract excludes, in canonical order. Empty is the passing answer.
107    pub findings: Vec<LayoutFinding>,
108    /// `true` when the halves were read and nothing was found.
109    pub passes: bool,
110}
111
112impl LayoutReport {
113    /// A report, its verdict derived from what was measured and found. The findings are
114    /// put in canonical order — by path, the contract breaking the tie — whatever order the
115    /// walk found them in, so two runs over one tree answer the same bytes.
116    ///
117    /// ```
118    /// use majordomus_cli::layout::{LayoutFinding, LayoutReport};
119    /// let at = |path: &str| LayoutFinding {
120    ///     path: path.into(),
121    ///     contract: "manifest local".into(),
122    ///     excludes: "canonical".into(),
123    ///     reason: "a copy".into(),
124    /// };
125    /// let found = vec![at(".ai/local/z.md"), at(".ai/local/a.md")];
126    /// let report = LayoutReport::of(true, None, 5, found);
127    /// assert!(!report.passes, "measured, but something was found");
128    /// let paths: Vec<&str> = report.findings.iter().map(|f| f.path.as_str()).collect();
129    /// assert_eq!(paths, [".ai/local/a.md", ".ai/local/z.md"]);
130    /// ```
131    pub fn of(
132        measured: bool,
133        reason: Option<String>,
134        checked: usize,
135        mut findings: Vec<LayoutFinding>,
136    ) -> Self {
137        crate::order::canonical(&mut findings);
138        let passes = measured && findings.is_empty();
139        LayoutReport {
140            measured,
141            reason,
142            checked,
143            findings,
144            passes,
145        }
146    }
147
148    fn unmeasured(reason: String) -> Self {
149        Self::of(false, Some(reason), 0, Vec::new())
150    }
151}
152
153/// A declared layout: the contract that declares it and what it excludes.
154struct Declared {
155    contract: String,
156    excludes: Vec<String>,
157}
158
159/// Check both halves of `repository` against their declared layouts.
160///
161/// Reads git at the repository's root — the tracked half's index entries and the ones its
162/// ignore rules match — and the local half's Markdown and YAML files. A root git cannot
163/// read is answered unmeasured rather than passing; a local half the manifest does not
164/// declare `excludes: [canonical]` for is counted but not judged.
165///
166/// ```
167/// use majordomus_cli::layout;
168/// use majordomus_cli::metadata::KindSchema;
169/// use majordomus_cli::repository::Repository;
170/// use majordomus_cli::share::Share;
171/// use majordomus_cli::synthetic::{crate_share, SyntheticRepository};
172///
173/// let repo = SyntheticRepository::small().unwrap();
174/// let root = repo.root();
175/// // the local half declares that authored knowledge never lives there
176/// let manifest = root.join(".ai/manifest.yaml");
177/// let text = std::fs::read_to_string(&manifest).unwrap().replace(
178///     "  implicit_context: false\n",
179///     "  implicit_context: false\n  lifecycle: runtime\n  excludes: [canonical]\n",
180/// );
181/// std::fs::write(&manifest, text).unwrap();
182/// let git = |args: &[&str]| {
183///     let mut git = std::process::Command::new("git");
184///     assert!(git.arg("-C").arg(root).args(args).status().unwrap().success());
185/// };
186/// git(&["init", "-q"]);
187/// git(&["add", ".ai"]);
188/// // a byte copy of a tracked rule, left in the local half
189/// let rule = std::fs::read(root.join(".ai/repo/rules/project/rule-0.v1.md")).unwrap();
190/// std::fs::create_dir_all(root.join(".ai/local/notes")).unwrap();
191/// std::fs::write(root.join(".ai/local/notes/rule-0.md"), rule).unwrap();
192///
193/// let repository = Repository::discover(root).unwrap();
194/// let share = Share::locate(Some(&crate_share()), root).unwrap();
195/// let kinds = KindSchema::load(&share, &repository).unwrap();
196/// let report = layout::check(&repository, &repo.index().unwrap(), &kinds);
197/// assert!(report.measured && !report.passes);
198/// assert_eq!(report.findings.len(), 1);
199/// assert_eq!(report.findings[0].path, ".ai/local/notes/rule-0.md");
200/// assert!(report.findings[0].reason.contains(".ai/repo/rules/project/rule-0.v1.md"));
201/// ```
202pub fn check(repository: &Repository, index: &Index, kinds: &KindSchema) -> LayoutReport {
203    let root = repository.root();
204    let manifest = repository.manifest();
205    let tracked_half = format!(".ai/{}", manifest.repo.path.trim_end_matches('/'));
206    let local_half = format!(".ai/{}", manifest.local.path.trim_end_matches('/'));
207
208    // the tracked half with its blob ids, then which of it git's ignore rules match: both
209    // listings or neither, so a half-read tree is never judged
210    let listed = git_lines(root, &["ls-files", "-s", "--", &tracked_half]).and_then(|tracked| {
211        git_lines(
212            root,
213            &[
214                "ls-files",
215                "-c",
216                "-i",
217                "--exclude-standard",
218                "--",
219                &tracked_half,
220            ],
221        )
222        .map(|ignored| (tracked, ignored))
223    });
224    let (tracked, ignored) = match listed {
225        Ok(lists) => lists,
226        Err(e) => return LayoutReport::unmeasured(e),
227    };
228
229    let mut findings = Vec::new();
230    let contracts = declared_layouts(index);
231    for path in &ignored {
232        let Some(d) = nearest(&contracts, path) else {
233            continue;
234        };
235        let Some(excluded) = d
236            .excludes
237            .iter()
238            .find(|e| matches!(e.as_str(), "cache" | "runtime"))
239        else {
240            continue;
241        };
242        findings.push(LayoutFinding {
243            path: path.clone(),
244            contract: d.contract.clone(),
245            excludes: excluded.clone(),
246            reason: "tracked although git's ignore rules match it: state that does not travel"
247                .into(),
248        });
249    }
250
251    let mut checked = tracked.len();
252    if manifest.local.excludes.iter().any(|e| e == "canonical") {
253        // the tracked half's blobs, by id: a local file with one of these ids is that file
254        let blobs: BTreeMap<String, String> =
255            tracked.iter().filter_map(|l| staged_blob(l)).collect();
256        let canonical: BTreeSet<String> = kinds
257            .kinds()
258            .filter(|(_, k)| k.lifecycle.unwrap_or(Lifecycle::Canonical) == Lifecycle::Canonical)
259            .filter_map(|(_, k)| k.schema.as_deref())
260            .flat_map(|s| {
261                let short = s.split_once('.').map_or(s, |(_, rest)| rest);
262                [s.to_string(), short.to_string()]
263            })
264            .collect();
265        let files = local_files(&root.join(&local_half));
266        checked += files.len();
267        let ids = match hash_objects(root, &files) {
268            Ok(ids) => ids,
269            Err(e) => return LayoutReport::unmeasured(e),
270        };
271        for (file, id) in files.iter().zip(ids) {
272            let rel = file
273                .strip_prefix(root)
274                .unwrap_or(file)
275                .to_string_lossy()
276                .replace('\\', "/");
277            if let Some(original) = blobs.get(&id) {
278                findings.push(LayoutFinding {
279                    path: rel,
280                    contract: "manifest local".into(),
281                    excludes: "canonical".into(),
282                    reason: format!(
283                        "a copy of {original}: authored knowledge belongs in the tracked half"
284                    ),
285                });
286                continue;
287            }
288            if let Some(schema) = declared_schema(file).filter(|s| canonical.contains(s)) {
289                findings.push(LayoutFinding {
290                    path: rel,
291                    contract: "manifest local".into(),
292                    excludes: "canonical".into(),
293                    reason: format!("declares `schema: {schema}`, a canonical kind: authored knowledge belongs in the tracked half"),
294                });
295            }
296        }
297    }
298    LayoutReport::of(true, None, checked, findings)
299}
300
301/// Every contract that declares a layout, by the directory it governs.
302fn declared_layouts(index: &Index) -> BTreeMap<String, Declared> {
303    index
304        .objects
305        .iter()
306        .filter(|o| o.kind == "context")
307        .filter_map(|o| {
308            let layout = o.metadata.get("layout")?;
309            let dir = Path::new(&o.provenance.path)
310                .parent()
311                .map(|p| p.to_string_lossy().replace('\\', "/"))
312                .unwrap_or_default();
313            let excludes = layout
314                .get("excludes")
315                .and_then(|e| e.as_array())
316                .map(|a| {
317                    a.iter()
318                        .filter_map(|v| v.as_str().map(str::to_string))
319                        .collect()
320                })
321                .unwrap_or_default();
322            Some((
323                dir,
324                Declared {
325                    contract: o.identity.clone(),
326                    excludes,
327                },
328            ))
329        })
330        .collect()
331}
332
333/// The contract nearest above `path` that declares a layout.
334fn nearest<'a>(contracts: &'a BTreeMap<String, Declared>, path: &str) -> Option<&'a Declared> {
335    Path::new(path)
336        .ancestors()
337        .skip(1)
338        .find_map(|dir| contracts.get(&dir.to_string_lossy().replace('\\', "/")))
339}
340
341/// The `schema:` a file declares in its first lines (front matter or a YAML document).
342fn declared_schema(file: &Path) -> Option<String> {
343    let meta = std::fs::metadata(file).ok()?;
344    if meta.len() > HEAD_BYTES {
345        return None;
346    }
347    let text = std::fs::read_to_string(file).ok()?;
348    text.lines().take(40).find_map(|l| {
349        let v = l.strip_prefix("schema:")?.trim();
350        Some(v.trim_matches(|c| c == '"' || c == '\'').to_string())
351    })
352}
353
354/// Every regular file under `dir` that could carry knowledge (Markdown or YAML). Links are
355/// not followed; the order is the walk's, and the report orders what it finds.
356fn local_files(dir: &Path) -> Vec<PathBuf> {
357    let mut out = Vec::new();
358    let mut stack = vec![dir.to_path_buf()];
359    while let Some(d) = stack.pop() {
360        let Ok(entries) = std::fs::read_dir(&d) else {
361            continue;
362        };
363        for e in entries.flatten() {
364            let p = e.path();
365            match e.file_type() {
366                Ok(t) if t.is_dir() => stack.push(p),
367                Ok(t)
368                    if t.is_file()
369                        && p.extension()
370                            .is_some_and(|x| x == "md" || x == "yaml" || x == "yml")
371                        && e.metadata().is_ok_and(|m| m.len() > 0) =>
372                {
373                    out.push(p)
374                }
375                // another kind of file, or one whose type cannot be read: not a record
376                _ => {}
377            }
378        }
379    }
380    out
381}
382
383/// How many paths one `git hash-object` is handed, so that the argument list stays far
384/// below any platform's limit however many local files there are.
385const HASH_CHUNK: usize = 256;
386
387/// git's blob id of each file, in order. The paths are arguments, never lines of input: git
388/// prints one id per path it is given or fails, so the ids pair with the files one to one,
389/// whatever characters a file name holds.
390fn hash_objects(root: &Path, files: &[PathBuf]) -> Result<Vec<String>, String> {
391    let mut ids = Vec::with_capacity(files.len());
392    for chunk in files.chunks(HASH_CHUNK) {
393        let paths: Vec<String> = chunk.iter().map(|f| f.display().to_string()).collect();
394        let mut args = vec!["hash-object", "--no-filters", "--"];
395        args.extend(paths.iter().map(String::as_str));
396        ids.extend(git_lines(root, &args)?);
397    }
398    Ok(ids)
399}
400
401/// The tracked path and its blob id, from one line of `git ls-files -s`
402/// (`<mode> <blob> <stage>\t<path>`); none from a line of another shape.
403fn staged_blob(line: &str) -> Option<(String, String)> {
404    let (meta, path) = line.split_once('\t')?;
405    let blob = meta.split_whitespace().nth(1)?;
406    Some((blob.to_string(), path.to_string()))
407}
408
409/// The lines git prints for `args` at `root`.
410fn git_lines(root: &Path, args: &[&str]) -> Result<Vec<String>, String> {
411    program_lines("git", root, args)
412}
413
414/// The lines `program`, a git, prints for `args` at `root`: the seam a test names a program
415/// that cannot start through.
416fn program_lines(program: &str, root: &Path, args: &[&str]) -> Result<Vec<String>, String> {
417    let out = Command::new(program)
418        .arg("-C")
419        .arg(root)
420        .args(args)
421        .output()
422        .map_err(|e| format!("git could not start: {e}"))?;
423    if !out.status.success() {
424        return Err(format!(
425            "git {} failed: {}",
426            args.join(" "),
427            String::from_utf8_lossy(&out.stderr).trim()
428        ));
429    }
430    Ok(String::from_utf8_lossy(&out.stdout)
431        .lines()
432        .filter(|l| !l.is_empty())
433        .map(str::to_string)
434        .collect())
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::share::Share;
441    use crate::synthetic::{crate_share, SyntheticRepository};
442
443    const RULE: &str = ".ai/repo/rules/project/rule-0.v1.md";
444
445    /// A synthetic layer whose tracked half is in git's index, its local half declared to
446    /// exclude canonical knowledge when `excludes` says so.
447    fn layer(excludes: bool) -> SyntheticRepository {
448        let repo = SyntheticRepository::small().expect("a synthetic repository");
449        if excludes {
450            let manifest = repo.root().join(".ai/manifest.yaml");
451            let text = std::fs::read_to_string(&manifest).unwrap().replace(
452                "  implicit_context: false\n",
453                "  implicit_context: false\n  lifecycle: runtime\n  excludes: [canonical]\n",
454            );
455            std::fs::write(&manifest, text).unwrap();
456        }
457        git(repo.root(), &["init", "-q"]);
458        git(repo.root(), &["add", ".ai"]);
459        repo
460    }
461
462    fn git(root: &Path, args: &[&str]) {
463        let status = Command::new("git")
464            .arg("-C")
465            .arg(root)
466            .args(args)
467            .status()
468            .expect("git");
469        assert!(status.success(), "git {}", args.join(" "));
470    }
471
472    fn local(repo: &SyntheticRepository, rel: &str, body: &[u8]) {
473        let path = repo.root().join(".ai/local").join(rel);
474        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
475        std::fs::write(path, body).unwrap();
476    }
477
478    fn checked(repo: &SyntheticRepository) -> LayoutReport {
479        let repository = Repository::discover(repo.root()).unwrap();
480        let share = Share::locate(Some(&crate_share()), repo.root()).unwrap();
481        let kinds = KindSchema::load(&share, &repository).unwrap();
482        check(&repository, &repo.index().unwrap(), &kinds)
483    }
484
485    #[test]
486    fn a_local_copy_of_a_tracked_file_is_refused_and_names_the_original() {
487        let repo = layer(true);
488        local(
489            &repo,
490            "notes/copied.md",
491            &std::fs::read(repo.root().join(RULE)).unwrap(),
492        );
493        local(&repo, "notes/mine.md", b"what this machine noticed today\n");
494        local(&repo, "empty.md", b"");
495
496        let report = checked(&repo);
497        assert!(report.measured, "{:?}", report.reason);
498        assert!(!report.passes);
499        assert_eq!(report.findings.len(), 1, "{:?}", report.findings);
500        let f = &report.findings[0];
501        assert_eq!(f.path, ".ai/local/notes/copied.md");
502        assert_eq!(f.contract, "manifest local");
503        assert_eq!(f.excludes, "canonical");
504        assert!(f.reason.contains(RULE), "{}", f.reason);
505        // every tracked file of the tracked half, and the two local files with content
506        let tracked = git_lines(repo.root(), &["ls-files", "--", ".ai/repo"]).unwrap();
507        assert_eq!(report.checked, tracked.len() + 2);
508    }
509
510    #[test]
511    fn a_local_file_declaring_a_canonical_schema_is_refused_and_a_runtime_one_is_not() {
512        let repo = layer(true);
513        local(
514            &repo,
515            "drafts/I9999.yaml",
516            b"schema: majordomus.issue/v1\nid: I9999\n",
517        );
518        local(
519            &repo,
520            "sessions/now.md",
521            b"---\nschema: \"majordomus.session-context/v1\"\n---\n\nruntime state\n",
522        );
523
524        let report = checked(&repo);
525        let paths: Vec<&str> = report.findings.iter().map(|f| f.path.as_str()).collect();
526        assert_eq!(paths, [".ai/local/drafts/I9999.yaml"]);
527        assert!(
528            report.findings[0]
529                .reason
530                .contains("schema: majordomus.issue/v1"),
531            "{}",
532            report.findings[0].reason
533        );
534    }
535
536    #[test]
537    fn a_local_half_that_excludes_nothing_is_not_judged() {
538        let repo = layer(false);
539        local(
540            &repo,
541            "notes/copied.md",
542            &std::fs::read(repo.root().join(RULE)).unwrap(),
543        );
544
545        let report = checked(&repo);
546        assert!(report.passes, "{:?}", report.findings);
547        let tracked = git_lines(repo.root(), &["ls-files", "--", ".ai/repo"]).unwrap();
548        assert_eq!(
549            report.checked,
550            tracked.len(),
551            "the local half is not walked"
552        );
553    }
554
555    #[test]
556    fn a_root_git_cannot_read_is_unmeasured_and_does_not_pass() {
557        let repo = SyntheticRepository::small().unwrap();
558        if git_lines(repo.root(), &["rev-parse", "--show-toplevel"]).is_ok() {
559            return; // the temporary directory lies inside a work tree; nothing to prove here
560        }
561        let report = checked(&repo);
562        assert!(!report.measured);
563        assert!(!report.passes);
564        assert_eq!(report.checked, 0);
565        assert!(report.reason.unwrap().contains("ls-files"));
566    }
567
568    /// Findings are listed by path and, where two contracts refuse one path, by contract,
569    /// whatever order the walk found them in.
570    #[test]
571    fn findings_are_ordered_by_path_and_then_by_contract() {
572        let at = |path: &str, contract: &str| LayoutFinding {
573            path: path.into(),
574            contract: contract.into(),
575            excludes: "canonical".into(),
576            reason: "a copy".into(),
577        };
578        let found = vec![
579            at(".ai/local/b.md", "manifest local"),
580            at(".ai/local/a.md", "ai.repo"),
581            at(".ai/local/a.md", "ai.local"),
582        ];
583        let report = LayoutReport::of(true, None, 3, found);
584        let order: Vec<(&str, &str)> = report
585            .findings
586            .iter()
587            .map(|f| (f.path.as_str(), f.contract.as_str()))
588            .collect();
589        assert_eq!(
590            order,
591            [
592                (".ai/local/a.md", "ai.local"),
593                (".ai/local/a.md", "ai.repo"),
594                (".ai/local/b.md", "manifest local"),
595            ]
596        );
597    }
598
599    /// A context document under the tracked half that declares a layout.
600    fn contract(repo: &SyntheticRepository, dir: &str, id: &str, excludes: &str) {
601        let path = repo.root().join(dir).join("README.md");
602        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
603        let text = format!(
604            "---\nschema: context/v1\nid: {id}\nkind: context\ntitle: {id}\n\
605             description: The {id} section.\nstatus: active\nscope: subtree\n\
606             providers: [\"*\"]\naudience: [human, agent]\ncomposition: extend\n\
607             order: 20\nlayout:\n  lifecycle: canonical\n  excludes: [{excludes}]\n---\n\n\
608             # {id}\n"
609        );
610        std::fs::write(path, text).unwrap();
611    }
612
613    /// A tracked file git's ignore rules match is refused under the nearest contract that
614    /// excludes cache or runtime, and only there: not under a contract that excludes
615    /// something else, and not where no contract declares a layout.
616    #[test]
617    fn a_tracked_file_the_ignore_rules_match_is_refused_only_where_a_layout_excludes_it() {
618        let repo = SyntheticRepository::small().unwrap();
619        let sources = repo.root().join(".ai/repo/knowledge/sources.yaml");
620        let text = std::fs::read_to_string(&sources).unwrap().replacen(
621            "sources:\n",
622            "sources:\n  - id: context\n    kind: context\n    discovery: vcs\n    \
623             pathspec: ':(glob).ai/**/README.md'\n    required: false\n",
624            1,
625        );
626        std::fs::write(&sources, text).unwrap();
627        contract(&repo, ".ai/repo/state", "ai.state", "runtime");
628        contract(&repo, ".ai/repo/profiles", "ai.profiles", "canonical");
629        for rel in [
630            ".ai/repo/state/deep/now.state",
631            ".ai/repo/profiles/stray.state",
632            ".ai/repo/stray.state",
633        ] {
634            std::fs::create_dir_all(repo.root().join(rel).parent().unwrap()).unwrap();
635            std::fs::write(repo.root().join(rel), "pid: 42\n").unwrap();
636        }
637        git(repo.root(), &["init", "-q"]);
638        git(repo.root(), &["add", ".ai"]);
639        std::fs::write(repo.root().join(".gitignore"), "*.state\n").unwrap();
640
641        let report = checked(&repo);
642        assert!(report.measured, "{:?}", report.reason);
643        assert!(!report.passes);
644        assert_eq!(report.findings.len(), 1, "{:?}", report.findings);
645        let f = &report.findings[0];
646        assert_eq!(f.path, ".ai/repo/state/deep/now.state");
647        assert_eq!(f.contract, "ai.state");
648        assert_eq!(f.excludes, "runtime");
649        assert!(
650            f.reason.contains("git's ignore rules match it"),
651            "{}",
652            f.reason
653        );
654    }
655
656    /// A local half that does not exist holds nothing to refuse: the check passes, having
657    /// judged the tracked half alone, without asking git to hash anything.
658    #[test]
659    fn an_absent_local_half_passes_having_judged_the_tracked_half() {
660        let repo = layer(true);
661        assert!(!repo.root().join(".ai/local").exists());
662
663        let report = checked(&repo);
664        assert!(report.passes, "{:?} {:?}", report.reason, report.findings);
665        let tracked = git_lines(repo.root(), &["ls-files", "--", ".ai/repo"]).unwrap();
666        assert_eq!(report.checked, tracked.len());
667        assert_eq!(hash_objects(repo.root(), &[]), Ok(Vec::new()));
668    }
669
670    /// A local file git cannot hash — one nobody may read — leaves the check unmeasured,
671    /// never passing.
672    #[cfg(unix)]
673    #[test]
674    fn a_local_file_git_cannot_hash_leaves_the_check_unmeasured() {
675        use std::os::unix::fs::PermissionsExt;
676        let repo = layer(true);
677        local(&repo, "notes/sealed.md", b"a note\n");
678        let sealed = repo.root().join(".ai/local/notes/sealed.md");
679        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o000)).unwrap();
680
681        let report = checked(&repo);
682        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o644)).unwrap();
683        assert!(!report.measured);
684        assert!(!report.passes);
685        let reason = report.reason.unwrap();
686        assert!(
687            reason.contains("git hash-object") && reason.contains("failed"),
688            "{reason}"
689        );
690    }
691
692    /// A file name holding a line break is one path, hashed as that file: the paths reach
693    /// git as arguments, never as lines it would split.
694    #[test]
695    fn a_file_name_with_a_line_break_is_hashed_as_one_file() {
696        let dir = tempfile::tempdir().unwrap();
697        let odd = dir.path().join("one\ntwo.md");
698        std::fs::write(&odd, "two\n").unwrap();
699        let plain = dir.path().join("two.md");
700        std::fs::write(&plain, "two\n").unwrap();
701
702        let ids = hash_objects(dir.path(), &[odd, plain]).unwrap();
703        assert_eq!(ids.len(), 2, "{ids:?}");
704        assert_eq!(ids[0], ids[1], "the same bytes are the same blob");
705    }
706
707    /// Only a `git ls-files -s` line of its own shape names a blob and a path.
708    #[test]
709    fn a_staged_line_names_its_blob_and_its_path() {
710        assert_eq!(
711            staged_blob("100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0\t.ai/repo/a.md"),
712            Some((
713                "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391".to_string(),
714                ".ai/repo/a.md".to_string()
715            ))
716        );
717        assert_eq!(staged_blob("no tab here"), None);
718        assert_eq!(staged_blob("100644\t.ai/repo/a.md"), None);
719    }
720
721    /// A git that cannot start is said as such, and nothing is listed.
722    #[test]
723    fn a_git_that_cannot_start_is_said() {
724        let dir = tempfile::tempdir().unwrap();
725        let missing = dir.path().join("no-such-git");
726        let err = program_lines(missing.to_str().unwrap(), dir.path(), &["status"]).unwrap_err();
727        assert!(err.starts_with("git could not start"), "{err}");
728    }
729
730    /// A declared schema is read only from a file that exists, fits the head budget and is
731    /// text; from anything else nothing is read, and the file is judged by its bytes alone.
732    #[test]
733    fn a_schema_is_read_only_from_a_small_text_file() {
734        let dir = tempfile::tempdir().unwrap();
735        let small = dir.path().join("small.yaml");
736        std::fs::write(&small, "schema: 'majordomus.issue/v1'\nid: I1\n").unwrap();
737        assert_eq!(
738            declared_schema(&small).as_deref(),
739            Some("majordomus.issue/v1")
740        );
741
742        assert_eq!(declared_schema(&dir.path().join("missing.yaml")), None);
743
744        let large = dir.path().join("large.yaml");
745        let mut body = b"schema: majordomus.issue/v1\n".to_vec();
746        body.resize(HEAD_BYTES as usize + 1, b'#');
747        std::fs::write(&large, body).unwrap();
748        assert_eq!(declared_schema(&large), None, "larger than the head budget");
749
750        let binary = dir.path().join("binary.yaml");
751        std::fs::write(&binary, b"schema: majordomus.issue/v1\n\xff\xfe\n").unwrap();
752        assert_eq!(declared_schema(&binary), None, "not UTF-8");
753    }
754}