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, init_holding};
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 what would change 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 == crate::domain::paths::HOOKS_CONFIG_PATH {
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/// Refuse an answer on a path where no plan offers a decision.
111///
112/// A target with nothing to do and one whose managed files were edited
113/// both end before a plan exists. An answer given on either is an answer
114/// to a question nobody asked, and letting it pass silently would make
115/// typing look like consent on the one path where nothing checked it.
116/// A downgrade refuses on its own terms before this can matter.
117///
118/// # Errors
119///
120/// [`AppError::Usage`] naming the decision nothing offered.
121/// Every managed file and region the target no longer holds as recorded.
122///
123/// A reinstall replaces a managed file and re-splices a managed region, so
124/// an edit to either would be lost. An edit outside the markers is the
125/// project's own and survives, which is why the region is compared by its
126/// own hash rather than the host file's.
127///
128/// # Errors
129///
130/// Any I/O error reading a destination.
131fn conflicts_at(target: &Utf8Path, installed: &Installed) -> Result<Vec<String>, AppError> {
132    let mut conflicts = Vec::new();
133    for (destination, recorded) in &installed.managed {
134        let file = target.join(destination);
135        if !file.is_file() {
136            conflicts.push(format!("CONFLICT missing managed file: {destination}"));
137            continue;
138        }
139        if sha256_file(&file)? != *recorded {
140            conflicts.push(format!(
141                "CONFLICT locally edited managed file: {destination}"
142            ));
143        }
144    }
145    for (path, recorded) in &installed.integration {
146        let full = target.join(path);
147        if !full.is_file() {
148            conflicts.push(format!("CONFLICT missing integration host: {path}"));
149            continue;
150        }
151        let (begin, end) = markers_for(path.as_str());
152        let host = std::fs::read_to_string(&full)?;
153        match crate::domain::marker::block_hash_with(&host, begin, end) {
154            Some(present) if present == *recorded => {}
155            _ => conflicts.push(format!("CONFLICT locally edited managed block: {path}")),
156        }
157    }
158    Ok(conflicts)
159}
160
161/// The options a reinstall carries.
162///
163/// No flag: the reinstall carries the recorded declarations forward, and
164/// the project's own declaration file is adopted, so the reinstall reads
165/// it rather than replacing it.
166fn reinstall_options(target: &Utf8Path, profile: ProfileId) -> InitOptions {
167    InitOptions {
168        target: target.to_path_buf(),
169        profile,
170        apply: false,
171        dry_run: true,
172        docs_scratch: None,
173        reserve: Vec::new(),
174        writing_style: None,
175    }
176}
177
178/// Report what the landing took back.
179///
180/// The removals are the landing's own, taken only where the record
181/// vouched for the bytes it removed. This only says what happened.
182fn report_removals(removed: &[String], outcome: &mut UpgradeOutcome) {
183    for raw in removed {
184        outcome
185            .lines
186            .push(format!("removed managed file no longer owned: {raw}"));
187    }
188}
189
190/// Upgrade an installed instance to this binary's version.
191///
192/// # Errors
193///
194/// [`AppError::Violations`] when conflicts block the upgrade or removals
195/// remain unfinished, [`AppError::Refused`] when the binary is older than
196/// the instance or the reinstall refuses, and manifest errors when the
197/// record cannot be read.
198pub fn upgrade(options: &UpgradeOptions) -> Result<UpgradeOutcome, AppError> {
199    if !options.target.is_absolute() {
200        return Err(AppError::Usage("target must be absolute".to_string()));
201    }
202    if !options.target.is_dir() {
203        return Err(AppError::Usage(format!(
204            "unresolved target: {}",
205            options.target
206        )));
207    }
208    let target = Utf8PathBuf::from_path_buf(std::fs::canonicalize(&options.target)?)
209        .map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
210
211    // An apply holds the target for the whole run, the observation below
212    // included. Without it, a second run could read the tree another
213    // landing is halfway through and report that as drift rather than
214    // naming the holder.
215    let held = if options.dry_run {
216        None
217    } else {
218        Some(crate::landing::lock::hold(&target)?)
219    };
220
221    let installed = read_installed(&target)?;
222    // The version landed is the version running. The operator chose which
223    // binary to install, and that binary projects only itself.
224    let new = CanonVersion::current();
225    let old = installed.version;
226    let mut outcome = UpgradeOutcome::default();
227
228    if old > new {
229        return Err(AppError::Refused(format!(
230            "sdd {new} is older than the installed canon {old}; upgrade sdd"
231        )));
232    }
233
234    // The conflict scan comes before the version shortcut. A managed file
235    // edited in a current instance is exactly the drift this verb serves,
236    // and a shortcut that reported success first would leave the one route
237    // to it unable to do its job.
238    let conflicts = conflicts_at(&target, &installed)?;
239    if !conflicts.is_empty() {
240        let count = conflicts.len();
241        outcome.lines.extend(conflicts);
242        outcome.failures += count;
243        return Ok(outcome);
244    }
245
246    if old == new && installed.schema_current {
247        outcome.lines.push(format!("OK already at {new}"));
248        return Ok(outcome);
249    }
250
251    if options.dry_run {
252        // At the same canon version the work is the record's schema alone,
253        // so saying "upgrade X to X" would describe nothing.
254        if old == new {
255            outcome
256                .lines
257                .push(format!("DRY RUN migrate the record of {new}"));
258        } else {
259            outcome
260                .lines
261                .push(format!("DRY RUN upgrade {old} to {new}"));
262        }
263        // A seed the landing will not write, and a file it cannot account
264        // for, are what a dry run exists to show.
265        let preview = init(
266            &reinstall_options(&target, installed.profile),
267            crate::landing::classify::Intent::Reconcile,
268        )
269        .map_err(|error| {
270            AppError::Refused(format!(
271                "upgrade could not be previewed from {old} to {new}: {error}"
272            ))
273        })?;
274        outcome.lines.extend(
275            preview
276                .lines
277                .into_iter()
278                .filter(|line| line.starts_with("note:")),
279        );
280        return Ok(outcome);
281    }
282
283    let reinstalled = init_holding(
284        held,
285        &InitOptions {
286            apply: true,
287            dry_run: false,
288            ..reinstall_options(&target, installed.profile)
289        },
290        // The upgrade already classified the target; the reinstall is its
291        // own act rather than a second landing decision.
292        crate::landing::classify::Intent::Reconcile,
293    )
294    .map_err(|error| {
295        AppError::Refused(format!(
296            "upgrade aborted during reinstall from {old} to {new}: {error}"
297        ))
298    })?;
299    // The reinstall's destination list is noise here, and its notes are
300    // not: a seed that did not land because the project already holds the
301    // destination is something the operator must hear about once.
302    let removed = reinstalled.removed.clone();
303    outcome.lines.extend(
304        reinstalled
305            .lines
306            .into_iter()
307            .filter(|line| line.starts_with("note:")),
308    );
309
310    finish(&target, &installed, &removed, old, new, &mut outcome);
311    Ok(outcome)
312}
313
314fn finish(
315    target: &Utf8Path,
316    installed: &Installed,
317    removed: &[String],
318    old: CanonVersion,
319    new: CanonVersion,
320    outcome: &mut UpgradeOutcome,
321) {
322    report_removals(removed, outcome);
323
324    let mut local_ids = std::collections::BTreeSet::new();
325    let specs = target.join(&installed.docs_root).join("specs");
326    if let Ok(entries) = specs.read_dir_utf8() {
327        for entry in entries.filter_map(Result::ok) {
328            if let Ok(text) = std::fs::read_to_string(entry.path()) {
329                local_ids.extend(crate::embedded::rule_ids_in(&text));
330            }
331        }
332    }
333    let upstream_only: Vec<String> = crate::embedded::spec_rule_ids()
334        .difference(&local_ids)
335        .cloned()
336        .collect();
337    if !upstream_only.is_empty() {
338        outcome
339            .lines
340            .push("upstream rule IDs not present locally:".to_string());
341        for id in upstream_only {
342            outcome.lines.push(format!("  {id}"));
343        }
344    }
345
346    if outcome.failures > 0 {
347        outcome.lines.push(format!(
348            "FAIL upgraded {old} to {new} with unfinished removals above"
349        ));
350    } else if old == new {
351        outcome
352            .lines
353            .push(format!("OK migrated the record of {new}"));
354    } else {
355        outcome.lines.push(format!("OK upgraded {old} to {new}"));
356    }
357}