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