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