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::paths::HOOKS_CONFIG_PATH;
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
43fn 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
63pub fn regenerate(
70 root: &Utf8Path,
71 bundle: &dyn crate::release::ReleaseBundle,
72) -> Result<String, AppError> {
73 let _released = bundle.manifest()?;
74 if !is_canon_checkout(root) {
75 return Err(AppError::Refused("not the canon checkout".to_string()));
76 }
77
78 let mut managed = Vec::new();
79 #[allow(
82 clippy::case_sensitive_file_extension_comparisons,
83 reason = "the payload convention is lowercase, as the glob it replaces was"
84 )]
85 let jsonc = |name: &str| name.ends_with(".jsonc");
86 for path in sorted_files(root, ".markdownlint", jsonc) {
87 managed.push(ManagedEntry {
88 source: path.clone().into(),
89 destination: path.clone().into(),
90 sha256: sha256_file(&root.join(&path))?,
91 });
92 }
93 for path in skill_files(root) {
94 managed.push(ManagedEntry {
95 source: path.clone().into(),
96 destination: path.clone().into(),
97 sha256: sha256_file(&root.join(&path))?,
98 });
99 }
100 for (path, _) in crate::embedded::shared_artifacts() {
103 let path = format!("skill-shared/{path}");
104 managed.push(ManagedEntry {
105 source: path.clone().into(),
106 destination: path.clone().into(),
107 sha256: sha256_file(&root.join(&path))?,
108 });
109 }
110 #[allow(
111 clippy::case_sensitive_file_extension_comparisons,
112 reason = "the spec convention is lowercase, as the glob it replaces was"
113 )]
114 let spec = |name: &str| name.starts_with("SPEC-") && name.ends_with(".md");
115 let mut adopted_paths = sorted_files(root, "_docs/specs", spec);
116 for template in crate::domain::profile::CANON_TEMPLATES.iter() {
117 adopted_paths.push((*template).to_string());
118 }
119 let mut adopted = Vec::new();
120 for path in adopted_paths {
121 let digest = sha256_file(&root.join(&path))?;
122 adopted.push(AdoptedEntry {
123 source: path.clone().into(),
124 destination: path.into(),
125 sha256: digest.clone(),
126 baseline_sha256: digest,
127 });
128 }
129 let registry = "_docs/reference/tracking.yaml";
132 let registry_baseline = "templates/TEMPLATE-tracking.yaml";
133 if root.join(registry).is_file() {
134 adopted.push(AdoptedEntry {
135 source: registry_baseline.into(),
136 destination: registry.into(),
137 sha256: sha256_file(&root.join(registry))?,
138 baseline_sha256: sha256_file(&root.join(registry_baseline))?,
139 });
140 }
141
142 let config = std::fs::read_to_string(root.join(HOOKS_CONFIG_PATH))?;
143 let marker_hash = crate::domain::marker::block_hash(&config).ok_or_else(|| {
144 AppError::Refused("no managed block in .pre-commit-config.yaml".to_string())
145 })?;
146
147 let installed_at = crate::services::installer::recorded_field(root, "installed_at")
152 .and_then(|value| value.as_str().map(String::from))
153 .unwrap_or_else(|| {
154 jiff::Timestamp::now()
155 .strftime("%Y-%m-%dT%H:%M:%SZ")
156 .to_string()
157 });
158 let plan_zone = crate::services::installer::resolved_plan_zone(root, None)?;
159 let docs_scratch = crate::services::installer::resolved_docs_scratch(root, None)?;
160
161 let manifest = Manifest {
162 schema_version: SCHEMA_VERSION,
163 canon_version: CanonVersion::current(),
164 canon_source: CANON_SOURCE.to_string(),
165 profile: ProfileId::KnowledgeBase,
166 docs_root: DocsRoot::UnderscoreDocs,
167 installed_at,
168 plan_zone,
169 docs_scratch,
170 managed_files: managed,
171 adopted_files: adopted,
172 integration_blocks: vec![IntegrationBlock {
173 path: HOOKS_CONFIG_PATH.into(),
174 marker_hash,
175 }],
176 };
177 crate::adapters::fs::write_file(&root.join(MANIFEST_PATH), manifest.to_json().as_bytes())?;
178 Ok(format!("OK regenerated {MANIFEST_PATH}"))
179}