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