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}
45
46fn markers_for(path: &str) -> (&'static str, &'static str) {
48 use crate::domain::marker::{AGENTS_BEGIN, AGENTS_END, BEGIN, END};
49 if path == ".pre-commit-config.yaml" {
50 (BEGIN, END)
51 } else {
52 (AGENTS_BEGIN, AGENTS_END)
53 }
54}
55
56fn read_installed(target: &Utf8Path) -> Result<Installed, AppError> {
57 let path = target.join(MANIFEST_PATH);
58 if !path.is_file() {
59 return Err(AppError::ManifestMissing(path));
60 }
61 let text = std::fs::read_to_string(&path)?;
62 match Manifest::parse(&text) {
63 Ok(manifest) => Ok(Installed {
64 version: manifest.canon_version,
65 profile: manifest.profile,
66 docs_root: manifest.docs_root.as_str().to_string(),
67 managed: manifest
68 .managed_files
69 .into_iter()
70 .map(|entry| (entry.destination, entry.sha256))
71 .collect(),
72 integration: manifest
73 .integration_blocks
74 .into_iter()
75 .map(|block| (block.path, block.marker_hash))
76 .collect(),
77 }),
78 Err(ManifestParseError::Older(1)) => {
79 let legacy: LegacyManifest = serde_json::from_str(&text)
80 .map_err(|e| AppError::ManifestInvalid(e.to_string()))?;
81 Ok(Installed {
82 version: legacy.canon_version,
83 profile: legacy.profile,
84 docs_root: legacy.docs_root.as_str().to_string(),
85 managed: legacy
86 .managed_files
87 .into_iter()
88 .map(|entry| (entry.destination, entry.sha256))
89 .collect(),
90 integration: Vec::new(),
91 })
92 }
93 Err(error) => Err(AppError::ManifestInvalid(error.to_string())),
94 }
95}
96
97const PRUNABLE: &[&str] = &[".spec-driven-docs/", ".claude/skills/", ".agents/skills/"];
99
100fn prune_empty_parent(full: &Utf8Path, raw: &str, prunable: &str) {
106 let Some((relative_parent, _)) = raw.rsplit_once('/') else {
107 return;
108 };
109 if relative_parent == prunable.trim_end_matches('/') {
110 return;
111 }
112 let Some(directory) = full.parent() else {
113 return;
114 };
115 if std::fs::read_dir(directory).is_ok_and(|mut entries| entries.next().is_none()) {
116 let _ = std::fs::remove_dir(directory);
117 }
118}
119
120fn prune(
121 target: &Utf8Path,
122 dropped: &[Utf8PathBuf],
123 outcome: &mut UpgradeOutcome,
124) -> Vec<Utf8PathBuf> {
125 let mut unremoved = Vec::new();
126 for destination in dropped {
127 let raw = destination.as_str();
128 if raw.starts_with('/')
129 || raw == ".."
130 || raw.starts_with("../")
131 || raw.ends_with("/..")
132 || raw.contains("/../")
133 {
134 outcome.lines.push(format!(
135 "refused to remove a destination that leaves the target: {raw}"
136 ));
137 outcome.failures += 1;
138 continue;
139 }
140 let Some(prunable) = PRUNABLE.iter().find(|prefix| raw.starts_with(**prefix)) else {
141 continue;
142 };
143 let mut prefix = target.to_path_buf();
144 let parts: Vec<&str> = raw.split('/').collect();
145 let mut escapes = false;
146 for part in &parts[..parts.len() - 1] {
147 prefix.push(part);
148 if prefix.is_symlink() {
149 escapes = true;
150 }
151 }
152 let full = target.join(destination);
153 if escapes || full.is_symlink() {
154 outcome.lines.push(format!(
155 "refused to remove a destination reached through a symlink: {raw}"
156 ));
157 outcome.failures += 1;
158 continue;
159 }
160 if !full.is_file() {
161 continue;
162 }
163 if std::fs::remove_file(&full).is_ok() {
164 outcome
165 .lines
166 .push(format!("removed managed file no longer owned: {raw}"));
167 prune_empty_parent(&full, raw, prunable);
168 } else {
169 unremoved.push(destination.clone());
170 }
171 }
172 unremoved
173}
174
175pub fn upgrade(options: &UpgradeOptions) -> Result<UpgradeOutcome, AppError> {
184 if !options.target.is_absolute() {
185 return Err(AppError::Usage("target must be absolute".to_string()));
186 }
187 if !options.target.is_dir() {
188 return Err(AppError::Usage(format!(
189 "unresolved target: {}",
190 options.target
191 )));
192 }
193 let target = Utf8PathBuf::from_path_buf(std::fs::canonicalize(&options.target)?)
194 .map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
195
196 let installed = read_installed(&target)?;
197 let new = CanonVersion::current();
198 let old = installed.version;
199 let mut outcome = UpgradeOutcome::default();
200
201 if old == new {
202 outcome.lines.push(format!("OK already at {new}"));
203 return Ok(outcome);
204 }
205 if old > new {
206 return Err(AppError::Refused(format!(
207 "sdd {new} is older than the installed canon {old}; upgrade sdd"
208 )));
209 }
210
211 let mut conflicts = Vec::new();
212 for (destination, recorded) in &installed.managed {
213 let file = target.join(destination);
214 if !file.is_file() {
215 conflicts.push(format!("CONFLICT missing managed file: {destination}"));
216 continue;
217 }
218 if sha256_file(&file)? != *recorded {
219 conflicts.push(format!(
220 "CONFLICT locally edited managed file: {destination}"
221 ));
222 }
223 }
224 for (path, recorded) in &installed.integration {
228 let full = target.join(path);
229 if !full.is_file() {
230 conflicts.push(format!("CONFLICT missing integration host: {path}"));
231 continue;
232 }
233 let (begin, end) = markers_for(path.as_str());
234 let host = std::fs::read_to_string(&full)?;
235 match crate::domain::marker::block_hash_with(&host, begin, end) {
236 Some(present) if present == *recorded => {}
237 _ => conflicts.push(format!("CONFLICT locally edited managed block: {path}")),
238 }
239 }
240 if !conflicts.is_empty() {
241 let count = conflicts.len();
242 outcome.lines.extend(conflicts);
243 outcome.failures += count;
244 return Ok(outcome);
245 }
246
247 if options.dry_run {
248 outcome
249 .lines
250 .push(format!("DRY RUN upgrade {old} to {new}"));
251 return Ok(outcome);
252 }
253
254 init(&InitOptions {
255 target: target.clone(),
256 profile: installed.profile,
257 apply: true,
258 dry_run: false,
259 })
260 .map_err(|error| {
261 AppError::Refused(format!(
262 "upgrade aborted during reinstall from {old} to {new}: {error}"
263 ))
264 })?;
265
266 finish(&target, &installed, old, new, &mut outcome)?;
267 Ok(outcome)
268}
269
270fn finish(
271 target: &Utf8Path,
272 installed: &Installed,
273 old: CanonVersion,
274 new: CanonVersion,
275 outcome: &mut UpgradeOutcome,
276) -> Result<(), AppError> {
277 let fresh = Manifest::parse(&std::fs::read_to_string(target.join(MANIFEST_PATH))?)
278 .map_err(|error| AppError::ManifestInvalid(error.to_string()))?;
279 let kept: std::collections::BTreeSet<&Utf8PathBuf> = fresh
280 .managed_files
281 .iter()
282 .map(|entry| &entry.destination)
283 .collect();
284 let dropped: Vec<Utf8PathBuf> = installed
285 .managed
286 .iter()
287 .map(|(destination, _)| destination.clone())
288 .filter(|destination| !kept.contains(destination))
289 .collect();
290 let unremoved = prune(target, &dropped, outcome);
291 if !unremoved.is_empty() {
292 outcome.lines.push(
293 "FAIL these files are no longer owned and could not be removed; delete them by hand:"
294 .to_string(),
295 );
296 for destination in &unremoved {
297 outcome.lines.push(format!(" {destination}"));
298 }
299 outcome.lines.push(format!(
300 "the payload and manifest are upgraded to {new}; only these removals remain, and"
301 ));
302 outcome.lines.push(format!(
303 "re-running this upgrade will report 'already at {new}' rather than retry them"
304 ));
305 outcome.failures += unremoved.len();
306 }
307
308 let mut local_ids = std::collections::BTreeSet::new();
309 let specs = target.join(&installed.docs_root).join("specs");
310 if let Ok(entries) = specs.read_dir_utf8() {
311 for entry in entries.filter_map(Result::ok) {
312 if let Ok(text) = std::fs::read_to_string(entry.path()) {
313 local_ids.extend(crate::embedded::rule_ids_in(&text));
314 }
315 }
316 }
317 let upstream_only: Vec<String> = crate::embedded::spec_rule_ids()
318 .difference(&local_ids)
319 .cloned()
320 .collect();
321 if !upstream_only.is_empty() {
322 outcome
323 .lines
324 .push("upstream rule IDs not present locally:".to_string());
325 for id in upstream_only {
326 outcome.lines.push(format!(" {id}"));
327 }
328 }
329
330 if outcome.failures > 0 {
331 outcome.lines.push(format!(
332 "FAIL upgraded {old} to {new} with unfinished removals above"
333 ));
334 } else {
335 outcome.lines.push(format!("OK upgraded {old} to {new}"));
336 }
337 Ok(())
338}