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