1use 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#[derive(Debug, Serialize)]
25struct FileEntry {
26 path: String,
28 kind: &'static str,
30 action: &'static str,
33}
34
35#[derive(Debug, Serialize)]
37struct Report {
38 schema: &'static str,
40 mode: &'static str,
42 target: String,
44 tech: String,
46 forge: String,
48 from_version: String,
50 to_version: &'static str,
52 workflow: &'static str,
55 style: &'static str,
56 nix: bool,
58 #[serde(skip_serializing_if = "Option::is_none")]
61 withheld: Option<Vec<landing::Withheld>>,
62 config: crate::config::Plan,
63 files: Vec<FileEntry>,
65 next: Vec<String>,
67}
68
69struct Decision<'a> {
72 entry: Option<&'a Entry>,
73 action: &'static str,
74 record: FileRecord,
75}
76
77pub fn run(args: &UpgradeArgs) -> Result<(), RkError> {
86 let out = Output::new(args.json);
87 let recorded = load_upgradable(&args.target)?;
88 let existing = crate::config::load(args.target.as_std_path())?;
89 let params = resolve_params(args, &recorded, existing.as_ref())?;
90 let config = crate::config::Plan::new(
91 args.target.as_std_path(),
92 ¶ms,
93 existing.as_ref(),
94 Some(&recorded),
95 )?;
96 for key in &config.changes {
97 out.result_line(format!("configuration changes {key}"));
98 }
99 out.result_line(format!("{} {}", config.action, crate::config::CONFIG_PATH));
100 let style = params
101 .style()
102 .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
103 let mut entries = landing::projection(¶ms)?;
104 let withheld =
105 landing::withhold_nix(&args.target, params.nix(), Some(&recorded), &mut entries)?;
106 refuse_non_regular(&args.target, &entries)?;
107
108 let (decisions, conflicts) = decide_all(args, &recorded, &entries)?;
109 let dropped: Vec<String> = recorded
112 .files
113 .iter()
114 .filter(|file| {
115 !entries
116 .iter()
117 .any(|entry| entry.destination == file.destination)
118 })
119 .map(|file| file.destination.clone())
120 .collect();
121
122 if args.apply && !conflicts.is_empty() {
123 return Err(refuse_conflicts(&conflicts));
124 }
125
126 let mut sentinels: Vec<String> = Vec::new();
127 for decision in &decisions {
128 if args.apply && matches!(decision.action, "updated" | "added") {
129 if let Some(entry) = decision.entry {
130 landing::write_destination(&args.target, entry)?;
131 collect_sentinels(entry, &mut sentinels);
132 }
133 }
134 out.result_line(describe(decision));
135 }
136 for path in &dropped {
137 out.result_line(format!(
138 "dropped {path} (no longer shipped; now target-owned)"
139 ));
140 }
141 for entry in &withheld {
142 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
143 }
144
145 if args.apply {
146 config.apply(args.target.as_std_path())?;
147 rewrite_record(&args.target, &recorded, ¶ms, &decisions)?;
148 out.result_line(format!("rewrote {}", manifest::MANIFEST_PATH));
149 for sentinel in &sentinels {
150 out.result_line(format!("fill this sentinel: {sentinel}"));
151 }
152 }
153
154 let next = next_lines(args, conflicts.is_empty());
155 out.next(&next);
156 out.emit(&Report {
157 schema: "rk.upgrade/5",
158 config,
159 mode: if args.apply { "apply" } else { "preview" },
160 target: args.target.to_string(),
161 tech: params.tech().into(),
162 forge: params.forge().into(),
163 from_version: recorded.rk_version.clone(),
164 to_version: env!("CARGO_PKG_VERSION"),
165 workflow: params.workflow().as_str(),
166 style: style.as_str(),
167 nix: params.nix(),
168 withheld: (!withheld.is_empty()).then_some(withheld),
169 files: decisions
170 .iter()
171 .map(|decision| FileEntry {
172 path: decision.record.destination.clone(),
173 kind: decision.record.kind.as_str(),
174 action: decision.action,
175 })
176 .chain(dropped.iter().map(|path| FileEntry {
177 path: path.clone(),
178 kind: "dropped",
179 action: "dropped",
180 }))
181 .collect(),
182 next,
183 })
184}
185
186fn describe(decision: &Decision<'_>) -> String {
187 match decision.action {
188 "drift" => format!(
189 "drift {} (seeded, target-owned)",
190 decision.record.destination
191 ),
192 "kept" => format!("kept {} (target-owned)", decision.record.destination),
193 "conflict" => format!(
194 "conflict {} (edited, release-kit-owned)",
195 decision.record.destination
196 ),
197 action => format!("{action} {}", decision.record.destination),
198 }
199}
200
201fn resolve_params(
202 args: &UpgradeArgs,
203 recorded: &Manifest,
204 existing: Option<&crate::config::Config>,
205) -> Result<landing::Params, RkError> {
206 let nix = match args.nix.as_deref() {
207 None => None,
208 Some("on") => Some(true),
209 Some("off") => Some(false),
210 Some(other) => {
211 return Err(RkError::Usage(format!(
212 "unknown --nix value '{other}'; the values are: on, off"
213 )));
214 }
215 };
216 landing::Params::resolve(
217 &args.target,
218 &landing::Inputs {
219 tech: args.tech.as_deref(),
220 forge: args.forge.as_deref(),
221 repo: args.repo.as_deref(),
222 workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
223 style: args.style.as_deref().map(Style::parse).transpose()?,
224 nix,
225 },
226 existing,
227 Some(recorded),
228 landing::Purpose::Upgrade,
229 )
230}
231
232fn refuse_conflicts(conflicts: &[String]) -> RkError {
235 RkError::refusal(
236 Diagnostic::new(
237 Reason::StateDrift,
238 format!(
239 "these files release-kit owns were edited, and nothing was written: {}",
240 conflicts.join(", ")
241 ),
242 )
243 .expected("every rendered file as the record left it")
244 .action("resolve each, or re-land it, then run 'rk upgrade' again")
245 .target_state("unchanged"),
246 )
247}
248
249fn next_lines(args: &UpgradeArgs, clean: bool) -> Vec<String> {
253 let identity_flags: String = [
254 ("tech", args.tech.as_deref()),
255 ("forge", args.forge.as_deref()),
256 ("repo", args.repo.as_deref()),
257 ]
258 .into_iter()
259 .filter_map(|(key, value)| value.map(|value| format!(" --{key} {value}")))
260 .collect();
261 let workflow_flag = args
262 .workflow
263 .as_deref()
264 .map_or_else(String::new, |mode| format!(" --workflow {mode}"));
265 let style_flag = args
266 .style
267 .as_deref()
268 .map_or_else(String::new, |style| format!(" --style {style}"));
269 let nix_flag = args
270 .nix
271 .as_deref()
272 .map_or_else(String::new, |value| format!(" --nix {value}"));
273 if args.apply {
274 vec![
275 "commit the upgraded files, the record included".to_owned(),
276 format!("rk status --target {} reports the result", args.target),
277 ]
278 } else if clean {
279 vec![format!(
280 "rk upgrade{identity_flags}{workflow_flag}{style_flag}{nix_flag} --target {} --apply writes",
281 args.target
282 )]
283 } else {
284 vec![format!(
285 "resolve each conflict above; rk upgrade{identity_flags}{workflow_flag}{style_flag}{nix_flag} --target {} --apply refuses until then",
286 args.target
287 )]
288 }
289}
290
291fn rewrite_record(
295 target: &camino::Utf8Path,
296 recorded: &Manifest,
297 params: &landing::Params,
298 decisions: &[Decision],
299) -> Result<(), RkError> {
300 manifest::write(
301 target,
302 &Manifest {
303 schema_version: manifest::SCHEMA_VERSION,
304 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
305 payload_sha256: crate::commands::payload::report().payload_sha256,
306 origin: recorded.origin.clone(),
307 tech: params.tech().into(),
308 forge: params.forge().into(),
309 landed_at: recorded.landed_at.clone(),
310 parameters: manifest::Parameters {
311 repo: params.repo().into(),
312 workflow: params.workflow(),
313 style: params.style(),
314 nix: params.nix(),
315 trunk: params.trunk().to_owned(),
316 line_prefix: params.line_prefix().to_owned(),
317 security_contact: params.security_contact().to_owned(),
318 security_response: params.security_response().to_owned(),
319 },
320 files: decisions
321 .iter()
322 .map(|decision| clone_record(&decision.record))
323 .collect(),
324 pins: registry::pins_for(params.tech())
325 .into_iter()
326 .map(|pin| (pin.name, pin.version))
327 .collect(),
328 },
329 )
330}
331
332fn load_upgradable(target: &camino::Utf8Path) -> Result<Manifest, RkError> {
335 let Some(recorded) = manifest::load(target)? else {
336 return Err(RkError::refusal(
337 Diagnostic::new(
338 Reason::StateDrift,
339 format!(
340 "no {} at {target}: there is no baseline to upgrade against",
341 manifest::MANIFEST_PATH
342 ),
343 )
344 .expected("a recorded landing")
345 .action(
346 "rk init lands a first landing; rk adopt records one made before the record existed",
347 )
348 .target_state("unchanged"),
349 ));
350 };
351 if manifest::alignment(&recorded.rk_version, env!("CARGO_PKG_VERSION"))
352 == Alignment::TargetNewer
353 {
354 return Err(RkError::refusal(
355 Diagnostic::new(
356 Reason::StateDrift,
357 format!(
358 "this landing came from rk {}, newer than this binary's {}; downgrading a target is not an upgrade",
359 recorded.rk_version,
360 env!("CARGO_PKG_VERSION")
361 ),
362 )
363 .expected("a binary at or above the recorded rk_version")
364 .action(format!("install release-kit {} or newer", recorded.rk_version))
365 .target_state("unchanged"),
366 ));
367 }
368 Ok(recorded)
369}
370
371fn decide_all<'a>(
378 args: &UpgradeArgs,
379 recorded: &'a Manifest,
380 entries: &'a [Entry],
381) -> Result<(Vec<Decision<'a>>, Vec<String>), RkError> {
382 let mut conflicts: Vec<String> = Vec::new();
383 let mut decisions: Vec<Decision<'a>> = Vec::new();
384 if landing::hooks_file_defect(&args.target)?.is_some() {
385 conflicts.push(landing::HOOKS_DESTINATION.to_owned());
386 }
387 for entry in entries {
388 let disk = landing::read_recorded(&args.target, &entry.destination)?;
389 let mut decision = decide(
390 entry,
391 recorded.file(&entry.destination),
392 disk.as_deref(),
393 &mut conflicts,
394 );
395 if entry.destination == landing::HOOKS_DESTINATION
396 && conflicts.iter().any(|c| c == landing::HOOKS_DESTINATION)
397 {
398 decision.action = "conflict";
399 }
400 decisions.push(decision);
401 }
402 let mut seen = std::collections::HashSet::new();
403 conflicts.retain(|conflict| seen.insert(conflict.clone()));
404 Ok((decisions, conflicts))
405}
406
407fn decide<'a>(
408 entry: &'a Entry,
409 recorded: Option<&FileRecord>,
410 disk: Option<&[u8]>,
411 conflicts: &mut Vec<String>,
412) -> Decision<'a> {
413 let candidate_record = |sha256: Digest| FileRecord {
414 destination: entry.destination.clone(),
415 kind: entry.kind,
416 sha256,
417 baseline_sha256: match entry.kind {
418 Kind::State => None,
419 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
420 },
421 };
422 let Some(recorded) = recorded else {
423 return decide_added(entry, disk, conflicts);
424 };
425
426 if recorded.kind == Kind::Seeded && entry.kind == Kind::Rendered {
430 let untouched =
431 disk.is_some_and(|bytes| Some(Digest::of(bytes)) == recorded.baseline_sha256);
432 if !untouched {
433 conflicts.push(entry.destination.clone());
434 return Decision {
435 entry: Some(entry),
436 action: "conflict",
437 record: candidate_record(Digest::of(&entry.rendered)),
438 };
439 }
440 return Decision {
441 entry: Some(entry),
442 action: "updated",
443 record: candidate_record(Digest::of(&entry.rendered)),
444 };
445 }
446
447 match entry.kind {
448 Kind::Rendered => match disk {
449 Some(bytes) if Digest::of(bytes) == recorded.sha256 => Decision {
450 entry: Some(entry),
451 action: if bytes == entry.rendered {
452 "unchanged"
453 } else {
454 "updated"
455 },
456 record: candidate_record(Digest::of(&entry.rendered)),
457 },
458 Some(bytes) if bytes == entry.rendered => Decision {
459 entry: Some(entry),
460 action: "unchanged",
461 record: candidate_record(Digest::of(&entry.rendered)),
462 },
463 _ => {
466 conflicts.push(entry.destination.clone());
467 Decision {
468 entry: Some(entry),
469 action: "conflict",
470 record: candidate_record(Digest::of(&entry.rendered)),
471 }
472 }
473 },
474 Kind::Seeded => {
475 let baseline = if recorded.kind == Kind::Rendered {
482 Some(recorded.sha256.clone())
483 } else {
484 recorded.baseline_sha256.clone()
485 };
486 let (action, sha256) = disk.map_or_else(
487 || ("drift", recorded.sha256.clone()),
488 |bytes| {
489 let digest = Digest::of(bytes);
490 if Some(&digest) == baseline.as_ref() {
491 ("unchanged", digest)
492 } else {
493 ("drift", digest)
494 }
495 },
496 );
497 Decision {
498 entry: None,
499 action,
500 record: FileRecord {
501 destination: entry.destination.clone(),
502 kind: entry.kind,
503 sha256,
504 baseline_sha256: baseline,
505 },
506 }
507 }
508 Kind::State => Decision {
509 entry: None,
510 action: "state",
511 record: FileRecord {
512 destination: entry.destination.clone(),
513 kind: entry.kind,
514 sha256: recorded.sha256.clone(),
515 baseline_sha256: None,
516 },
517 },
518 }
519}
520
521fn decide_added<'a>(
526 entry: &'a Entry,
527 disk: Option<&[u8]>,
528 conflicts: &mut Vec<String>,
529) -> Decision<'a> {
530 let (action, sha256) = match disk {
531 None => ("added", Digest::of(&entry.rendered)),
532 Some(bytes) if bytes == entry.rendered => ("unchanged", Digest::of(bytes)),
533 Some(bytes) if entry.kind != Kind::Rendered => ("kept", Digest::of(bytes)),
534 Some(_) => {
535 conflicts.push(entry.destination.clone());
536 ("conflict", Digest::of(&entry.rendered))
537 }
538 };
539 Decision {
540 entry: Some(entry),
541 action,
542 record: FileRecord {
543 destination: entry.destination.clone(),
544 kind: entry.kind,
545 sha256,
546 baseline_sha256: match entry.kind {
547 Kind::State => None,
548 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
549 },
550 },
551 }
552}
553
554fn refuse_non_regular(target: &camino::Utf8Path, entries: &[Entry]) -> Result<(), RkError> {
557 for entry in entries {
558 if entry.kind != Kind::Rendered {
559 continue;
560 }
561 let path = target.join(&entry.destination);
562 if let Ok(meta) = std::fs::symlink_metadata(&path) {
563 if !meta.is_file() {
564 return Err(RkError::refusal(
565 Diagnostic::new(
566 Reason::StateDrift,
567 format!("{path} exists and is not a regular file; nothing was written"),
568 )
569 .expected("every rendered destination a regular file")
570 .target_state("unchanged"),
571 ));
572 }
573 }
574 }
575 Ok(())
576}
577
578fn collect_sentinels(entry: &Entry, found: &mut Vec<String>) {
580 let text = String::from_utf8_lossy(&entry.rendered);
581 for (idx, line) in text.lines().enumerate() {
582 if line.contains(embedded::SENTINEL) {
583 found.push(format!(
584 "{}:{}: {}",
585 entry.destination,
586 idx + 1,
587 line.trim()
588 ));
589 }
590 }
591}
592
593fn clone_record(record: &FileRecord) -> FileRecord {
595 FileRecord {
596 destination: record.destination.clone(),
597 kind: record.kind,
598 sha256: record.sha256.clone(),
599 baseline_sha256: record.baseline_sha256.clone(),
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::{FileEntry, Report};
606
607 #[test]
609 fn the_upgrade_report_schema_snapshot_holds() {
610 let report = Report {
611 schema: "rk.upgrade/5",
612 config: crate::config::Plan {
613 action: "added",
614 changes: vec![],
615 content: "schema_version = 1\n".into(),
616 },
617 mode: "preview",
618 target: "/tmp/t".into(),
619 tech: "rust".into(),
620 forge: "github".into(),
621 from_version: "0.1.0".into(),
622 to_version: "0.2.0",
623 workflow: "branches",
624 style: "trunk",
625 nix: false,
626 withheld: None,
627 files: vec![FileEntry {
628 path: "release-plz.toml".into(),
629 kind: "seeded",
630 action: "drift",
631 }],
632 next: vec!["rk upgrade --target /tmp/t --apply writes".into()],
633 };
634 assert_eq!(
635 serde_json::to_string(&report).expect("a report serializes"),
636 r#"{"schema":"rk.upgrade/5","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,"config":{"action":"added","changes":[],"content":"schema_version = 1\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"drift"}],"next":["rk upgrade --target /tmp/t --apply writes"]}"#
637 );
638 }
639}