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}
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
80/// The only roots an upgrade may remove dropped managed files from.
81const PRUNABLE: &[&str] = &[".spec-driven-docs/", ".claude/skills/", ".agents/skills/"];
82
83/// Remove the directory a pruned file leaves empty, never the prunable root.
84///
85/// A skill is a directory holding one `SKILL.md`, so pruning the file alone
86/// leaves an empty directory carrying the old skill's name — which some
87/// agents still list.
88fn 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
158/// Upgrade an installed instance to this binary's version.
159///
160/// # Errors
161///
162/// [`AppError::Violations`] when conflicts block the upgrade or removals
163/// remain unfinished, [`AppError::Refused`] when the binary is older than
164/// the instance or the reinstall refuses, and manifest errors when the
165/// record cannot be read.
166pub 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}