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