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