1use std::fmt::Write as _;
23
24use serde::Serialize;
25
26use crate::cli::upgrade::UpgradeArgs;
27use crate::diagnostic::{Diagnostic, Reason};
28use crate::embedded;
29use crate::error::RkError;
30use crate::held;
31use crate::landing::apply::{self, Action, Collision, Prepared, Replaced};
32use crate::landing::manifest::{self, Alignment, Manifest, Provider};
33use crate::landing::{self, lock};
34use crate::output::Output;
35use crate::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot};
36use crate::stage::CapabilityNote;
37
38#[derive(Debug, Serialize)]
40struct FileEntry {
41 path: String,
43 kind: &'static str,
46 action: &'static str,
49}
50
51#[derive(Debug, Serialize, PartialEq, Eq)]
53struct PendingAnswer {
54 key: &'static str,
56 default: &'static str,
58 reason: &'static str,
60 action: &'static str,
62}
63
64fn pending_answers(
71 args: &UpgradeArgs,
72 recorded: &Manifest,
73 existing: Option<&crate::config::Config>,
74) -> Vec<PendingAnswer> {
75 let answered = args.workflow_guards.is_some()
76 || args.workflow_only
77 || existing.is_some_and(|config| config.capabilities.workflow_guards.is_some())
78 || recorded.capabilities.workflow_guards.is_some();
79 if answered {
80 return Vec::new();
81 }
82 vec![PendingAnswer {
83 key: "capabilities.workflow_guards",
84 default: "on",
85 reason: "the record predates the workflow guards, and this target carries them",
86 action: "--workflow-guards off drops them; --workflow-guards on keeps them and records the answer",
87 }]
88}
89
90#[derive(Debug, Serialize)]
92struct Report {
93 schema: &'static str,
95 mode: &'static str,
97 target: String,
99 from_version: String,
101 to_version: &'static str,
103 profile: ProfileSnapshot,
105 git: GitWorkflow,
107 capabilities: CapabilityRequests,
109 selection: Vec<CapabilityNote>,
111 #[serde(skip_serializing_if = "Option::is_none")]
115 release_unavailable: Option<String>,
116 #[serde(skip_serializing_if = "Option::is_none")]
120 licence_refusal: Option<String>,
121 #[serde(skip_serializing_if = "Option::is_none")]
124 withheld: Option<Vec<landing::Withheld>>,
125 #[serde(skip_serializing_if = "Option::is_none")]
128 collisions: Option<Vec<Collision>>,
129 replaced: Vec<Replaced>,
132 #[serde(skip_serializing_if = "Vec::is_empty")]
135 pending: Vec<PendingAnswer>,
136 config: crate::config::Plan,
137 files: Vec<FileEntry>,
139 next: Vec<String>,
141}
142
143#[allow(
151 clippy::too_many_lines,
152 reason = "one pass holds the target, resolves the answers once from the flags or the stage, and lands"
153)]
154pub fn run(args: &UpgradeArgs) -> Result<(), RkError> {
155 let out = Output::new(args.json);
156 let lock = args
159 .apply
160 .then(|| lock::acquire(&args.target))
161 .transpose()?;
162 held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
167 let held = lock.as_ref().map_or_else(
168 || apply::Held::open(&args.target),
169 |lock| apply::Held::open_locked(&args.target, lock),
170 )?;
171 held::pause(apply::PAUSE_VAR, "held", "proceed-held");
173 let recorded = load_upgradable(&held)?;
174 let existing = crate::config::load(held.base().as_std_path())?;
175 let staged = args
176 .stage
177 .as_deref()
178 .map(|root| {
179 if answered(args) {
180 return Err(crate::stage::read::answered_twice());
181 }
182 let canonical = std::fs::canonicalize(args.target.as_std_path())?;
183 crate::stage::read::load(root, &canonical).map(|receipt| (root, receipt))
184 })
185 .transpose()?;
186 let params = match &staged {
187 Some((root, receipt)) => {
188 let params = landing::Params::resolve(
189 held.base(),
190 &crate::stage::read::inputs(&receipt.parameters),
191 existing.as_ref(),
192 Some(&recorded),
193 landing::Purpose::Upgrade,
194 )?;
195 crate::stage::read::confirm(¶ms, receipt, root)?;
196 params
197 }
198 None => resolve_params(args, &held, &recorded, existing.as_ref())?,
199 };
200
201 let mut prepared = apply::prepare(&held, Some(&recorded), ¶ms, existing.as_ref())?;
202 apply::name_replacements(&mut prepared, &args.replace)?;
203 crate::commands::init::report_moved_secrets(out, &held, &prepared)?;
204 let landed = if let Some(lock) = lock {
205 let landed = apply::land(
206 &held,
207 Some(&recorded),
208 &prepared,
209 apply::Origin::Upgrade,
210 &lock,
211 )?;
212 drop(lock);
213 Some(landed)
214 } else {
215 None
216 };
217 let replaced = landed.as_ref().map_or_else(
218 || apply::previewed_replacements(&prepared),
219 |landed| landed.replaced.clone(),
220 );
221 for entry in &replaced {
222 if let Some(saved) = &entry.saved_to {
223 out.result_line(format!("saved {} to {saved}", entry.path));
224 }
225 }
226
227 let pending = pending_answers(args, &recorded, existing.as_ref());
228 for answer in &pending {
229 out.result_line(format!(
230 "pending {}: {}; this upgrade answers {}; {}",
231 answer.key, answer.reason, answer.default, answer.action
232 ));
233 }
234 let sentinels = report_decisions(out, &held, &prepared, landed.is_some())?;
235 if landed.is_some() {
236 out.result_line(format!("rewrote {}", manifest::MANIFEST_PATH));
237 for sentinel in &sentinels {
238 out.result_line(format!("fill this sentinel: {sentinel}"));
239 }
240 }
241
242 let next = next_lines(args, ¶ms, prepared.collisions.is_empty());
243 out.next(&next);
244 out.emit(&Report {
245 schema: SCHEMA,
246 config: prepared.config.clone(),
247 mode: if args.apply { "apply" } else { "preview" },
248 target: args.target.to_string(),
249 from_version: recorded.rk_version,
250 to_version: env!("CARGO_PKG_VERSION"),
251 profile: params.profile().clone(),
252 git: params.git().clone(),
253 capabilities: params.capabilities().clone(),
254 selection: prepared
255 .projection
256 .capabilities
257 .iter()
258 .map(|selection| CapabilityNote::of(selection, &prepared.projection))
259 .collect(),
260 release_unavailable: prepared.projection.release_unavailable().map(str::to_owned),
261 licence_refusal: prepared.projection.licence_refusal.clone(),
262 withheld: withheld_of(&prepared),
263 collisions: (!prepared.collisions.is_empty()).then(|| prepared.collisions.clone()),
264 replaced,
265 pending,
266 files: prepared
267 .decisions
268 .iter()
269 .map(|decision| FileEntry {
270 path: decision.destination.clone(),
271 kind: decision.kind.as_str(),
272 action: decision.action.as_str(),
273 })
274 .chain(prepared.collisions.iter().map(|collision| FileEntry {
275 path: collision.path.clone(),
276 kind: landing::kind_of(&collision.path).map_or("unknown", landing::Kind::as_str),
277 action: "collision",
278 }))
279 .collect(),
280 next,
281 })
282}
283
284const SCHEMA: &str = "rk.upgrade/11";
286
287const fn answered(args: &UpgradeArgs) -> bool {
289 !args.profile.is_empty()
290 || args.nix_packaging.is_some()
291 || args.reporting_policy.is_some()
292 || args.scorecard.is_some()
293 || args.workflow_guards.is_some()
294 || args.workflow_only
295 || args.code_scanning.is_some()
296}
297
298fn withheld_of(prepared: &Prepared) -> Option<Vec<landing::Withheld>> {
300 let withheld: Vec<landing::Withheld> = prepared
301 .projection
302 .omissions
303 .iter()
304 .map(|omission| landing::Withheld {
305 path: omission.destination.clone(),
306 reason: omission.reason.clone(),
307 })
308 .collect();
309 (!withheld.is_empty()).then_some(withheld)
310}
311
312fn report_decisions(
323 out: Output,
324 held: &apply::Held,
325 prepared: &Prepared,
326 landed: bool,
327) -> Result<Vec<String>, RkError> {
328 out.result_line(format!(
329 "profile: {}",
330 crate::commands::profile::describe(
331 prepared.params.profile(),
332 prepared.params.git(),
333 prepared.params.capabilities(),
334 prepared.params.repo()
335 )
336 ));
337 crate::commands::init::describe_selection(out, prepared);
338 for key in &prepared.config.changes {
339 out.result_line(format!("configuration changes {key}"));
340 }
341 out.result_line(format!(
342 "{} {}",
343 prepared.config.action,
344 crate::config::CONFIG_PATH
345 ));
346 let mut sentinels: Vec<String> = Vec::new();
347 for decision in &prepared.decisions {
348 if landed && matches!(decision.action, Action::Created | Action::Replaced) {
349 let bytes =
350 landing::read_recorded(held.base(), &decision.destination)?.unwrap_or_default();
351 collect_sentinels(&decision.destination, &bytes, &mut sentinels);
352 }
353 out.result_line(crate::commands::init::describe(decision));
354 }
355 for collision in &prepared.collisions {
356 out.result_line(format!(
357 "collision {}: {}",
358 collision.path, collision.reason
359 ));
360 }
361 for entry in &prepared.projection.omissions {
362 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
363 if let Some(action) = &entry.action {
364 out.result_line(format!(" action: {action}"));
365 }
366 }
367 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
368 out.result_line(format!("licence refusal: {reason}"));
369 }
370 if let Some(reason) = prepared.projection.release_unavailable() {
371 out.result_line(format!("release automation unavailable: {reason}"));
372 }
373 Ok(sentinels)
374}
375
376fn toggle(flag: &str, value: Option<&str>) -> Result<Option<bool>, RkError> {
381 match value {
382 None => Ok(None),
383 Some("on") => Ok(Some(true)),
384 Some("off") => Ok(Some(false)),
385 Some(other) => Err(RkError::Usage(format!(
386 "unknown --{flag} value '{other}'; the values are: on, off"
387 ))),
388 }
389}
390
391fn resolve_params(
392 args: &UpgradeArgs,
393 held: &apply::Held,
394 recorded: &Manifest,
395 existing: Option<&crate::config::Config>,
396) -> Result<landing::Params, RkError> {
397 let nix = toggle("nix-packaging", args.nix_packaging.as_deref())?;
398 let reporting_policy = toggle("reporting-policy", args.reporting_policy.as_deref())?;
399 let scorecard = toggle("scorecard", args.scorecard.as_deref())?;
400 let workflow_guards = toggle("workflow-guards", args.workflow_guards.as_deref())?;
401 let code_scanning = args
402 .code_scanning
403 .as_deref()
404 .map(Provider::parse)
405 .transpose()?;
406 let inputs = landing::Inputs {
407 nix,
408 reporting_policy,
409 scorecard,
410 code_scanning,
411 workflow_guards,
412 ..args.profile.inputs()?
413 };
414 let inputs = if args.workflow_only {
415 crate::cli::profile_flags::workflow_only(inputs)?
416 } else {
417 inputs
418 };
419 landing::Params::resolve(
420 held.base(),
421 &inputs,
422 existing,
423 Some(recorded),
424 landing::Purpose::Upgrade,
425 )
426}
427
428fn next_lines(args: &UpgradeArgs, params: &landing::Params, clean: bool) -> Vec<String> {
432 let profile = &args.profile;
436 let mut identity_flags = String::new();
437 for technology in params.technologies() {
438 if !profile.technology.is_empty() {
439 let _ = write!(identity_flags, " --technology {technology}");
440 }
441 }
442 for (given, flag, value) in [
443 (
444 profile.forge.is_some(),
445 "forge",
446 params.forge().map(str::to_owned),
447 ),
448 (
449 profile.repo.is_some(),
450 "repo",
451 Some(params.repo().to_owned()),
452 ),
453 (
454 profile.release_mode.is_some(),
455 "release-mode",
456 Some(params.release_mode().as_str().to_owned()),
457 ),
458 (
459 profile.release_driver.is_some(),
460 "release-driver",
461 params.driver().map(str::to_owned),
462 ),
463 (
464 profile.release_style.is_some(),
465 "release-style",
466 params.style().map(|style| style.as_str().to_owned()),
467 ),
468 (
469 profile.trunk.is_some(),
470 "trunk",
471 Some(params.trunk().to_owned()),
472 ),
473 (
474 profile.checkout_mode.is_some(),
475 "checkout-mode",
476 Some(params.checkout_mode().as_str().to_owned()),
477 ),
478 ] {
479 if given && let Some(value) = value {
480 let _ = write!(identity_flags, " --{flag} {value}");
481 }
482 }
483 let workflow_flag = if args.workflow_only {
486 format!(" {}", crate::cli::profile_flags::WORKFLOW_ONLY)
487 } else {
488 String::new()
489 };
490 let style_flag = String::new();
491 let capabilities = params.capability_toggles();
492 if args.apply {
493 vec![
494 "commit the upgraded files, the receipt included".to_owned(),
495 format!("rk status --target {} reports the result", args.target),
496 ]
497 } else if clean {
498 vec![
499 format!(
500 "rk upgrade{identity_flags}{workflow_flag}{style_flag}{capabilities} --target {} --apply writes",
501 args.target
502 ),
503 format!(
504 "rk stage --target {} stages the complete candidate for a byte comparison",
505 args.target
506 ),
507 ]
508 } else {
509 vec![
510 format!(
511 "resolve each collision above through the rk-setup skill; rk upgrade{identity_flags}{workflow_flag}{style_flag}{capabilities} --target {} --apply refuses until then",
512 args.target
513 ),
514 format!(
515 "rk stage --target {} stages the complete candidate for a byte comparison",
516 args.target
517 ),
518 ]
519 }
520}
521
522fn load_upgradable(held: &apply::Held) -> Result<Manifest, RkError> {
525 let target = held.display();
526 let Some(recorded) = manifest::load(held.base())? else {
527 return Err(RkError::refusal(
528 Diagnostic::new(
529 Reason::StateDrift,
530 format!(
531 "no {} at {target}: an upgrade needs the receipt of the landing it moves, and nothing was written",
532 manifest::MANIFEST_PATH
533 ),
534 )
535 .expected("a recorded landing")
536 .action(format!(
537 "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"
538 ))
539 .target_state("unchanged"),
540 ));
541 };
542 if manifest::alignment(&recorded.rk_version, env!("CARGO_PKG_VERSION"))
543 == Alignment::TargetNewer
544 {
545 return Err(RkError::refusal(
546 Diagnostic::new(
547 Reason::StateDrift,
548 format!(
549 "this landing came from rk {}, newer than this binary's {}; downgrading a target is not an upgrade",
550 recorded.rk_version,
551 env!("CARGO_PKG_VERSION")
552 ),
553 )
554 .expected("a binary at or above the recorded rk_version")
555 .action(format!("install release-kit {} or newer", recorded.rk_version))
556 .target_state("unchanged"),
557 ));
558 }
559 Ok(recorded)
560}
561
562fn collect_sentinels(destination: &str, bytes: &[u8], found: &mut Vec<String>) {
564 let text = String::from_utf8_lossy(bytes);
565 for (idx, line) in text.lines().enumerate() {
566 if line.contains(embedded::SENTINEL) {
567 found.push(format!("{destination}:{}: {}", idx + 1, line.trim()));
568 }
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::{FileEntry, Report};
575 use crate::landing::CheckoutMode;
576 use crate::landing::Integration;
577 use crate::profile::{
578 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
579 };
580
581 #[test]
583 fn the_upgrade_report_schema_snapshot_holds() {
584 let report = Report {
585 schema: super::SCHEMA,
586 config: crate::config::Plan {
587 action: "added",
588 changes: vec![],
589 content: "schema_version = 2\n".into(),
590 },
591 mode: "preview",
592 target: "/tmp/t".into(),
593 from_version: "0.1.0".into(),
594 to_version: "0.2.0",
595 profile: ProfileSnapshot {
596 technologies: vec!["rust".into()],
597 forge: Some("github".into()),
598 release: ReleaseIntent {
599 mode: ReleaseMode::Automatic,
600 driver: Some("rust".into()),
601 style: Some(crate::landing::Style::Trunk),
602 line_prefix: Some("release/".into()),
603 registry: None,
604 build: None,
605 },
606 },
607 git: GitWorkflow {
608 trunk: "master".into(),
609 checkout_mode: CheckoutMode::MainWorktree,
610 integration: Integration::Local,
611 merge_form: crate::landing::MergeForm::Squash,
612 },
613 capabilities: CapabilityRequests {
614 nix_packaging: false,
615 reporting_policy: true,
616 scorecard: false,
617 code_scanning: None,
618 workflow_guards: Some(false),
619 },
620 selection: vec![],
621 release_unavailable: None,
622 licence_refusal: None,
623 withheld: None,
624 collisions: Some(vec![crate::landing::apply::Collision {
625 path: "SECURITY.md".into(),
626 reason: "was edited after the landing".into(),
627 replaceable: true,
628 }]),
629 replaced: vec![crate::landing::apply::Replaced {
630 path: ".github/workflows/release-plz.yml".into(),
631 saved_to: Some("/state/snapshots/widget-0123456789ab/20261009T120000Z/replaced/.github/workflows/release-plz.yml".into()),
632 }],
633 pending: vec![],
634 files: vec![
635 FileEntry {
636 path: "release-plz.toml".into(),
637 kind: "seeded",
638 action: "drift",
639 },
640 FileEntry {
641 path: "legacy.yml".into(),
642 kind: "rendered",
643 action: "released",
644 },
645 ],
646 next: vec!["rk upgrade --target /tmp/t --apply writes".into()],
647 };
648 assert_eq!(
649 serde_json::to_string(&report).expect("a report serializes"),
650 r#"{"schema":"rk.upgrade/11","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","merge_form":"squash"},"capabilities":{"nix_packaging":false,"reporting_policy":true,"scorecard":false,"workflow_guards":false},"selection":[],"collisions":[{"path":"SECURITY.md","reason":"was edited after the landing","replaceable":true}],"replaced":[{"path":".github/workflows/release-plz.yml","saved_to":"/state/snapshots/widget-0123456789ab/20261009T120000Z/replaced/.github/workflows/release-plz.yml"}],"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"]}"#
651 );
652 }
653}