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/// Regenerate `.spec-driven-docs/manifest.json` in the canon checkout.
44///
45/// # Errors
46///
47/// [`AppError::Refused`] outside the canon checkout, and I/O errors when a
48/// recorded file cannot be read or the manifest cannot be written.
49pub fn regenerate(root: &Utf8Path) -> Result<String, AppError> {
50    if !is_canon_checkout(root) {
51        return Err(AppError::Refused("not the canon checkout".to_string()));
52    }
53
54    let mut managed = Vec::new();
55    // The payload convention is lowercase; the shell glob this replaces was
56    // case-sensitive too.
57    #[allow(clippy::case_sensitive_file_extension_comparisons)]
58    let jsonc = |name: &str| name.ends_with(".jsonc");
59    for path in sorted_files(root, ".markdownlint", jsonc) {
60        managed.push(ManagedEntry {
61            source: path.clone().into(),
62            destination: path.clone().into(),
63            sha256: sha256_file(&root.join(&path))?,
64        });
65    }
66
67    #[allow(clippy::case_sensitive_file_extension_comparisons)]
68    let spec = |name: &str| name.starts_with("SPEC-") && name.ends_with(".md");
69    let mut adopted_paths = sorted_files(root, "_docs/specs", spec);
70    adopted_paths.push("_docs/decisions/TEMPLATE-adr.md".to_string());
71    adopted_paths.push("_docs/reference/TEMPLATE-agents-digest.md".to_string());
72    let mut adopted = Vec::new();
73    for path in adopted_paths {
74        let digest = sha256_file(&root.join(&path))?;
75        adopted.push(AdoptedEntry {
76            source: path.clone().into(),
77            destination: path.into(),
78            sha256: digest.clone(),
79            baseline_sha256: digest,
80        });
81    }
82
83    let config = std::fs::read_to_string(root.join(".pre-commit-config.yaml"))?;
84    let marker_hash = crate::domain::marker::block_hash(&config).ok_or_else(|| {
85        AppError::Refused("no managed block in .pre-commit-config.yaml".to_string())
86    })?;
87
88    let installed_at = std::fs::read_to_string(root.join(MANIFEST_PATH))
89        .ok()
90        .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
91        .and_then(|value| {
92            value
93                .get("installed_at")
94                .and_then(|v| v.as_str())
95                .map(String::from)
96        })
97        .unwrap_or_else(|| {
98            jiff::Timestamp::now()
99                .strftime("%Y-%m-%dT%H:%M:%SZ")
100                .to_string()
101        });
102
103    let manifest = Manifest {
104        schema_version: SCHEMA_VERSION,
105        canon_version: CanonVersion::current(),
106        canon_source: CANON_SOURCE.to_string(),
107        profile: ProfileId::KnowledgeBase,
108        docs_root: DocsRoot::UnderscoreDocs,
109        installed_at,
110        managed_files: managed,
111        adopted_files: adopted,
112        integration_blocks: vec![IntegrationBlock {
113            path: ".pre-commit-config.yaml".into(),
114            marker_hash,
115        }],
116    };
117    crate::adapters::fs::write_file(&root.join(MANIFEST_PATH), manifest.to_json().as_bytes())?;
118    Ok(format!("OK regenerated {MANIFEST_PATH}"))
119}