1use std::fmt::Write as _;
21
22use serde::Serialize;
23
24use crate::cli::upgrade::UpgradeArgs;
25use crate::diagnostic::{Diagnostic, Reason};
26use crate::embedded;
27use crate::error::RkError;
28use crate::held;
29use crate::landing::apply::{self, Action, Collision, Prepared};
30use crate::landing::manifest::{self, Alignment, Manifest, Provider};
31use crate::landing::{self, lock};
32use crate::output::Output;
33use crate::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot};
34use crate::stage::CapabilityNote;
35
36#[derive(Debug, Serialize)]
38struct FileEntry {
39 path: String,
41 kind: &'static str,
44 action: &'static str,
47}
48
49#[derive(Debug, Serialize)]
51struct Report {
52 schema: &'static str,
54 mode: &'static str,
56 target: String,
58 from_version: String,
60 to_version: &'static str,
62 profile: ProfileSnapshot,
64 git: GitWorkflow,
66 capabilities: CapabilityRequests,
68 selection: Vec<CapabilityNote>,
70 #[serde(skip_serializing_if = "Option::is_none")]
74 release_unavailable: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
79 licence_refusal: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
83 withheld: Option<Vec<landing::Withheld>>,
84 #[serde(skip_serializing_if = "Option::is_none")]
87 collisions: Option<Vec<Collision>>,
88 config: crate::config::Plan,
89 files: Vec<FileEntry>,
91 next: Vec<String>,
93}
94
95pub fn run(args: &UpgradeArgs) -> Result<(), RkError> {
103 let out = Output::new(args.json);
104 let lock = args
107 .apply
108 .then(|| lock::acquire(&args.target))
109 .transpose()?;
110 held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
115 let held = lock.as_ref().map_or_else(
116 || apply::Held::open(&args.target),
117 |lock| apply::Held::open_locked(&args.target, lock),
118 )?;
119 held::pause(apply::PAUSE_VAR, "held", "proceed-held");
121 let recorded = load_upgradable(&held)?;
122 let existing = crate::config::load(held.base().as_std_path())?;
123 let params = resolve_params(args, &held, &recorded, existing.as_ref())?;
124
125 let (prepared, landed) = if let Some(lock) = lock {
126 let prepared = apply::prepare(&held, Some(&recorded), ¶ms, existing.as_ref())?;
127 let landed = apply::land(
128 &held,
129 Some(&recorded),
130 &prepared,
131 apply::Origin::Upgrade,
132 &lock,
133 )?;
134 drop(lock);
135 (prepared, Some(landed))
136 } else {
137 (
138 apply::prepare(&held, Some(&recorded), ¶ms, existing.as_ref())?,
139 None,
140 )
141 };
142
143 let sentinels = report_decisions(out, &held, &prepared, landed.is_some())?;
144 if landed.is_some() {
145 out.result_line(format!("rewrote {}", manifest::MANIFEST_PATH));
146 for sentinel in &sentinels {
147 out.result_line(format!("fill this sentinel: {sentinel}"));
148 }
149 }
150
151 let next = next_lines(args, ¶ms, prepared.collisions.is_empty());
152 out.next(&next);
153 out.emit(&Report {
154 schema: "rk.upgrade/10",
155 config: prepared.config.clone(),
156 mode: if args.apply { "apply" } else { "preview" },
157 target: args.target.to_string(),
158 from_version: recorded.rk_version,
159 to_version: env!("CARGO_PKG_VERSION"),
160 profile: params.profile().clone(),
161 git: params.git().clone(),
162 capabilities: params.capabilities().clone(),
163 selection: prepared
164 .projection
165 .capabilities
166 .iter()
167 .map(|selection| CapabilityNote::of(selection, &prepared.projection))
168 .collect(),
169 release_unavailable: prepared.projection.release_unavailable().map(str::to_owned),
170 licence_refusal: prepared.projection.licence_refusal.clone(),
171 withheld: withheld_of(&prepared),
172 collisions: (!prepared.collisions.is_empty()).then(|| prepared.collisions.clone()),
173 files: prepared
174 .decisions
175 .iter()
176 .map(|decision| FileEntry {
177 path: decision.destination.clone(),
178 kind: decision.kind.as_str(),
179 action: decision.action.as_str(),
180 })
181 .chain(prepared.collisions.iter().map(|collision| FileEntry {
182 path: collision.path.clone(),
183 kind: landing::kind_of(&collision.path).map_or("unknown", landing::Kind::as_str),
184 action: "collision",
185 }))
186 .collect(),
187 next,
188 })
189}
190
191fn withheld_of(prepared: &Prepared) -> Option<Vec<landing::Withheld>> {
193 let withheld: Vec<landing::Withheld> = prepared
194 .projection
195 .omissions
196 .iter()
197 .map(|omission| landing::Withheld {
198 path: omission.destination.clone(),
199 reason: omission.reason.clone(),
200 })
201 .collect();
202 (!withheld.is_empty()).then_some(withheld)
203}
204
205fn report_decisions(
216 out: Output,
217 held: &apply::Held,
218 prepared: &Prepared,
219 landed: bool,
220) -> Result<Vec<String>, RkError> {
221 out.result_line(format!(
222 "profile: {}",
223 crate::commands::profile::describe(
224 prepared.params.profile(),
225 prepared.params.git(),
226 prepared.params.capabilities(),
227 prepared.params.repo()
228 )
229 ));
230 crate::commands::init::describe_selection(out, prepared);
231 for key in &prepared.config.changes {
232 out.result_line(format!("configuration changes {key}"));
233 }
234 out.result_line(format!(
235 "{} {}",
236 prepared.config.action,
237 crate::config::CONFIG_PATH
238 ));
239 let mut sentinels: Vec<String> = Vec::new();
240 for decision in &prepared.decisions {
241 if landed && matches!(decision.action, Action::Created | Action::Replaced) {
242 let bytes =
243 landing::read_recorded(held.base(), &decision.destination)?.unwrap_or_default();
244 collect_sentinels(&decision.destination, &bytes, &mut sentinels);
245 }
246 out.result_line(crate::commands::init::describe(decision));
247 }
248 for collision in &prepared.collisions {
249 out.result_line(format!(
250 "collision {}: {}",
251 collision.path, collision.reason
252 ));
253 }
254 for entry in &prepared.projection.omissions {
255 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
256 if let Some(action) = &entry.action {
257 out.result_line(format!(" action: {action}"));
258 }
259 }
260 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
261 out.result_line(format!("licence refusal: {reason}"));
262 }
263 if let Some(reason) = prepared.projection.release_unavailable() {
264 out.result_line(format!("release automation unavailable: {reason}"));
265 }
266 Ok(sentinels)
267}
268
269fn toggle(flag: &str, value: Option<&str>) -> Result<Option<bool>, RkError> {
274 match value {
275 None => Ok(None),
276 Some("on") => Ok(Some(true)),
277 Some("off") => Ok(Some(false)),
278 Some(other) => Err(RkError::Usage(format!(
279 "unknown --{flag} value '{other}'; the values are: on, off"
280 ))),
281 }
282}
283
284fn resolve_params(
285 args: &UpgradeArgs,
286 held: &apply::Held,
287 recorded: &Manifest,
288 existing: Option<&crate::config::Config>,
289) -> Result<landing::Params, RkError> {
290 let nix = toggle("nix-packaging", args.nix_packaging.as_deref())?;
291 let reporting_policy = toggle("reporting-policy", args.reporting_policy.as_deref())?;
292 let scorecard = toggle("scorecard", args.scorecard.as_deref())?;
293 let code_scanning = args
294 .code_scanning
295 .as_deref()
296 .map(Provider::parse)
297 .transpose()?;
298 landing::Params::resolve(
299 held.base(),
300 &landing::Inputs {
301 nix,
302 reporting_policy,
303 scorecard,
304 code_scanning,
305 ..args.profile.inputs()?
306 },
307 existing,
308 Some(recorded),
309 landing::Purpose::Upgrade,
310 )
311}
312
313fn next_lines(args: &UpgradeArgs, params: &landing::Params, clean: bool) -> Vec<String> {
317 let profile = &args.profile;
321 let mut identity_flags = String::new();
322 for technology in params.technologies() {
323 if !profile.technology.is_empty() {
324 let _ = write!(identity_flags, " --technology {technology}");
325 }
326 }
327 for (given, flag, value) in [
328 (
329 profile.forge.is_some(),
330 "forge",
331 params.forge().map(str::to_owned),
332 ),
333 (
334 profile.repo.is_some(),
335 "repo",
336 Some(params.repo().to_owned()),
337 ),
338 (
339 profile.release_mode.is_some(),
340 "release-mode",
341 Some(params.release_mode().as_str().to_owned()),
342 ),
343 (
344 profile.release_driver.is_some(),
345 "release-driver",
346 params.driver().map(str::to_owned),
347 ),
348 (
349 profile.release_style.is_some(),
350 "release-style",
351 params.style().map(|style| style.as_str().to_owned()),
352 ),
353 (
354 profile.trunk.is_some(),
355 "trunk",
356 Some(params.trunk().to_owned()),
357 ),
358 (
359 profile.checkout_mode.is_some(),
360 "checkout-mode",
361 Some(params.checkout_mode().as_str().to_owned()),
362 ),
363 ] {
364 if given && let Some(value) = value {
365 let _ = write!(identity_flags, " --{flag} {value}");
366 }
367 }
368 let workflow_flag = String::new();
369 let style_flag = String::new();
370 let capabilities = params.capability_toggles();
371 if args.apply {
372 vec![
373 "commit the upgraded files, the receipt included".to_owned(),
374 format!("rk status --target {} reports the result", args.target),
375 ]
376 } else if clean {
377 vec![
378 format!(
379 "rk upgrade{identity_flags}{workflow_flag}{style_flag}{capabilities} --target {} --apply writes",
380 args.target
381 ),
382 format!(
383 "rk stage --target {} stages the complete candidate for a byte comparison",
384 args.target
385 ),
386 ]
387 } else {
388 vec![
389 format!(
390 "resolve each collision above through the rk-setup skill; rk upgrade{identity_flags}{workflow_flag}{style_flag}{capabilities} --target {} --apply refuses until then",
391 args.target
392 ),
393 format!(
394 "rk stage --target {} stages the complete candidate for a byte comparison",
395 args.target
396 ),
397 ]
398 }
399}
400
401fn load_upgradable(held: &apply::Held) -> Result<Manifest, RkError> {
404 let target = held.display();
405 let Some(recorded) = manifest::load(held.base())? else {
406 return Err(RkError::refusal(
407 Diagnostic::new(
408 Reason::StateDrift,
409 format!(
410 "no {} at {target}: an upgrade needs the receipt of the landing it moves, and nothing was written",
411 manifest::MANIFEST_PATH
412 ),
413 )
414 .expected("a recorded landing")
415 .action(format!(
416 "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"
417 ))
418 .target_state("unchanged"),
419 ));
420 };
421 if manifest::alignment(&recorded.rk_version, env!("CARGO_PKG_VERSION"))
422 == Alignment::TargetNewer
423 {
424 return Err(RkError::refusal(
425 Diagnostic::new(
426 Reason::StateDrift,
427 format!(
428 "this landing came from rk {}, newer than this binary's {}; downgrading a target is not an upgrade",
429 recorded.rk_version,
430 env!("CARGO_PKG_VERSION")
431 ),
432 )
433 .expected("a binary at or above the recorded rk_version")
434 .action(format!("install release-kit {} or newer", recorded.rk_version))
435 .target_state("unchanged"),
436 ));
437 }
438 Ok(recorded)
439}
440
441fn collect_sentinels(destination: &str, bytes: &[u8], found: &mut Vec<String>) {
443 let text = String::from_utf8_lossy(bytes);
444 for (idx, line) in text.lines().enumerate() {
445 if line.contains(embedded::SENTINEL) {
446 found.push(format!("{destination}:{}: {}", idx + 1, line.trim()));
447 }
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::{FileEntry, Report};
454 use crate::landing::CheckoutMode;
455 use crate::landing::Integration;
456 use crate::profile::{
457 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
458 };
459
460 #[test]
462 fn the_upgrade_report_schema_snapshot_holds() {
463 let report = Report {
464 schema: "rk.upgrade/10",
465 config: crate::config::Plan {
466 action: "added",
467 changes: vec![],
468 content: "schema_version = 2\n".into(),
469 },
470 mode: "preview",
471 target: "/tmp/t".into(),
472 from_version: "0.1.0".into(),
473 to_version: "0.2.0",
474 profile: ProfileSnapshot {
475 technologies: vec!["rust".into()],
476 forge: Some("github".into()),
477 release: ReleaseIntent {
478 mode: ReleaseMode::Automatic,
479 driver: Some("rust".into()),
480 style: Some(crate::landing::Style::Trunk),
481 line_prefix: Some("release/".into()),
482 },
483 },
484 git: GitWorkflow {
485 trunk: "master".into(),
486 checkout_mode: CheckoutMode::MainWorktree,
487 integration: Integration::Local,
488 },
489 capabilities: CapabilityRequests {
490 nix_packaging: false,
491 reporting_policy: true,
492 scorecard: false,
493 code_scanning: None,
494 },
495 selection: vec![],
496 release_unavailable: None,
497 licence_refusal: None,
498 withheld: None,
499 collisions: None,
500 files: vec![
501 FileEntry {
502 path: "release-plz.toml".into(),
503 kind: "seeded",
504 action: "drift",
505 },
506 FileEntry {
507 path: "legacy.yml".into(),
508 kind: "rendered",
509 action: "released",
510 },
511 ],
512 next: vec!["rk upgrade --target /tmp/t --apply writes".into()],
513 };
514 assert_eq!(
515 serde_json::to_string(&report).expect("a report serializes"),
516 r#"{"schema":"rk.upgrade/10","mode":"preview","target":"/tmp/t","from_version":"0.1.0","to_version":"0.2.0","profile":{"technologies":["rust"],"forge":"github","release":{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}},"git":{"trunk":"master","checkout_mode":"main-worktree","integration":"local"},"capabilities":{"nix_packaging":false,"reporting_policy":true,"scorecard":false},"selection":[],"config":{"action":"added","changes":[],"content":"schema_version = 2\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"]}"#
517 );
518 }
519}