use serde::Serialize;
use crate::cli::upgrade::UpgradeArgs;
use crate::diagnostic::{Diagnostic, Reason};
use crate::embedded;
use crate::error::RkError;
use crate::held;
use crate::landing::apply::{self, Action, Collision, Prepared};
use crate::landing::manifest::{self, Alignment, Manifest, Provider, Style, Workflow};
use crate::landing::{self, lock};
use crate::output::Output;
#[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,
workflow: &'static str,
style: &'static str,
nix: bool,
scorecard: bool,
#[serde(skip_serializing_if = "Option::is_none")]
code_scanning: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
licence_refusal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
withheld: Option<Vec<landing::Withheld>>,
#[serde(skip_serializing_if = "Option::is_none")]
collisions: Option<Vec<Collision>>,
config: crate::config::Plan,
files: Vec<FileEntry>,
next: Vec<String>,
}
pub fn run(args: &UpgradeArgs) -> Result<(), RkError> {
let out = Output::new(args.json);
let lock = args
.apply
.then(|| lock::acquire(&args.target))
.transpose()?;
held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
let held = lock.as_ref().map_or_else(
|| apply::Held::open(&args.target),
|lock| apply::Held::open_locked(&args.target, lock),
)?;
held::pause(apply::PAUSE_VAR, "held", "proceed-held");
let recorded = load_upgradable(&held)?;
let existing = crate::config::load(held.base().as_std_path())?;
let params = resolve_params(args, &held, &recorded, existing.as_ref())?;
let style = params
.style()
.ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
let (prepared, landed) = if let Some(lock) = lock {
let prepared = apply::prepare(&held, Some(&recorded), ¶ms, existing.as_ref())?;
let landed = apply::land(
&held,
Some(&recorded),
&prepared,
apply::Origin::Upgrade,
&lock,
)?;
drop(lock);
(prepared, Some(landed))
} else {
(
apply::prepare(&held, Some(&recorded), ¶ms, existing.as_ref())?,
None,
)
};
let sentinels = report_decisions(out, &held, &prepared, landed.is_some())?;
if landed.is_some() {
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, ¶ms, prepared.collisions.is_empty());
out.next(&next);
out.emit(&Report {
schema: "rk.upgrade/9",
config: prepared.config.clone(),
mode: if args.apply { "apply" } else { "preview" },
target: args.target.to_string(),
tech: params.tech().into(),
forge: params.forge().into(),
from_version: recorded.rk_version,
to_version: env!("CARGO_PKG_VERSION"),
workflow: params.workflow().as_str(),
style: style.as_str(),
nix: params.nix(),
scorecard: params.scorecard(),
code_scanning: params.code_scanning().map(Provider::as_str),
licence_refusal: prepared.projection.licence_refusal.clone(),
withheld: withheld_of(&prepared),
collisions: (!prepared.collisions.is_empty()).then(|| prepared.collisions.clone()),
files: prepared
.decisions
.iter()
.map(|decision| FileEntry {
path: decision.destination.clone(),
kind: decision.kind.as_str(),
action: decision.action.as_str(),
})
.chain(prepared.collisions.iter().map(|collision| FileEntry {
path: collision.path.clone(),
kind: landing::kind_of(&collision.path).map_or("unknown", landing::Kind::as_str),
action: "collision",
}))
.collect(),
next,
})
}
fn withheld_of(prepared: &Prepared) -> Option<Vec<landing::Withheld>> {
let withheld: Vec<landing::Withheld> = prepared
.projection
.omissions
.iter()
.map(|omission| landing::Withheld {
path: omission.destination.clone(),
reason: omission.reason.clone(),
})
.collect();
(!withheld.is_empty()).then_some(withheld)
}
fn report_decisions(
out: Output,
held: &apply::Held,
prepared: &Prepared,
landed: bool,
) -> Result<Vec<String>, RkError> {
for key in &prepared.config.changes {
out.result_line(format!("configuration changes {key}"));
}
out.result_line(format!(
"{} {}",
prepared.config.action,
crate::config::CONFIG_PATH
));
let mut sentinels: Vec<String> = Vec::new();
for decision in &prepared.decisions {
if landed && matches!(decision.action, Action::Created | Action::Replaced) {
let bytes =
landing::read_recorded(held.base(), &decision.destination)?.unwrap_or_default();
collect_sentinels(&decision.destination, &bytes, &mut sentinels);
}
out.result_line(crate::commands::init::describe(decision));
}
for collision in &prepared.collisions {
out.result_line(format!(
"collision {}: {}",
collision.path, collision.reason
));
}
for entry in &prepared.projection.omissions {
out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
}
if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
out.result_line(format!("licence refusal: {reason}"));
}
Ok(sentinels)
}
fn toggle(flag: &str, value: Option<&str>) -> Result<Option<bool>, RkError> {
match value {
None => Ok(None),
Some("on") => Ok(Some(true)),
Some("off") => Ok(Some(false)),
Some(other) => Err(RkError::Usage(format!(
"unknown --{flag} value '{other}'; the values are: on, off"
))),
}
}
fn resolve_params(
args: &UpgradeArgs,
held: &apply::Held,
recorded: &Manifest,
existing: Option<&crate::config::Config>,
) -> Result<landing::Params, RkError> {
let nix = toggle("nix", args.nix.as_deref())?;
let scorecard = toggle("scorecard", args.scorecard.as_deref())?;
let code_scanning = args
.code_scanning
.as_deref()
.map(Provider::parse)
.transpose()?;
landing::Params::resolve(
held.base(),
&landing::Inputs {
tech: args.tech.as_deref(),
forge: args.forge.as_deref(),
repo: args.repo.as_deref(),
workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
style: args.style.as_deref().map(Style::parse).transpose()?,
nix,
scorecard,
code_scanning,
},
existing,
Some(recorded),
landing::Purpose::Upgrade,
)
}
fn next_lines(args: &UpgradeArgs, params: &landing::Params, clean: bool) -> Vec<String> {
let identity_flags: String = [
("tech", args.tech.as_deref()),
("forge", args.forge.as_deref()),
("repo", args.repo.as_deref()),
]
.into_iter()
.filter_map(|(key, value)| value.map(|value| format!(" --{key} {value}")))
.collect();
let workflow_flag = args
.workflow
.as_deref()
.map_or_else(String::new, |mode| format!(" --workflow {mode}"));
let style_flag = args
.style
.as_deref()
.map_or_else(String::new, |style| format!(" --style {style}"));
let capabilities = params.capability_toggles();
if args.apply {
vec![
"commit the upgraded files, the receipt included".to_owned(),
format!("rk status --target {} reports the result", args.target),
]
} else if clean {
vec![
format!(
"rk upgrade{identity_flags}{workflow_flag}{style_flag}{capabilities} --target {} --apply writes",
args.target
),
format!(
"rk stage --target {} stages the complete candidate for a byte comparison",
args.target
),
]
} else {
vec![
format!(
"resolve each collision above through the rk-setup skill; rk upgrade{identity_flags}{workflow_flag}{style_flag}{capabilities} --target {} --apply refuses until then",
args.target
),
format!(
"rk stage --target {} stages the complete candidate for a byte comparison",
args.target
),
]
}
}
fn load_upgradable(held: &apply::Held) -> Result<Manifest, RkError> {
let target = held.display();
let Some(recorded) = manifest::load(held.base())? else {
return Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"no {} at {target}: an upgrade needs the receipt of the landing it moves, and nothing was written",
manifest::MANIFEST_PATH
),
)
.expected("a recorded landing")
.action(format!(
"rk stage --target {target} stages this binary's candidate for a byte comparison; the rk-setup skill carries the best-effort migration, ending in rk adopt for a target brought to the candidate or rk init for a fresh one"
))
.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 collect_sentinels(destination: &str, bytes: &[u8], found: &mut Vec<String>) {
let text = String::from_utf8_lossy(bytes);
for (idx, line) in text.lines().enumerate() {
if line.contains(embedded::SENTINEL) {
found.push(format!("{destination}:{}: {}", idx + 1, line.trim()));
}
}
}
#[cfg(test)]
mod tests {
use super::{FileEntry, Report};
#[test]
fn the_upgrade_report_schema_snapshot_holds() {
let report = Report {
schema: "rk.upgrade/9",
config: crate::config::Plan {
action: "added",
changes: vec![],
content: "schema_version = 1\n".into(),
},
mode: "preview",
target: "/tmp/t".into(),
tech: "rust".into(),
forge: "github".into(),
from_version: "0.1.0".into(),
to_version: "0.2.0",
workflow: "branches",
style: "trunk",
nix: false,
scorecard: false,
code_scanning: None,
licence_refusal: None,
withheld: None,
collisions: None,
files: vec![
FileEntry {
path: "release-plz.toml".into(),
kind: "seeded",
action: "drift",
},
FileEntry {
path: "legacy.yml".into(),
kind: "rendered",
action: "released",
},
],
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/9","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,"scorecard":false,"config":{"action":"added","changes":[],"content":"schema_version = 1\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"drift"},{"path":"legacy.yml","kind":"rendered","action":"released"}],"next":["rk upgrade --target /tmp/t --apply writes"]}"#
);
}
}