Skip to main content

release_kit/commands/
upgrade.rs

1//! `rk upgrade`: a landed target takes a newer payload.
2//!
3//! Three digests decide each file: the baseline the record keeps — the
4//! payload as it stood at landing — the bytes on disk now, and this
5//! binary's candidate, rendered under the recorded parameters. A
6//! `rendered` file nobody touched is rewritten; one the target edited is
7//! a conflict, and every conflict is collected before the whole upgrade
8//! refuses in one run. There is no merge: the two outcomes are a clean
9//! write and a refusal, because a wrong guess in a release workflow is
10//! discovered at the next release.
11
12use serde::Serialize;
13
14use crate::cli::upgrade::UpgradeArgs;
15use crate::diagnostic::{Diagnostic, Reason};
16use crate::digest::Digest;
17use crate::error::RkError;
18use crate::landing::manifest::{self, Alignment, FileRecord, Manifest};
19use crate::landing::{self, Entry, Kind};
20use crate::output::Output;
21use crate::{embedded, registry};
22
23/// One destination and what the upgrade decided for it.
24#[derive(Debug, Serialize)]
25struct FileEntry {
26    /// The destination, relative to the target.
27    path: String,
28    /// The kind this payload declares for it.
29    kind: &'static str,
30    /// `updated`, `unchanged`, `added`, `drift`, `kept`, `dropped`,
31    /// `state`, or `conflict`.
32    action: &'static str,
33}
34
35/// The machine form of an upgrade report.
36#[derive(Debug, Serialize)]
37struct Report {
38    /// The shape version of this document.
39    schema: &'static str,
40    /// `preview` or `apply`.
41    mode: &'static str,
42    /// The target directory.
43    target: String,
44    /// The recorded technology.
45    tech: String,
46    /// The recorded forge.
47    forge: String,
48    /// The version the record came from.
49    from_version: String,
50    /// This binary's version.
51    to_version: &'static str,
52    /// Every destination, with its action.
53    files: Vec<FileEntry>,
54    /// What plausibly follows.
55    next: Vec<String>,
56}
57
58/// One decided destination, carried from the decision pass to the write
59/// pass and the record rewrite.
60struct Decision<'a> {
61    entry: Option<&'a Entry>,
62    action: &'static str,
63    record: FileRecord,
64}
65
66/// Upgrade the landed target to this binary's payload.
67///
68/// # Errors
69///
70/// Returns a refusal for a missing record, an unknown record schema, a
71/// record from a newer binary, a `rendered` destination that is not a
72/// regular file, and — on apply — any collected conflict; and
73/// [`RkError::Io`] on filesystem failure.
74pub fn run(args: &UpgradeArgs) -> Result<(), RkError> {
75    let out = Output::new(args.json);
76    let mut recorded = load_upgradable(&args.target)?;
77    resolve_scopes(&mut recorded, args.scopes.as_deref())?;
78    let entries = landing::projection(
79        &recorded.tech,
80        &recorded.forge,
81        &recorded.parameters.repo,
82        &recorded.parameters.scopes,
83    )?;
84    refuse_non_regular(&args.target, &entries)?;
85
86    let (decisions, conflicts) = decide_all(args, &recorded, &entries)?;
87    // A file this payload stops shipping is a file the target owns from
88    // that moment: left in place, named, and dropped from the record.
89    let mut dropped: Vec<String> = Vec::new();
90    for file in &recorded.files {
91        if !entries
92            .iter()
93            .any(|entry| entry.destination == file.destination)
94        {
95            dropped.push(file.destination.clone());
96        }
97    }
98
99    if args.apply && !conflicts.is_empty() {
100        return Err(refuse_conflicts(&conflicts));
101    }
102
103    let mut sentinels: Vec<String> = Vec::new();
104    for decision in &decisions {
105        if args.apply && matches!(decision.action, "updated" | "added") {
106            if let Some(entry) = decision.entry {
107                landing::write_destination(&args.target, entry)?;
108                collect_sentinels(entry, &mut sentinels);
109            }
110        }
111        out.result_line(match decision.action {
112            "drift" => format!(
113                "drift {} (seeded, target-owned)",
114                decision.record.destination
115            ),
116            "kept" => format!("kept {} (target-owned)", decision.record.destination),
117            "conflict" => format!(
118                "conflict {} (edited, release-kit-owned)",
119                decision.record.destination
120            ),
121            action => format!("{action} {}", decision.record.destination),
122        });
123    }
124    for path in &dropped {
125        out.result_line(format!(
126            "dropped {path} (no longer shipped; now target-owned)"
127        ));
128    }
129
130    if args.apply {
131        rewrite_record(&args.target, &recorded, &decisions)?;
132        out.result_line(format!("rewrote {}", manifest::MANIFEST_PATH));
133        for sentinel in &sentinels {
134            out.result_line(format!("fill this sentinel: {sentinel}"));
135        }
136    }
137
138    let next = next_lines(args, conflicts.is_empty());
139    out.next(&next);
140    out.emit(&Report {
141        schema: "rk.upgrade/1",
142        mode: if args.apply { "apply" } else { "preview" },
143        target: args.target.to_string(),
144        tech: recorded.tech.clone(),
145        forge: recorded.forge.clone(),
146        from_version: recorded.rk_version.clone(),
147        to_version: env!("CARGO_PKG_VERSION"),
148        files: decisions
149            .iter()
150            .map(|decision| FileEntry {
151                path: decision.record.destination.clone(),
152                kind: decision.record.kind.as_str(),
153                action: decision.action,
154            })
155            .chain(dropped.iter().map(|path| FileEntry {
156                path: path.clone(),
157                kind: "dropped",
158                action: "dropped",
159            }))
160            .collect(),
161        next,
162    })
163}
164
165/// The collect-then-refuse conflict answer: the whole list in one run, so
166/// an operator resolves everything and re-runs once.
167fn refuse_conflicts(conflicts: &[String]) -> RkError {
168    RkError::refusal(
169        Diagnostic::new(
170            Reason::StateDrift,
171            format!(
172                "these files release-kit owns were edited, and nothing was written: {}",
173                conflicts.join(", ")
174            ),
175        )
176        .expected("every rendered file as the record left it")
177        .action("resolve each, or re-land it, then run 'rk upgrade' again")
178        .target_state("unchanged"),
179    )
180}
181
182/// The `Next:` lines for each outcome.
183fn next_lines(args: &UpgradeArgs, clean: bool) -> Vec<String> {
184    if args.apply {
185        vec![
186            "commit the upgraded files, the record included".to_owned(),
187            format!("rk status --target {} reports the result", args.target),
188        ]
189    } else if clean {
190        vec![format!(
191            "rk upgrade --target {} --apply writes",
192            args.target
193        )]
194    } else {
195        vec![format!(
196            "resolve each conflict above; rk upgrade --target {} --apply refuses until then",
197            args.target
198        )]
199    }
200}
201
202/// The record after a successful apply, rewritten whole: new version, new
203/// digests, new pins; the first landing's instant, origin, and parameters
204/// are preserved.
205fn rewrite_record(
206    target: &camino::Utf8Path,
207    recorded: &Manifest,
208    decisions: &[Decision],
209) -> Result<(), RkError> {
210    manifest::write(
211        target,
212        &Manifest {
213            schema_version: manifest::SCHEMA_VERSION,
214            rk_version: env!("CARGO_PKG_VERSION").to_owned(),
215            payload_sha256: crate::commands::payload::report().payload_sha256,
216            origin: recorded.origin.clone(),
217            tech: recorded.tech.clone(),
218            forge: recorded.forge.clone(),
219            landed_at: recorded.landed_at.clone(),
220            parameters: manifest::Parameters {
221                repo: recorded.parameters.repo.clone(),
222                scopes: recorded.parameters.scopes.clone(),
223            },
224            files: decisions
225                .iter()
226                .map(|decision| clone_record(&decision.record))
227                .collect(),
228            pins: registry::pins_for(&recorded.tech)
229                .into_iter()
230                .map(|pin| (pin.name, pin.version))
231                .collect(),
232        },
233    )
234}
235
236/// The record an upgrade may act on: present, at a known schema, and not
237/// from a newer binary than this one.
238fn load_upgradable(target: &camino::Utf8Path) -> Result<Manifest, RkError> {
239    let Some(recorded) = manifest::load(target)? else {
240        return Err(RkError::refusal(
241            Diagnostic::new(
242                Reason::StateDrift,
243                format!(
244                    "no {} at {target}: there is no baseline to upgrade against",
245                    manifest::MANIFEST_PATH
246                ),
247            )
248            .expected("a recorded landing")
249            .action(
250                "rk init lands a first landing; rk adopt records one made before the record existed",
251            )
252            .target_state("unchanged"),
253        ));
254    };
255    if manifest::alignment(&recorded.rk_version, env!("CARGO_PKG_VERSION"))
256        == Alignment::TargetNewer
257    {
258        return Err(RkError::refusal(
259            Diagnostic::new(
260                Reason::StateDrift,
261                format!(
262                    "this landing came from rk {}, newer than this binary's {}; downgrading a target is not an upgrade",
263                    recorded.rk_version,
264                    env!("CARGO_PKG_VERSION")
265                ),
266            )
267            .expected("a binary at or above the recorded rk_version")
268            .action(format!("install release-kit {} or newer", recorded.rk_version))
269            .target_state("unchanged"),
270        ));
271    }
272    Ok(recorded)
273}
274
275/// Decide one candidate destination from the three digests.
276/// Every entry decided in one pass, with the collected conflicts. An
277/// ill-formed hook file is a conflict in preview and apply alike: its
278/// first block may match while a duplicate still executes, so the
279/// per-entry comparison cannot see it, and the refusal names each
280/// conflict once.
281fn decide_all<'a>(
282    args: &UpgradeArgs,
283    recorded: &'a Manifest,
284    entries: &'a [Entry],
285) -> Result<(Vec<Decision<'a>>, Vec<String>), RkError> {
286    let mut conflicts: Vec<String> = Vec::new();
287    let mut decisions: Vec<Decision<'a>> = Vec::new();
288    if landing::hooks_file_defect(&args.target)?.is_some() {
289        conflicts.push(landing::HOOKS_DESTINATION.to_owned());
290    }
291    for entry in entries {
292        let disk = landing::read_recorded(&args.target, &entry.destination)?;
293        let mut decision = decide(
294            entry,
295            recorded.file(&entry.destination),
296            disk.as_deref(),
297            &mut conflicts,
298        );
299        if entry.destination == landing::HOOKS_DESTINATION
300            && conflicts.iter().any(|c| c == landing::HOOKS_DESTINATION)
301        {
302            decision.action = "conflict";
303        }
304        decisions.push(decision);
305    }
306    let mut seen = std::collections::HashSet::new();
307    conflicts.retain(|conflict| seen.insert(conflict.clone()));
308    Ok((decisions, conflicts))
309}
310
311fn decide<'a>(
312    entry: &'a Entry,
313    recorded: Option<&FileRecord>,
314    disk: Option<&[u8]>,
315    conflicts: &mut Vec<String>,
316) -> Decision<'a> {
317    let candidate_record = |sha256: Digest| FileRecord {
318        destination: entry.destination.clone(),
319        kind: entry.kind,
320        sha256,
321        baseline_sha256: match entry.kind {
322            Kind::State => None,
323            Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
324        },
325    };
326    let Some(recorded) = recorded else {
327        return decide_added(entry, disk, conflicts);
328    };
329
330    // A seeded file this payload reclassifies as rendered claims
331    // ownership of a file the target may have tuned; only untouched bytes
332    // — matching the recorded baseline — permit the claim.
333    if recorded.kind == Kind::Seeded && entry.kind == Kind::Rendered {
334        let untouched =
335            disk.is_some_and(|bytes| Some(Digest::of(bytes)) == recorded.baseline_sha256);
336        if !untouched {
337            conflicts.push(entry.destination.clone());
338            return Decision {
339                entry: Some(entry),
340                action: "conflict",
341                record: candidate_record(Digest::of(&entry.rendered)),
342            };
343        }
344        return Decision {
345            entry: Some(entry),
346            action: "updated",
347            record: candidate_record(Digest::of(&entry.rendered)),
348        };
349    }
350
351    match entry.kind {
352        Kind::Rendered => match disk {
353            Some(bytes) if Digest::of(bytes) == recorded.sha256 => Decision {
354                entry: Some(entry),
355                action: if bytes == entry.rendered {
356                    "unchanged"
357                } else {
358                    "updated"
359                },
360                record: candidate_record(Digest::of(&entry.rendered)),
361            },
362            Some(bytes) if bytes == entry.rendered => Decision {
363                entry: Some(entry),
364                action: "unchanged",
365                record: candidate_record(Digest::of(&entry.rendered)),
366            },
367            // Edited or deleted: either way the target changed a file
368            // release-kit owns.
369            _ => {
370                conflicts.push(entry.destination.clone());
371                Decision {
372                    entry: Some(entry),
373                    action: "conflict",
374                    record: candidate_record(Digest::of(&entry.rendered)),
375                }
376            }
377        },
378        Kind::Seeded => {
379            // Never written; the record keeps the target's current bytes
380            // and the baseline it tunes away from. For a file this payload
381            // reclassifies from rendered to seeded — safe and silent — that
382            // baseline is the rendered bytes release-kit last wrote, not
383            // the pre-substitution payload, so an untouched file is not
384            // reported as drift.
385            let baseline = if recorded.kind == Kind::Rendered {
386                Some(recorded.sha256.clone())
387            } else {
388                recorded.baseline_sha256.clone()
389            };
390            let (action, sha256) = disk.map_or_else(
391                || ("drift", recorded.sha256.clone()),
392                |bytes| {
393                    let digest = Digest::of(bytes);
394                    if Some(&digest) == baseline.as_ref() {
395                        ("unchanged", digest)
396                    } else {
397                        ("drift", digest)
398                    }
399                },
400            );
401            Decision {
402                entry: None,
403                action,
404                record: FileRecord {
405                    destination: entry.destination.clone(),
406                    kind: entry.kind,
407                    sha256,
408                    baseline_sha256: baseline,
409                },
410            }
411        }
412        Kind::State => Decision {
413            entry: None,
414            action: "state",
415            record: FileRecord {
416                destination: entry.destination.clone(),
417                kind: entry.kind,
418                sha256: recorded.sha256.clone(),
419                baseline_sha256: None,
420            },
421        },
422    }
423}
424
425/// A destination the record does not name, added by this payload: it
426/// lands exactly as `rk init` lands it — a differing `rendered`
427/// destination is a conflict, a differing `seeded` or `state` one is the
428/// target's and is kept.
429fn decide_added<'a>(
430    entry: &'a Entry,
431    disk: Option<&[u8]>,
432    conflicts: &mut Vec<String>,
433) -> Decision<'a> {
434    let (action, sha256) = match disk {
435        None => ("added", Digest::of(&entry.rendered)),
436        Some(bytes) if bytes == entry.rendered => ("unchanged", Digest::of(bytes)),
437        Some(bytes) if entry.kind != Kind::Rendered => ("kept", Digest::of(bytes)),
438        Some(_) => {
439            conflicts.push(entry.destination.clone());
440            ("conflict", Digest::of(&entry.rendered))
441        }
442    };
443    Decision {
444        entry: Some(entry),
445        action,
446        record: FileRecord {
447            destination: entry.destination.clone(),
448            kind: entry.kind,
449            sha256,
450            baseline_sha256: match entry.kind {
451                Kind::State => None,
452                Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
453            },
454        },
455    }
456}
457
458/// A `rendered` destination that exists and is not a regular file refuses
459/// before anything is read.
460fn refuse_non_regular(target: &camino::Utf8Path, entries: &[Entry]) -> Result<(), RkError> {
461    for entry in entries {
462        if entry.kind != Kind::Rendered {
463            continue;
464        }
465        let path = target.join(&entry.destination);
466        if let Ok(meta) = std::fs::symlink_metadata(&path) {
467            if !meta.is_file() {
468                return Err(RkError::refusal(
469                    Diagnostic::new(
470                        Reason::StateDrift,
471                        format!("{path} exists and is not a regular file; nothing was written"),
472                    )
473                    .expected("every rendered destination a regular file")
474                    .target_state("unchanged"),
475                ));
476            }
477        }
478    }
479    Ok(())
480}
481
482/// The judgment sentinels a newly written file carries.
483fn collect_sentinels(entry: &Entry, found: &mut Vec<String>) {
484    let text = String::from_utf8_lossy(&entry.rendered);
485    for (idx, line) in text.lines().enumerate() {
486        if line.contains(embedded::SENTINEL) {
487            found.push(format!(
488                "{}:{}: {}",
489                entry.destination,
490                idx + 1,
491                line.trim()
492            ));
493        }
494    }
495}
496
497/// [`FileRecord`] carries digests, which are cheap to clone by field.
498fn clone_record(record: &FileRecord) -> FileRecord {
499    FileRecord {
500        destination: record.destination.clone(),
501        kind: record.kind,
502        sha256: record.sha256.clone(),
503        baseline_sha256: record.baseline_sha256.clone(),
504    }
505}
506
507/// The scope parameter comes from the record; a record from before the
508/// parameter existed takes `--scopes` once, and the rewrite records it.
509fn resolve_scopes(recorded: &mut Manifest, raw: Option<&str>) -> Result<(), RkError> {
510    if let Some(raw) = raw {
511        recorded.parameters.scopes = landing::parse_scopes(raw)?;
512    }
513    if recorded.parameters.scopes.is_empty() {
514        return Err(RkError::Usage(
515            "the record carries no scopes parameter; pass --scopes <list>, the Conventional Commit scopes this project accepts, and the upgrade records it".into(),
516        ));
517    }
518    Ok(())
519}
520
521#[cfg(test)]
522mod tests {
523    #![allow(clippy::expect_used)]
524
525    use super::{FileEntry, Report};
526
527    /// The complete `rk.upgrade/1` shape, held by snapshot.
528    #[test]
529    fn the_upgrade_report_schema_snapshot_holds() {
530        let report = Report {
531            schema: "rk.upgrade/1",
532            mode: "preview",
533            target: "/tmp/t".into(),
534            tech: "rust".into(),
535            forge: "github".into(),
536            from_version: "0.1.0".into(),
537            to_version: "0.2.0",
538            files: vec![FileEntry {
539                path: "release-plz.toml".into(),
540                kind: "seeded",
541                action: "drift",
542            }],
543            next: vec!["rk upgrade --target /tmp/t --apply writes".into()],
544        };
545        assert_eq!(
546            serde_json::to_string(&report).expect("a report serializes"),
547            r#"{"schema":"rk.upgrade/1","mode":"preview","target":"/tmp/t","tech":"rust","forge":"github","from_version":"0.1.0","to_version":"0.2.0","files":[{"path":"release-plz.toml","kind":"seeded","action":"drift"}],"next":["rk upgrade --target /tmp/t --apply writes"]}"#
548        );
549    }
550}