use serde::Serialize;
use crate::cli::upgrade::UpgradeArgs;
use crate::diagnostic::{Diagnostic, Reason};
use crate::digest::Digest;
use crate::error::RkError;
use crate::landing::manifest::{self, Alignment, FileRecord, Manifest};
use crate::landing::{self, Entry, Kind};
use crate::output::Output;
use crate::{embedded, registry};
#[derive(Debug, Serialize)]
struct FileEntry {
path: String,
kind: &'static str,
action: &'static str,
}
#[derive(Debug, Serialize)]
struct Report {
schema: &'static str,
mode: &'static str,
target: String,
tech: String,
forge: String,
from_version: String,
to_version: &'static str,
files: Vec<FileEntry>,
next: Vec<String>,
}
struct Decision<'a> {
entry: Option<&'a Entry>,
action: &'static str,
record: FileRecord,
}
pub fn run(args: &UpgradeArgs) -> Result<(), RkError> {
let out = Output::new(args.json);
let mut recorded = load_upgradable(&args.target)?;
resolve_scopes(&mut recorded, args.scopes.as_deref())?;
let entries = landing::projection(
&recorded.tech,
&recorded.forge,
&recorded.parameters.repo,
&recorded.parameters.scopes,
)?;
refuse_non_regular(&args.target, &entries)?;
let (decisions, conflicts) = decide_all(args, &recorded, &entries)?;
let mut dropped: Vec<String> = Vec::new();
for file in &recorded.files {
if !entries
.iter()
.any(|entry| entry.destination == file.destination)
{
dropped.push(file.destination.clone());
}
}
if args.apply && !conflicts.is_empty() {
return Err(refuse_conflicts(&conflicts));
}
let mut sentinels: Vec<String> = Vec::new();
for decision in &decisions {
if args.apply && matches!(decision.action, "updated" | "added") {
if let Some(entry) = decision.entry {
landing::write_destination(&args.target, entry)?;
collect_sentinels(entry, &mut sentinels);
}
}
out.result_line(match decision.action {
"drift" => format!(
"drift {} (seeded, target-owned)",
decision.record.destination
),
"kept" => format!("kept {} (target-owned)", decision.record.destination),
"conflict" => format!(
"conflict {} (edited, release-kit-owned)",
decision.record.destination
),
action => format!("{action} {}", decision.record.destination),
});
}
for path in &dropped {
out.result_line(format!(
"dropped {path} (no longer shipped; now target-owned)"
));
}
if args.apply {
rewrite_record(&args.target, &recorded, &decisions)?;
out.result_line(format!("rewrote {}", manifest::MANIFEST_PATH));
for sentinel in &sentinels {
out.result_line(format!("fill this sentinel: {sentinel}"));
}
}
let next = next_lines(args, conflicts.is_empty());
out.next(&next);
out.emit(&Report {
schema: "rk.upgrade/1",
mode: if args.apply { "apply" } else { "preview" },
target: args.target.to_string(),
tech: recorded.tech.clone(),
forge: recorded.forge.clone(),
from_version: recorded.rk_version.clone(),
to_version: env!("CARGO_PKG_VERSION"),
files: decisions
.iter()
.map(|decision| FileEntry {
path: decision.record.destination.clone(),
kind: decision.record.kind.as_str(),
action: decision.action,
})
.chain(dropped.iter().map(|path| FileEntry {
path: path.clone(),
kind: "dropped",
action: "dropped",
}))
.collect(),
next,
})
}
fn refuse_conflicts(conflicts: &[String]) -> RkError {
RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"these files release-kit owns were edited, and nothing was written: {}",
conflicts.join(", ")
),
)
.expected("every rendered file as the record left it")
.action("resolve each, or re-land it, then run 'rk upgrade' again")
.target_state("unchanged"),
)
}
fn next_lines(args: &UpgradeArgs, clean: bool) -> Vec<String> {
if args.apply {
vec![
"commit the upgraded files, the record included".to_owned(),
format!("rk status --target {} reports the result", args.target),
]
} else if clean {
vec![format!(
"rk upgrade --target {} --apply writes",
args.target
)]
} else {
vec![format!(
"resolve each conflict above; rk upgrade --target {} --apply refuses until then",
args.target
)]
}
}
fn rewrite_record(
target: &camino::Utf8Path,
recorded: &Manifest,
decisions: &[Decision],
) -> Result<(), RkError> {
manifest::write(
target,
&Manifest {
schema_version: manifest::SCHEMA_VERSION,
rk_version: env!("CARGO_PKG_VERSION").to_owned(),
payload_sha256: crate::commands::payload::report().payload_sha256,
origin: recorded.origin.clone(),
tech: recorded.tech.clone(),
forge: recorded.forge.clone(),
landed_at: recorded.landed_at.clone(),
parameters: manifest::Parameters {
repo: recorded.parameters.repo.clone(),
scopes: recorded.parameters.scopes.clone(),
},
files: decisions
.iter()
.map(|decision| clone_record(&decision.record))
.collect(),
pins: registry::pins_for(&recorded.tech)
.into_iter()
.map(|pin| (pin.name, pin.version))
.collect(),
},
)
}
fn load_upgradable(target: &camino::Utf8Path) -> Result<Manifest, RkError> {
let Some(recorded) = manifest::load(target)? else {
return Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"no {} at {target}: there is no baseline to upgrade against",
manifest::MANIFEST_PATH
),
)
.expected("a recorded landing")
.action(
"rk init lands a first landing; rk adopt records one made before the record existed",
)
.target_state("unchanged"),
));
};
if manifest::alignment(&recorded.rk_version, env!("CARGO_PKG_VERSION"))
== Alignment::TargetNewer
{
return Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"this landing came from rk {}, newer than this binary's {}; downgrading a target is not an upgrade",
recorded.rk_version,
env!("CARGO_PKG_VERSION")
),
)
.expected("a binary at or above the recorded rk_version")
.action(format!("install release-kit {} or newer", recorded.rk_version))
.target_state("unchanged"),
));
}
Ok(recorded)
}
fn decide_all<'a>(
args: &UpgradeArgs,
recorded: &'a Manifest,
entries: &'a [Entry],
) -> Result<(Vec<Decision<'a>>, Vec<String>), RkError> {
let mut conflicts: Vec<String> = Vec::new();
let mut decisions: Vec<Decision<'a>> = Vec::new();
if landing::hooks_file_defect(&args.target)?.is_some() {
conflicts.push(landing::HOOKS_DESTINATION.to_owned());
}
for entry in entries {
let disk = landing::read_recorded(&args.target, &entry.destination)?;
let mut decision = decide(
entry,
recorded.file(&entry.destination),
disk.as_deref(),
&mut conflicts,
);
if entry.destination == landing::HOOKS_DESTINATION
&& conflicts.iter().any(|c| c == landing::HOOKS_DESTINATION)
{
decision.action = "conflict";
}
decisions.push(decision);
}
let mut seen = std::collections::HashSet::new();
conflicts.retain(|conflict| seen.insert(conflict.clone()));
Ok((decisions, conflicts))
}
fn decide<'a>(
entry: &'a Entry,
recorded: Option<&FileRecord>,
disk: Option<&[u8]>,
conflicts: &mut Vec<String>,
) -> Decision<'a> {
let candidate_record = |sha256: Digest| FileRecord {
destination: entry.destination.clone(),
kind: entry.kind,
sha256,
baseline_sha256: match entry.kind {
Kind::State => None,
Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
},
};
let Some(recorded) = recorded else {
return decide_added(entry, disk, conflicts);
};
if recorded.kind == Kind::Seeded && entry.kind == Kind::Rendered {
let untouched =
disk.is_some_and(|bytes| Some(Digest::of(bytes)) == recorded.baseline_sha256);
if !untouched {
conflicts.push(entry.destination.clone());
return Decision {
entry: Some(entry),
action: "conflict",
record: candidate_record(Digest::of(&entry.rendered)),
};
}
return Decision {
entry: Some(entry),
action: "updated",
record: candidate_record(Digest::of(&entry.rendered)),
};
}
match entry.kind {
Kind::Rendered => match disk {
Some(bytes) if Digest::of(bytes) == recorded.sha256 => Decision {
entry: Some(entry),
action: if bytes == entry.rendered {
"unchanged"
} else {
"updated"
},
record: candidate_record(Digest::of(&entry.rendered)),
},
Some(bytes) if bytes == entry.rendered => Decision {
entry: Some(entry),
action: "unchanged",
record: candidate_record(Digest::of(&entry.rendered)),
},
_ => {
conflicts.push(entry.destination.clone());
Decision {
entry: Some(entry),
action: "conflict",
record: candidate_record(Digest::of(&entry.rendered)),
}
}
},
Kind::Seeded => {
let baseline = if recorded.kind == Kind::Rendered {
Some(recorded.sha256.clone())
} else {
recorded.baseline_sha256.clone()
};
let (action, sha256) = disk.map_or_else(
|| ("drift", recorded.sha256.clone()),
|bytes| {
let digest = Digest::of(bytes);
if Some(&digest) == baseline.as_ref() {
("unchanged", digest)
} else {
("drift", digest)
}
},
);
Decision {
entry: None,
action,
record: FileRecord {
destination: entry.destination.clone(),
kind: entry.kind,
sha256,
baseline_sha256: baseline,
},
}
}
Kind::State => Decision {
entry: None,
action: "state",
record: FileRecord {
destination: entry.destination.clone(),
kind: entry.kind,
sha256: recorded.sha256.clone(),
baseline_sha256: None,
},
},
}
}
fn decide_added<'a>(
entry: &'a Entry,
disk: Option<&[u8]>,
conflicts: &mut Vec<String>,
) -> Decision<'a> {
let (action, sha256) = match disk {
None => ("added", Digest::of(&entry.rendered)),
Some(bytes) if bytes == entry.rendered => ("unchanged", Digest::of(bytes)),
Some(bytes) if entry.kind != Kind::Rendered => ("kept", Digest::of(bytes)),
Some(_) => {
conflicts.push(entry.destination.clone());
("conflict", Digest::of(&entry.rendered))
}
};
Decision {
entry: Some(entry),
action,
record: FileRecord {
destination: entry.destination.clone(),
kind: entry.kind,
sha256,
baseline_sha256: match entry.kind {
Kind::State => None,
Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
},
},
}
}
fn refuse_non_regular(target: &camino::Utf8Path, entries: &[Entry]) -> Result<(), RkError> {
for entry in entries {
if entry.kind != Kind::Rendered {
continue;
}
let path = target.join(&entry.destination);
if let Ok(meta) = std::fs::symlink_metadata(&path) {
if !meta.is_file() {
return Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!("{path} exists and is not a regular file; nothing was written"),
)
.expected("every rendered destination a regular file")
.target_state("unchanged"),
));
}
}
}
Ok(())
}
fn collect_sentinels(entry: &Entry, found: &mut Vec<String>) {
let text = String::from_utf8_lossy(&entry.rendered);
for (idx, line) in text.lines().enumerate() {
if line.contains(embedded::SENTINEL) {
found.push(format!(
"{}:{}: {}",
entry.destination,
idx + 1,
line.trim()
));
}
}
}
fn clone_record(record: &FileRecord) -> FileRecord {
FileRecord {
destination: record.destination.clone(),
kind: record.kind,
sha256: record.sha256.clone(),
baseline_sha256: record.baseline_sha256.clone(),
}
}
fn resolve_scopes(recorded: &mut Manifest, raw: Option<&str>) -> Result<(), RkError> {
if let Some(raw) = raw {
recorded.parameters.scopes = landing::parse_scopes(raw)?;
}
if recorded.parameters.scopes.is_empty() {
return Err(RkError::Usage(
"the record carries no scopes parameter; pass --scopes <list>, the Conventional Commit scopes this project accepts, and the upgrade records it".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{FileEntry, Report};
#[test]
fn the_upgrade_report_schema_snapshot_holds() {
let report = Report {
schema: "rk.upgrade/1",
mode: "preview",
target: "/tmp/t".into(),
tech: "rust".into(),
forge: "github".into(),
from_version: "0.1.0".into(),
to_version: "0.2.0",
files: vec![FileEntry {
path: "release-plz.toml".into(),
kind: "seeded",
action: "drift",
}],
next: vec!["rk upgrade --target /tmp/t --apply writes".into()],
};
assert_eq!(
serde_json::to_string(&report).expect("a report serializes"),
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"]}"#
);
}
}