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    /// Whether the record is already at this binary's schema.
45    ///
46    /// The two versions move independently, so an instance can carry this
47    /// binary's canon version and an older schema. Without this the "already
48    /// at" shortcut would return before migrating, while every other verb
49    /// refuses the record and sends the operator back here.
50    schema_current: bool,
51}
52
53/// The marker pair a host file's managed region uses.
54fn 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
110/// The only roots an upgrade may remove dropped managed files from.
111const PRUNABLE: &[&str] = &[".spec-driven-docs/", ".claude/skills/", ".agents/skills/"];
112
113/// Remove the directory a pruned file leaves empty, never the prunable root.
114///
115/// A skill is a directory holding one `SKILL.md`, so pruning the file alone
116/// leaves an empty directory carrying the old skill's name — which some
117/// agents still list.
118fn 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
188/// Upgrade an installed instance to this binary's version.
189///
190/// # Errors
191///
192/// [`AppError::Violations`] when conflicts block the upgrade or removals
193/// remain unfinished, [`AppError::Refused`] when the binary is older than
194/// the instance or the reinstall refuses, and manifest errors when the
195/// record cannot be read.
196pub 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    // A locally edited managed integration region is a conflict too: the
238    // reinstall re-splices the region, so an edit inside the markers would be
239    // lost. An edit outside the markers is the project's own and survives.
240    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        // At the same canon version the work is the record's schema alone,
262        // so saying "upgrade X to X" would describe nothing.
263        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        // No flag: the reinstall carries the recorded declarations forward,
281        // and the project's own declaration file is adopted, so the
282        // reinstall reads it rather than replacing it.
283        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    // The reinstall's destination list is noise here, and its notes are
294    // not: a seed that did not land because the project already holds the
295    // destination is something the operator must hear about once.
296    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}