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_with};
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    /// Every decision the operator answered on the command line.
28    pub selections: crate::plan::decision::Selections,
29}
30
31/// What an upgrade did.
32#[derive(Debug, Default)]
33pub struct UpgradeOutcome {
34    /// Every line to print, in order.
35    pub lines: Vec<String>,
36    /// How many lines are conflicts or failures.
37    pub failures: usize,
38}
39
40struct Installed {
41    version: CanonVersion,
42    profile: ProfileId,
43    docs_root: String,
44    managed: Vec<(Utf8PathBuf, Sha256)>,
45    integration: Vec<(Utf8PathBuf, Sha256)>,
46    /// Whether the record is already at this binary's schema.
47    ///
48    /// The two versions move independently, so an instance can carry this
49    /// binary's canon version and an older schema. Without this the "already
50    /// at" shortcut would return before migrating, while every other verb
51    /// refuses the record and sends the operator back here.
52    schema_current: bool,
53}
54
55/// The marker pair a host file's managed region uses.
56fn markers_for(path: &str) -> (&'static str, &'static str) {
57    use crate::domain::marker::{AGENTS_BEGIN, AGENTS_END, BEGIN, END};
58    if path == crate::domain::paths::HOOKS_CONFIG_PATH {
59        (BEGIN, END)
60    } else {
61        (AGENTS_BEGIN, AGENTS_END)
62    }
63}
64
65fn read_installed(target: &Utf8Path) -> Result<Installed, AppError> {
66    let path = target.join(MANIFEST_PATH);
67    if !path.is_file() {
68        return Err(AppError::ManifestMissing(path));
69    }
70    let text = std::fs::read_to_string(&path)?;
71    match Manifest::parse(&text) {
72        Ok(manifest) => Ok(Installed {
73            version: manifest.canon_version,
74            profile: manifest.profile,
75            docs_root: manifest.docs_root.as_str().to_string(),
76            managed: manifest
77                .managed_files
78                .into_iter()
79                .map(|entry| (entry.destination, entry.sha256))
80                .collect(),
81            integration: manifest
82                .integration_blocks
83                .into_iter()
84                .map(|block| (block.path, block.marker_hash))
85                .collect(),
86            schema_current: true,
87        }),
88        Err(ManifestParseError::Older(_)) => {
89            let legacy: LegacyManifest = serde_json::from_str(&text)
90                .map_err(|e| AppError::ManifestInvalid(e.to_string()))?;
91            Ok(Installed {
92                version: legacy.canon_version,
93                profile: legacy.profile,
94                docs_root: legacy.docs_root.as_str().to_string(),
95                managed: legacy
96                    .managed_files
97                    .into_iter()
98                    .map(|entry| (entry.destination, entry.sha256))
99                    .collect(),
100                integration: legacy
101                    .integration_blocks
102                    .into_iter()
103                    .map(|block| (block.path, block.marker_hash))
104                    .collect(),
105                schema_current: false,
106            })
107        }
108        Err(error) => Err(AppError::ManifestInvalid(error.to_string())),
109    }
110}
111
112/// Refuse an answer on a path where no plan offers a decision.
113///
114/// A target with nothing to do and one whose managed files were edited
115/// both end before a plan exists. An answer given on either is an answer
116/// to a question nobody asked, and letting it pass silently would make
117/// typing look like consent on the one path where nothing checked it.
118/// A downgrade refuses on its own terms before this can matter.
119///
120/// # Errors
121///
122/// [`AppError::Usage`] naming the decision nothing offered.
123fn unanswerable(selections: &crate::plan::decision::Selections) -> Result<(), AppError> {
124    crate::plan::decision::validate(&[], selections)
125        .map_err(|error| AppError::Usage(error.to_string()))
126}
127
128/// Every managed file and region the target no longer holds as recorded.
129///
130/// A reinstall replaces a managed file and re-splices a managed region, so
131/// an edit to either would be lost. An edit outside the markers is the
132/// project's own and survives, which is why the region is compared by its
133/// own hash rather than the host file's.
134///
135/// # Errors
136///
137/// Any I/O error reading a destination.
138fn conflicts_at(target: &Utf8Path, installed: &Installed) -> Result<Vec<String>, AppError> {
139    let mut conflicts = Vec::new();
140    for (destination, recorded) in &installed.managed {
141        let file = target.join(destination);
142        if !file.is_file() {
143            conflicts.push(format!("CONFLICT missing managed file: {destination}"));
144            continue;
145        }
146        if sha256_file(&file)? != *recorded {
147            conflicts.push(format!(
148                "CONFLICT locally edited managed file: {destination}"
149            ));
150        }
151    }
152    for (path, recorded) in &installed.integration {
153        let full = target.join(path);
154        if !full.is_file() {
155            conflicts.push(format!("CONFLICT missing integration host: {path}"));
156            continue;
157        }
158        let (begin, end) = markers_for(path.as_str());
159        let host = std::fs::read_to_string(&full)?;
160        match crate::domain::marker::block_hash_with(&host, begin, end) {
161            Some(present) if present == *recorded => {}
162            _ => conflicts.push(format!("CONFLICT locally edited managed block: {path}")),
163        }
164    }
165    Ok(conflicts)
166}
167
168/// The options a reinstall carries.
169///
170/// No flag: the reinstall carries the recorded declarations forward, and
171/// the project's own declaration file is adopted, so the reinstall reads
172/// it rather than replacing it.
173fn reinstall_options(target: &Utf8Path, profile: ProfileId) -> InitOptions {
174    InitOptions {
175        target: target.to_path_buf(),
176        profile,
177        apply: false,
178        dry_run: true,
179        plan_zone: None,
180        docs_scratch: None,
181        reserve: Vec::new(),
182        writing_style: None,
183    }
184}
185
186/// Report what the landing took back.
187///
188/// The removals are the plan's own operations, applied under its journal,
189/// and the executor sweeps the directory each one emptied inside that
190/// same transaction. This only says what happened.
191fn report_removals(removed: &[String], outcome: &mut UpgradeOutcome) {
192    for raw in removed {
193        outcome
194            .lines
195            .push(format!("removed managed file no longer owned: {raw}"));
196    }
197}
198
199/// Upgrade an installed instance to this binary's version.
200///
201/// # Errors
202///
203/// [`AppError::Violations`] when conflicts block the upgrade or removals
204/// remain unfinished, [`AppError::Refused`] when the binary is older than
205/// the instance or the reinstall refuses, and manifest errors when the
206/// record cannot be read.
207pub fn upgrade(
208    options: &UpgradeOptions,
209    bundle: &dyn crate::release::ReleaseBundle,
210) -> Result<UpgradeOutcome, AppError> {
211    if !options.target.is_absolute() {
212        return Err(AppError::Usage("target must be absolute".to_string()));
213    }
214    if !options.target.is_dir() {
215        return Err(AppError::Usage(format!(
216            "unresolved target: {}",
217            options.target
218        )));
219    }
220    let target = Utf8PathBuf::from_path_buf(std::fs::canonicalize(&options.target)?)
221        .map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
222
223    let installed = read_installed(&target)?;
224    // The destination is the release the caller handed over, not the
225    // engine running. Reporting the engine's version would name a release
226    // the target does not hold, and every later classification reads it.
227    let new: CanonVersion = bundle
228        .manifest()?
229        .version
230        .to_string()
231        .parse()
232        .map_err(|_| AppError::Refused("the release is not a version triple".to_string()))?;
233    let old = installed.version;
234    let mut outcome = UpgradeOutcome::default();
235
236    if old == new && installed.schema_current {
237        unanswerable(&options.selections)?;
238        outcome.lines.push(format!("OK already at {new}"));
239        return Ok(outcome);
240    }
241    if old > new {
242        return Err(AppError::Refused(format!(
243            "sdd {new} is older than the installed canon {old}; upgrade sdd"
244        )));
245    }
246
247    let conflicts = conflicts_at(&target, &installed)?;
248    if !conflicts.is_empty() {
249        // The conflict is what the operator needs to see. An answer to a
250        // decision that never got offered is reported beside it rather
251        // than instead of it: turning it into the exit reason would hide
252        // the edited file behind a complaint about a flag.
253        if let Err(refused) = unanswerable(&options.selections) {
254            outcome.lines.push(format!("note: {refused}"));
255        }
256        let count = conflicts.len();
257        outcome.lines.extend(conflicts);
258        outcome.failures += count;
259        return Ok(outcome);
260    }
261
262    if options.dry_run {
263        // At the same canon version the work is the record's schema alone,
264        // so saying "upgrade X to X" would describe nothing.
265        if old == new {
266            outcome
267                .lines
268                .push(format!("DRY RUN migrate the record of {new}"));
269        } else {
270            outcome
271                .lines
272                .push(format!("DRY RUN upgrade {old} to {new}"));
273        }
274        // The preview is the plan. A release in the interval that asks
275        // something of a person is exactly what a dry run must show, and
276        // a version pair alone cannot show it.
277        let preview = init_with(
278            &options.selections,
279            &reinstall_options(&target, installed.profile),
280            bundle,
281            crate::plan::classify::Intent::Reconcile,
282        )
283        .map_err(|error| {
284            AppError::Refused(format!(
285                "upgrade could not be planned from {old} to {new}: {error}"
286            ))
287        })?;
288        outcome
289            .lines
290            .extend(preview.lines.into_iter().filter(|line| {
291                line.starts_with("BLOCKED")
292                    || line.starts_with("DECISION")
293                    || line.starts_with("note:")
294            }));
295        return Ok(outcome);
296    }
297
298    let reinstalled = init_with(
299        &options.selections,
300        &InitOptions {
301            apply: true,
302            dry_run: false,
303            ..reinstall_options(&target, installed.profile)
304        },
305        bundle,
306        // The upgrade already classified the target; the reinstall is its
307        // own act rather than a second landing decision.
308        crate::plan::classify::Intent::Reconcile,
309    )
310    .map_err(|error| {
311        AppError::Refused(format!(
312            "upgrade aborted during reinstall from {old} to {new}: {error}"
313        ))
314    })?;
315    // The reinstall's destination list is noise here, and its notes are
316    // not: a seed that did not land because the project already holds the
317    // destination is something the operator must hear about once.
318    let removed = reinstalled.removed.clone();
319    outcome.lines.extend(
320        reinstalled
321            .lines
322            .into_iter()
323            .filter(|line| line.starts_with("note:")),
324    );
325
326    finish(&target, &installed, &removed, old, new, &mut outcome);
327    Ok(outcome)
328}
329
330fn finish(
331    target: &Utf8Path,
332    installed: &Installed,
333    removed: &[String],
334    old: CanonVersion,
335    new: CanonVersion,
336    outcome: &mut UpgradeOutcome,
337) {
338    report_removals(removed, outcome);
339
340    let mut local_ids = std::collections::BTreeSet::new();
341    let specs = target.join(&installed.docs_root).join("specs");
342    if let Ok(entries) = specs.read_dir_utf8() {
343        for entry in entries.filter_map(Result::ok) {
344            if let Ok(text) = std::fs::read_to_string(entry.path()) {
345                local_ids.extend(crate::embedded::rule_ids_in(&text));
346            }
347        }
348    }
349    let upstream_only: Vec<String> = crate::embedded::spec_rule_ids()
350        .difference(&local_ids)
351        .cloned()
352        .collect();
353    if !upstream_only.is_empty() {
354        outcome
355            .lines
356            .push("upstream rule IDs not present locally:".to_string());
357        for id in upstream_only {
358            outcome.lines.push(format!("  {id}"));
359        }
360    }
361
362    if outcome.failures > 0 {
363        outcome.lines.push(format!(
364            "FAIL upgraded {old} to {new} with unfinished removals above"
365        ));
366    } else if old == new {
367        outcome
368            .lines
369            .push(format!("OK migrated the record of {new}"));
370    } else {
371        outcome.lines.push(format!("OK upgraded {old} to {new}"));
372    }
373}