Skip to main content

spec_driven_docs/services/
self_manifest.rs

1//! Regenerate the canon's own instance manifest.
2//!
3//! The canon is an instance of itself, but not one the installer can
4//! produce: its files sit where it authors them rather than under the
5//! vendored directory. Recording their hashes by hand is how the manifest
6//! drifts from the payload, so it is generated from the payload instead —
7//! and only in the canon checkout, which is recognised by its own crate
8//! manifest.
9
10use camino::Utf8Path;
11
12use crate::adapters::fs::sha256_file;
13use crate::domain::manifest::{CANON_SOURCE, MANIFEST_PATH, Manifest, SCHEMA_VERSION};
14use crate::domain::ownership::{AdoptedEntry, IntegrationBlock, ManagedEntry};
15use crate::domain::profile::{DocsRoot, ProfileId};
16use crate::domain::version::CanonVersion;
17use crate::error::AppError;
18
19pub(crate) fn is_canon_checkout(root: &Utf8Path) -> bool {
20    std::fs::read_to_string(root.join("Cargo.toml"))
21        .is_ok_and(|cargo| cargo.contains("name = \"spec-driven-docs\""))
22}
23
24fn sorted_files(root: &Utf8Path, dir: &str, matches: impl Fn(&str) -> bool) -> Vec<String> {
25    let mut names: Vec<String> = root
26        .join(dir)
27        .read_dir_utf8()
28        .map(|entries| {
29            entries
30                .filter_map(Result::ok)
31                .map(|entry| entry.file_name().to_string())
32                .filter(|name| matches(name))
33                .collect()
34        })
35        .unwrap_or_default();
36    names.sort();
37    names
38        .into_iter()
39        .map(|name| format!("{dir}/{name}"))
40        .collect()
41}
42
43/// Every `skills/<name>/SKILL.md` path in the checkout, sorted by name.
44fn skill_files(root: &Utf8Path) -> Vec<String> {
45    let mut names: Vec<String> = root
46        .join("skills")
47        .read_dir_utf8()
48        .map(|entries| {
49            entries
50                .filter_map(Result::ok)
51                .filter(|entry| entry.path().is_dir())
52                .map(|entry| entry.file_name().to_string())
53                .collect()
54        })
55        .unwrap_or_default();
56    names.sort();
57    names
58        .into_iter()
59        .map(|name| format!("skills/{name}/SKILL.md"))
60        .collect()
61}
62
63/// Regenerate `.spec-driven-docs/manifest.json` in the canon checkout.
64///
65/// # Errors
66///
67/// [`AppError::Refused`] outside the canon checkout, and I/O errors when a
68/// recorded file cannot be read or the manifest cannot be written.
69pub fn regenerate(root: &Utf8Path) -> Result<String, AppError> {
70    if !is_canon_checkout(root) {
71        return Err(AppError::Refused("not the canon checkout".to_string()));
72    }
73
74    let mut managed = Vec::new();
75    // The payload convention is lowercase; the shell glob this replaces was
76    // case-sensitive too.
77    // sdd: permanent the payload convention is lowercase, as the glob it replaces was
78    #[allow(clippy::case_sensitive_file_extension_comparisons)]
79    let jsonc = |name: &str| name.ends_with(".jsonc");
80    for path in sorted_files(root, ".markdownlint", jsonc) {
81        managed.push(ManagedEntry {
82            source: path.clone().into(),
83            destination: path.clone().into(),
84            sha256: sha256_file(&root.join(&path))?,
85        });
86    }
87    for path in skill_files(root) {
88        managed.push(ManagedEntry {
89            source: path.clone().into(),
90            destination: path.clone().into(),
91            sha256: sha256_file(&root.join(&path))?,
92        });
93    }
94    // The shared skill artifacts are payload the same way the skills are:
95    // the embedded inventory names them, so the record cannot miss one.
96    for (path, _) in crate::embedded::shared_artifacts() {
97        let path = format!("skill-shared/{path}");
98        managed.push(ManagedEntry {
99            source: path.clone().into(),
100            destination: path.clone().into(),
101            sha256: sha256_file(&root.join(&path))?,
102        });
103    }
104    // The vendored SimpleEnglish surface the profiles project into an
105    // instance. The canon records it at its authored path, so this record
106    // and an instance's hold the same bytes under different destinations.
107    for path in crate::domain::profile::SIMPLE_ENGLISH_MANAGED {
108        managed.push(ManagedEntry {
109            source: (*path).into(),
110            destination: (*path).into(),
111            sha256: sha256_file(&root.join(path))?,
112        });
113    }
114
115    // sdd: permanent the spec convention is lowercase, as the glob it replaces was
116    #[allow(clippy::case_sensitive_file_extension_comparisons)]
117    let spec = |name: &str| name.starts_with("SPEC-") && name.ends_with(".md");
118    let mut adopted_paths = sorted_files(root, "_docs/specs", spec);
119    for template in crate::domain::profile::CANON_TEMPLATES {
120        adopted_paths.push((*template).to_string());
121    }
122    let mut adopted = Vec::new();
123    for path in adopted_paths {
124        let digest = sha256_file(&root.join(&path))?;
125        adopted.push(AdoptedEntry {
126            source: path.clone().into(),
127            destination: path.into(),
128            sha256: digest.clone(),
129            baseline_sha256: digest,
130        });
131    }
132    // The dogfood tracking registry: the canon owns its populated bytes, and
133    // the tracking template is the baseline an instance seeds from.
134    let registry = "_docs/reference/tracking.yaml";
135    let registry_baseline = "templates/TEMPLATE-tracking.yaml";
136    if root.join(registry).is_file() {
137        adopted.push(AdoptedEntry {
138            source: registry_baseline.into(),
139            destination: registry.into(),
140            sha256: sha256_file(&root.join(registry))?,
141            baseline_sha256: sha256_file(&root.join(registry_baseline))?,
142        });
143    }
144
145    let config = std::fs::read_to_string(root.join(".pre-commit-config.yaml"))?;
146    let marker_hash = crate::domain::marker::block_hash(&config).ok_or_else(|| {
147        AppError::Refused("no managed block in .pre-commit-config.yaml".to_string())
148    })?;
149
150    // The three values the tree does not carry: they are declared once and
151    // read back from the record, the way an installed instance keeps them.
152    // Rebuilding this literal without them makes a regeneration erase this
153    // repository's own declarations.
154    let installed_at = crate::services::installer::recorded_field(root, "installed_at")
155        .and_then(|value| value.as_str().map(String::from))
156        .unwrap_or_else(|| {
157            jiff::Timestamp::now()
158                .strftime("%Y-%m-%dT%H:%M:%SZ")
159                .to_string()
160        });
161    let plan_zone = crate::services::installer::resolved_plan_zone(root, None)?;
162    let docs_scratch = crate::services::installer::resolved_docs_scratch(root, None)?;
163
164    let manifest = Manifest {
165        schema_version: SCHEMA_VERSION,
166        canon_version: CanonVersion::current(),
167        canon_source: CANON_SOURCE.to_string(),
168        profile: ProfileId::KnowledgeBase,
169        docs_root: DocsRoot::UnderscoreDocs,
170        installed_at,
171        plan_zone,
172        docs_scratch,
173        managed_files: managed,
174        adopted_files: adopted,
175        integration_blocks: vec![IntegrationBlock {
176            path: ".pre-commit-config.yaml".into(),
177            marker_hash,
178        }],
179    };
180    crate::adapters::fs::write_file(&root.join(MANIFEST_PATH), manifest.to_json().as_bytes())?;
181    Ok(format!("OK regenerated {MANIFEST_PATH}"))
182}