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