Skip to main content

spec_driven_docs/services/
upgrader.rs

1//! Binary-driven instance upgrade.
2//!
3//! The newer binary carries the newer payload, so an upgrade is mechanical:
4//! refuse atomically while any managed file is locally edited, reinstall
5//! from the embedded payload, prune what the new version stopped managing —
6//! inside the vendored directory only, and never through a symlink — and
7//! report the rule-ID diff the operator reconciles by hand. Copier's model;
8//! what changed between versions is the changelog's business.
9
10use 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/// What an upgrade was asked to do.
21#[derive(Debug, Clone)]
22pub struct UpgradeOptions {
23    /// The absolute target instance.
24    pub target: Utf8PathBuf,
25    /// Report the plan and change nothing.
26    pub dry_run: bool,
27}
28
29/// What an upgrade did.
30#[derive(Debug, Default)]
31pub struct UpgradeOutcome {
32    /// Every line to print, in order.
33    pub lines: Vec<String>,
34    /// How many lines are conflicts or failures.
35    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
46/// The marker pair a host file's managed region uses.
47fn 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
97/// The only roots an upgrade may remove dropped managed files from.
98const PRUNABLE: &[&str] = &[".spec-driven-docs/", ".claude/skills/", ".agents/skills/"];
99
100/// Remove the directory a pruned file leaves empty, never the prunable root.
101///
102/// A skill is a directory holding one `SKILL.md`, so pruning the file alone
103/// leaves an empty directory carrying the old skill's name — which some
104/// agents still list.
105fn 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
175/// Upgrade an installed instance to this binary's version.
176///
177/// # Errors
178///
179/// [`AppError::Violations`] when conflicts block the upgrade or removals
180/// remain unfinished, [`AppError::Refused`] when the binary is older than
181/// the instance or the reinstall refuses, and manifest errors when the
182/// record cannot be read.
183pub 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    // A locally edited managed integration region is a conflict too: the
225    // reinstall re-splices the region, so an edit inside the markers would be
226    // lost. An edit outside the markers is the project's own and survives.
227    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}