spec_driven_docs/services/
self_manifest.rs1use 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
42fn 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
62pub 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 #[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 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 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 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}