spec_driven_docs/services/
upgrader.rs1use camino::{Utf8Path, Utf8PathBuf};
11
12use crate::adapters::fs::sha256_file;
13use crate::domain::manifest::{LegacyManifest, MANIFEST_PATH, Manifest, ManifestParseError};
14use crate::domain::ownership::Sha256;
15use crate::domain::profile::ProfileId;
16use crate::domain::version::CanonVersion;
17use crate::error::AppError;
18use crate::services::installer::{InitOptions, init};
19
20#[derive(Debug, Clone)]
22pub struct UpgradeOptions {
23 pub target: Utf8PathBuf,
25 pub dry_run: bool,
27}
28
29#[derive(Debug, Default)]
31pub struct UpgradeOutcome {
32 pub lines: Vec<String>,
34 pub failures: usize,
36}
37
38struct Installed {
39 version: CanonVersion,
40 profile: ProfileId,
41 docs_root: String,
42 managed: Vec<(Utf8PathBuf, Sha256)>,
43 integration: Vec<(Utf8PathBuf, Sha256)>,
44 schema_current: bool,
51}
52
53fn markers_for(path: &str) -> (&'static str, &'static str) {
55 use crate::domain::marker::{AGENTS_BEGIN, AGENTS_END, BEGIN, END};
56 if path == ".pre-commit-config.yaml" {
57 (BEGIN, END)
58 } else {
59 (AGENTS_BEGIN, AGENTS_END)
60 }
61}
62
63fn read_installed(target: &Utf8Path) -> Result<Installed, AppError> {
64 let path = target.join(MANIFEST_PATH);
65 if !path.is_file() {
66 return Err(AppError::ManifestMissing(path));
67 }
68 let text = std::fs::read_to_string(&path)?;
69 match Manifest::parse(&text) {
70 Ok(manifest) => Ok(Installed {
71 version: manifest.canon_version,
72 profile: manifest.profile,
73 docs_root: manifest.docs_root.as_str().to_string(),
74 managed: manifest
75 .managed_files
76 .into_iter()
77 .map(|entry| (entry.destination, entry.sha256))
78 .collect(),
79 integration: manifest
80 .integration_blocks
81 .into_iter()
82 .map(|block| (block.path, block.marker_hash))
83 .collect(),
84 schema_current: true,
85 }),
86 Err(ManifestParseError::Older(_)) => {
87 let legacy: LegacyManifest = serde_json::from_str(&text)
88 .map_err(|e| AppError::ManifestInvalid(e.to_string()))?;
89 Ok(Installed {
90 version: legacy.canon_version,
91 profile: legacy.profile,
92 docs_root: legacy.docs_root.as_str().to_string(),
93 managed: legacy
94 .managed_files
95 .into_iter()
96 .map(|entry| (entry.destination, entry.sha256))
97 .collect(),
98 integration: legacy
99 .integration_blocks
100 .into_iter()
101 .map(|block| (block.path, block.marker_hash))
102 .collect(),
103 schema_current: false,
104 })
105 }
106 Err(error) => Err(AppError::ManifestInvalid(error.to_string())),
107 }
108}
109
110const PRUNABLE: &[&str] = &[".spec-driven-docs/", ".claude/skills/", ".agents/skills/"];
112
113fn prune_empty_parent(full: &Utf8Path, raw: &str, prunable: &str) {
119 let Some((relative_parent, _)) = raw.rsplit_once('/') else {
120 return;
121 };
122 if relative_parent == prunable.trim_end_matches('/') {
123 return;
124 }
125 let Some(directory) = full.parent() else {
126 return;
127 };
128 if std::fs::read_dir(directory).is_ok_and(|mut entries| entries.next().is_none()) {
129 let _ = std::fs::remove_dir(directory);
130 }
131}
132
133fn prune(
134 target: &Utf8Path,
135 dropped: &[Utf8PathBuf],
136 outcome: &mut UpgradeOutcome,
137) -> Vec<Utf8PathBuf> {
138 let mut unremoved = Vec::new();
139 for destination in dropped {
140 let raw = destination.as_str();
141 if raw.starts_with('/')
142 || raw == ".."
143 || raw.starts_with("../")
144 || raw.ends_with("/..")
145 || raw.contains("/../")
146 {
147 outcome.lines.push(format!(
148 "refused to remove a destination that leaves the target: {raw}"
149 ));
150 outcome.failures += 1;
151 continue;
152 }
153 let Some(prunable) = PRUNABLE.iter().find(|prefix| raw.starts_with(**prefix)) else {
154 continue;
155 };
156 let mut prefix = target.to_path_buf();
157 let parts: Vec<&str> = raw.split('/').collect();
158 let mut escapes = false;
159 for part in &parts[..parts.len() - 1] {
160 prefix.push(part);
161 if prefix.is_symlink() {
162 escapes = true;
163 }
164 }
165 let full = target.join(destination);
166 if escapes || full.is_symlink() {
167 outcome.lines.push(format!(
168 "refused to remove a destination reached through a symlink: {raw}"
169 ));
170 outcome.failures += 1;
171 continue;
172 }
173 if !full.is_file() {
174 continue;
175 }
176 if std::fs::remove_file(&full).is_ok() {
177 outcome
178 .lines
179 .push(format!("removed managed file no longer owned: {raw}"));
180 prune_empty_parent(&full, raw, prunable);
181 } else {
182 unremoved.push(destination.clone());
183 }
184 }
185 unremoved
186}
187
188pub fn upgrade(options: &UpgradeOptions) -> Result<UpgradeOutcome, AppError> {
197 if !options.target.is_absolute() {
198 return Err(AppError::Usage("target must be absolute".to_string()));
199 }
200 if !options.target.is_dir() {
201 return Err(AppError::Usage(format!(
202 "unresolved target: {}",
203 options.target
204 )));
205 }
206 let target = Utf8PathBuf::from_path_buf(std::fs::canonicalize(&options.target)?)
207 .map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
208
209 let installed = read_installed(&target)?;
210 let new = CanonVersion::current();
211 let old = installed.version;
212 let mut outcome = UpgradeOutcome::default();
213
214 if old == new && installed.schema_current {
215 outcome.lines.push(format!("OK already at {new}"));
216 return Ok(outcome);
217 }
218 if old > new {
219 return Err(AppError::Refused(format!(
220 "sdd {new} is older than the installed canon {old}; upgrade sdd"
221 )));
222 }
223
224 let mut conflicts = Vec::new();
225 for (destination, recorded) in &installed.managed {
226 let file = target.join(destination);
227 if !file.is_file() {
228 conflicts.push(format!("CONFLICT missing managed file: {destination}"));
229 continue;
230 }
231 if sha256_file(&file)? != *recorded {
232 conflicts.push(format!(
233 "CONFLICT locally edited managed file: {destination}"
234 ));
235 }
236 }
237 for (path, recorded) in &installed.integration {
241 let full = target.join(path);
242 if !full.is_file() {
243 conflicts.push(format!("CONFLICT missing integration host: {path}"));
244 continue;
245 }
246 let (begin, end) = markers_for(path.as_str());
247 let host = std::fs::read_to_string(&full)?;
248 match crate::domain::marker::block_hash_with(&host, begin, end) {
249 Some(present) if present == *recorded => {}
250 _ => conflicts.push(format!("CONFLICT locally edited managed block: {path}")),
251 }
252 }
253 if !conflicts.is_empty() {
254 let count = conflicts.len();
255 outcome.lines.extend(conflicts);
256 outcome.failures += count;
257 return Ok(outcome);
258 }
259
260 if options.dry_run {
261 if old == new {
264 outcome
265 .lines
266 .push(format!("DRY RUN migrate the record of {new}"));
267 } else {
268 outcome
269 .lines
270 .push(format!("DRY RUN upgrade {old} to {new}"));
271 }
272 return Ok(outcome);
273 }
274
275 let reinstalled = init(&InitOptions {
276 target: target.clone(),
277 profile: installed.profile,
278 apply: true,
279 dry_run: false,
280 plan_zone: None,
284 docs_scratch: None,
285 reserve: Vec::new(),
286 writing_style: None,
287 })
288 .map_err(|error| {
289 AppError::Refused(format!(
290 "upgrade aborted during reinstall from {old} to {new}: {error}"
291 ))
292 })?;
293 outcome.lines.extend(
297 reinstalled
298 .lines
299 .into_iter()
300 .filter(|line| line.starts_with("note:")),
301 );
302
303 finish(&target, &installed, old, new, &mut outcome)?;
304 Ok(outcome)
305}
306
307fn finish(
308 target: &Utf8Path,
309 installed: &Installed,
310 old: CanonVersion,
311 new: CanonVersion,
312 outcome: &mut UpgradeOutcome,
313) -> Result<(), AppError> {
314 let fresh = Manifest::parse(&std::fs::read_to_string(target.join(MANIFEST_PATH))?)
315 .map_err(|error| AppError::ManifestInvalid(error.to_string()))?;
316 let kept: std::collections::BTreeSet<&Utf8PathBuf> = fresh
317 .managed_files
318 .iter()
319 .map(|entry| &entry.destination)
320 .collect();
321 let dropped: Vec<Utf8PathBuf> = installed
322 .managed
323 .iter()
324 .map(|(destination, _)| destination.clone())
325 .filter(|destination| !kept.contains(destination))
326 .collect();
327 let unremoved = prune(target, &dropped, outcome);
328 if !unremoved.is_empty() {
329 outcome.lines.push(
330 "FAIL these files are no longer owned and could not be removed; delete them by hand:"
331 .to_string(),
332 );
333 for destination in &unremoved {
334 outcome.lines.push(format!(" {destination}"));
335 }
336 outcome.lines.push(format!(
337 "the payload and manifest are upgraded to {new}; only these removals remain, and"
338 ));
339 outcome.lines.push(format!(
340 "re-running this upgrade will report 'already at {new}' rather than retry them"
341 ));
342 outcome.failures += unremoved.len();
343 }
344
345 let mut local_ids = std::collections::BTreeSet::new();
346 let specs = target.join(&installed.docs_root).join("specs");
347 if let Ok(entries) = specs.read_dir_utf8() {
348 for entry in entries.filter_map(Result::ok) {
349 if let Ok(text) = std::fs::read_to_string(entry.path()) {
350 local_ids.extend(crate::embedded::rule_ids_in(&text));
351 }
352 }
353 }
354 let upstream_only: Vec<String> = crate::embedded::spec_rule_ids()
355 .difference(&local_ids)
356 .cloned()
357 .collect();
358 if !upstream_only.is_empty() {
359 outcome
360 .lines
361 .push("upstream rule IDs not present locally:".to_string());
362 for id in upstream_only {
363 outcome.lines.push(format!(" {id}"));
364 }
365 }
366
367 if outcome.failures > 0 {
368 outcome.lines.push(format!(
369 "FAIL upgraded {old} to {new} with unfinished removals above"
370 ));
371 } else if old == new {
372 outcome
373 .lines
374 .push(format!("OK migrated the record of {new}"));
375 } else {
376 outcome.lines.push(format!("OK upgraded {old} to {new}"));
377 }
378 Ok(())
379}