Skip to main content

spec_driven_docs/gates/
instance_manifest.rs

1//! Gate: the instance manifest is present and fits the schema.
2//!
3//! The manifest is what every other ownership check reads, so a shape error
4//! in it disables them all at once. Only the shape is judged here; comparing
5//! the recorded hashes to the disk is `sdd verify`, which the managed block
6//! wires as its own hook.
7
8use crate::domain::finding::Finding;
9use crate::domain::manifest::{MANIFEST_PATH, Manifest};
10use crate::domain::rule_id::RuleId;
11use crate::gates::{GateCtx, GateResult, Violation};
12
13/// The rules this gate can cite.
14pub const CITES: &[RuleId] = &[RuleId::TheManifestStaysReadable];
15
16/// Judge the manifest's shape.
17///
18/// # Errors
19///
20/// None; an unreadable manifest is the violation itself.
21pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
22    let violation = |detail: &str| {
23        vec![Violation::Finding(Finding::on_file(
24            RuleId::TheManifestStaysReadable,
25            MANIFEST_PATH,
26            detail,
27        ))]
28    };
29    // The manifest is this gate's subject here, not support: it judges the
30    // file's own shape.
31    if ctx.retained([MANIFEST_PATH]).is_empty() {
32        return Ok(vec![]);
33    }
34    let Ok(text) = std::fs::read_to_string(ctx.path(MANIFEST_PATH)) else {
35        return Ok(violation("invalid manifest shape"));
36    };
37    match Manifest::parse(&text) {
38        Ok(_) => Ok(vec![]),
39        Err(error) => Ok(violation(&format!("invalid manifest shape: {error}"))),
40    }
41}
42
43#[cfg(test)]
44mod tests {
45    use super::*;
46
47    fn run_with_manifest(json: Option<&str>) -> Vec<String> {
48        let dir = tempfile::tempdir().unwrap();
49        if let Some(json) = json {
50            let instance = dir.path().join(".spec-driven-docs");
51            std::fs::create_dir_all(&instance).unwrap();
52            std::fs::write(instance.join("manifest.json"), json).unwrap();
53        }
54        let ctx = GateCtx::new(dir.path().to_str().unwrap());
55        run(&ctx, &[])
56            .unwrap()
57            .iter()
58            .map(ToString::to_string)
59            .collect()
60    }
61
62    const VALID: &str = r#"{
63        "schema_version": 3,
64        "canon_version": "0.2.0",
65        "canon_source": "https://github.com/gubasso/spec-driven-docs",
66        "profile": "knowledge-base",
67        "docs_root": "_docs",
68        "installed_at": "2026-08-25T00:00:00Z",
69        "managed_files": [
70            {"source": ".markdownlint/spec.markdownlint-cli2.jsonc",
71             "destination": ".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc",
72             "sha256": "dc17d596ae2c196cc01b439c291416f91198cc274e2376fd01a4d614c1ff60ad"}
73        ],
74        "adopted_files": [],
75        "integration_blocks": []
76    }"#;
77
78    #[test]
79    fn accepts_a_current_schema_manifest() {
80        assert!(run_with_manifest(Some(VALID)).is_empty());
81    }
82
83    #[test]
84    fn rejects_a_missing_or_older_manifest() {
85        let missing = run_with_manifest(None);
86        assert_eq!(missing.len(), 1);
87        assert!(missing[0].contains("instance:the-manifest-stays-readable"));
88
89        let older = VALID.replace("\"schema_version\": 3", "\"schema_version\": 1");
90        let out = run_with_manifest(Some(&older));
91        assert_eq!(out.len(), 1);
92        assert!(out[0].contains("invalid manifest shape: manifest schema_version 1"));
93    }
94}