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, 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 files: Vec<FileEntry>,
57 next: Vec<String>,
59}
60
61struct Decision<'a> {
64 entry: Option<&'a Entry>,
65 action: &'static str,
66 record: FileRecord,
67}
68
69pub fn run(args: &UpgradeArgs) -> Result<(), RkError> {
78 let out = Output::new(args.json);
79 let mut recorded = load_upgradable(&args.target)?;
80 resolve_scopes(&mut recorded, args.scopes.as_deref())?;
81 if let Some(raw) = args.workflow.as_deref() {
85 recorded.parameters.workflow = Workflow::parse(raw)?;
86 }
87 let entries = landing::projection(
88 &recorded.tech,
89 &recorded.forge,
90 &recorded.parameters.repo,
91 &recorded.parameters.scopes,
92 recorded.parameters.workflow,
93 )?;
94 refuse_non_regular(&args.target, &entries)?;
95
96 let (decisions, conflicts) = decide_all(args, &recorded, &entries)?;
97 let mut dropped: Vec<String> = Vec::new();
100 for file in &recorded.files {
101 if !entries
102 .iter()
103 .any(|entry| entry.destination == file.destination)
104 {
105 dropped.push(file.destination.clone());
106 }
107 }
108
109 if args.apply && !conflicts.is_empty() {
110 return Err(refuse_conflicts(&conflicts));
111 }
112
113 let mut sentinels: Vec<String> = Vec::new();
114 for decision in &decisions {
115 if args.apply && matches!(decision.action, "updated" | "added") {
116 if let Some(entry) = decision.entry {
117 landing::write_destination(&args.target, entry)?;
118 collect_sentinels(entry, &mut sentinels);
119 }
120 }
121 out.result_line(match decision.action {
122 "drift" => format!(
123 "drift {} (seeded, target-owned)",
124 decision.record.destination
125 ),
126 "kept" => format!("kept {} (target-owned)", decision.record.destination),
127 "conflict" => format!(
128 "conflict {} (edited, release-kit-owned)",
129 decision.record.destination
130 ),
131 action => format!("{action} {}", decision.record.destination),
132 });
133 }
134 for path in &dropped {
135 out.result_line(format!(
136 "dropped {path} (no longer shipped; now target-owned)"
137 ));
138 }
139
140 if args.apply {
141 rewrite_record(&args.target, &recorded, &decisions)?;
142 out.result_line(format!("rewrote {}", manifest::MANIFEST_PATH));
143 for sentinel in &sentinels {
144 out.result_line(format!("fill this sentinel: {sentinel}"));
145 }
146 }
147
148 let next = next_lines(args, conflicts.is_empty());
149 out.next(&next);
150 out.emit(&Report {
151 schema: "rk.upgrade/2",
152 mode: if args.apply { "apply" } else { "preview" },
153 target: args.target.to_string(),
154 tech: recorded.tech.clone(),
155 forge: recorded.forge.clone(),
156 from_version: recorded.rk_version.clone(),
157 to_version: env!("CARGO_PKG_VERSION"),
158 workflow: recorded.parameters.workflow.as_str(),
159 files: decisions
160 .iter()
161 .map(|decision| FileEntry {
162 path: decision.record.destination.clone(),
163 kind: decision.record.kind.as_str(),
164 action: decision.action,
165 })
166 .chain(dropped.iter().map(|path| FileEntry {
167 path: path.clone(),
168 kind: "dropped",
169 action: "dropped",
170 }))
171 .collect(),
172 next,
173 })
174}
175
176fn refuse_conflicts(conflicts: &[String]) -> RkError {
179 RkError::refusal(
180 Diagnostic::new(
181 Reason::StateDrift,
182 format!(
183 "these files release-kit owns were edited, and nothing was written: {}",
184 conflicts.join(", ")
185 ),
186 )
187 .expected("every rendered file as the record left it")
188 .action("resolve each, or re-land it, then run 'rk upgrade' again")
189 .target_state("unchanged"),
190 )
191}
192
193fn next_lines(args: &UpgradeArgs, clean: bool) -> Vec<String> {
197 let workflow_flag = args
198 .workflow
199 .as_deref()
200 .map_or_else(String::new, |mode| format!(" --workflow {mode}"));
201 if args.apply {
202 vec![
203 "commit the upgraded files, the record included".to_owned(),
204 format!("rk status --target {} reports the result", args.target),
205 ]
206 } else if clean {
207 vec![format!(
208 "rk upgrade{workflow_flag} --target {} --apply writes",
209 args.target
210 )]
211 } else {
212 vec![format!(
213 "resolve each conflict above; rk upgrade{workflow_flag} --target {} --apply refuses until then",
214 args.target
215 )]
216 }
217}
218
219fn rewrite_record(
223 target: &camino::Utf8Path,
224 recorded: &Manifest,
225 decisions: &[Decision],
226) -> Result<(), RkError> {
227 manifest::write(
228 target,
229 &Manifest {
230 schema_version: manifest::SCHEMA_VERSION,
231 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
232 payload_sha256: crate::commands::payload::report().payload_sha256,
233 origin: recorded.origin.clone(),
234 tech: recorded.tech.clone(),
235 forge: recorded.forge.clone(),
236 landed_at: recorded.landed_at.clone(),
237 parameters: manifest::Parameters {
238 repo: recorded.parameters.repo.clone(),
239 scopes: recorded.parameters.scopes.clone(),
240 workflow: recorded.parameters.workflow,
241 },
242 files: decisions
243 .iter()
244 .map(|decision| clone_record(&decision.record))
245 .collect(),
246 pins: registry::pins_for(&recorded.tech)
247 .into_iter()
248 .map(|pin| (pin.name, pin.version))
249 .collect(),
250 },
251 )
252}
253
254fn load_upgradable(target: &camino::Utf8Path) -> Result<Manifest, RkError> {
257 let Some(recorded) = manifest::load(target)? else {
258 return Err(RkError::refusal(
259 Diagnostic::new(
260 Reason::StateDrift,
261 format!(
262 "no {} at {target}: there is no baseline to upgrade against",
263 manifest::MANIFEST_PATH
264 ),
265 )
266 .expected("a recorded landing")
267 .action(
268 "rk init lands a first landing; rk adopt records one made before the record existed",
269 )
270 .target_state("unchanged"),
271 ));
272 };
273 if manifest::alignment(&recorded.rk_version, env!("CARGO_PKG_VERSION"))
274 == Alignment::TargetNewer
275 {
276 return Err(RkError::refusal(
277 Diagnostic::new(
278 Reason::StateDrift,
279 format!(
280 "this landing came from rk {}, newer than this binary's {}; downgrading a target is not an upgrade",
281 recorded.rk_version,
282 env!("CARGO_PKG_VERSION")
283 ),
284 )
285 .expected("a binary at or above the recorded rk_version")
286 .action(format!("install release-kit {} or newer", recorded.rk_version))
287 .target_state("unchanged"),
288 ));
289 }
290 Ok(recorded)
291}
292
293fn decide_all<'a>(
300 args: &UpgradeArgs,
301 recorded: &'a Manifest,
302 entries: &'a [Entry],
303) -> Result<(Vec<Decision<'a>>, Vec<String>), RkError> {
304 let mut conflicts: Vec<String> = Vec::new();
305 let mut decisions: Vec<Decision<'a>> = Vec::new();
306 if landing::hooks_file_defect(&args.target)?.is_some() {
307 conflicts.push(landing::HOOKS_DESTINATION.to_owned());
308 }
309 for entry in entries {
310 let disk = landing::read_recorded(&args.target, &entry.destination)?;
311 let mut decision = decide(
312 entry,
313 recorded.file(&entry.destination),
314 disk.as_deref(),
315 &mut conflicts,
316 );
317 if entry.destination == landing::HOOKS_DESTINATION
318 && conflicts.iter().any(|c| c == landing::HOOKS_DESTINATION)
319 {
320 decision.action = "conflict";
321 }
322 decisions.push(decision);
323 }
324 let mut seen = std::collections::HashSet::new();
325 conflicts.retain(|conflict| seen.insert(conflict.clone()));
326 Ok((decisions, conflicts))
327}
328
329fn decide<'a>(
330 entry: &'a Entry,
331 recorded: Option<&FileRecord>,
332 disk: Option<&[u8]>,
333 conflicts: &mut Vec<String>,
334) -> Decision<'a> {
335 let candidate_record = |sha256: Digest| FileRecord {
336 destination: entry.destination.clone(),
337 kind: entry.kind,
338 sha256,
339 baseline_sha256: match entry.kind {
340 Kind::State => None,
341 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
342 },
343 };
344 let Some(recorded) = recorded else {
345 return decide_added(entry, disk, conflicts);
346 };
347
348 if recorded.kind == Kind::Seeded && entry.kind == Kind::Rendered {
352 let untouched =
353 disk.is_some_and(|bytes| Some(Digest::of(bytes)) == recorded.baseline_sha256);
354 if !untouched {
355 conflicts.push(entry.destination.clone());
356 return Decision {
357 entry: Some(entry),
358 action: "conflict",
359 record: candidate_record(Digest::of(&entry.rendered)),
360 };
361 }
362 return Decision {
363 entry: Some(entry),
364 action: "updated",
365 record: candidate_record(Digest::of(&entry.rendered)),
366 };
367 }
368
369 match entry.kind {
370 Kind::Rendered => match disk {
371 Some(bytes) if Digest::of(bytes) == recorded.sha256 => Decision {
372 entry: Some(entry),
373 action: if bytes == entry.rendered {
374 "unchanged"
375 } else {
376 "updated"
377 },
378 record: candidate_record(Digest::of(&entry.rendered)),
379 },
380 Some(bytes) if bytes == entry.rendered => Decision {
381 entry: Some(entry),
382 action: "unchanged",
383 record: candidate_record(Digest::of(&entry.rendered)),
384 },
385 _ => {
388 conflicts.push(entry.destination.clone());
389 Decision {
390 entry: Some(entry),
391 action: "conflict",
392 record: candidate_record(Digest::of(&entry.rendered)),
393 }
394 }
395 },
396 Kind::Seeded => {
397 let baseline = if recorded.kind == Kind::Rendered {
404 Some(recorded.sha256.clone())
405 } else {
406 recorded.baseline_sha256.clone()
407 };
408 let (action, sha256) = disk.map_or_else(
409 || ("drift", recorded.sha256.clone()),
410 |bytes| {
411 let digest = Digest::of(bytes);
412 if Some(&digest) == baseline.as_ref() {
413 ("unchanged", digest)
414 } else {
415 ("drift", digest)
416 }
417 },
418 );
419 Decision {
420 entry: None,
421 action,
422 record: FileRecord {
423 destination: entry.destination.clone(),
424 kind: entry.kind,
425 sha256,
426 baseline_sha256: baseline,
427 },
428 }
429 }
430 Kind::State => Decision {
431 entry: None,
432 action: "state",
433 record: FileRecord {
434 destination: entry.destination.clone(),
435 kind: entry.kind,
436 sha256: recorded.sha256.clone(),
437 baseline_sha256: None,
438 },
439 },
440 }
441}
442
443fn decide_added<'a>(
448 entry: &'a Entry,
449 disk: Option<&[u8]>,
450 conflicts: &mut Vec<String>,
451) -> Decision<'a> {
452 let (action, sha256) = match disk {
453 None => ("added", Digest::of(&entry.rendered)),
454 Some(bytes) if bytes == entry.rendered => ("unchanged", Digest::of(bytes)),
455 Some(bytes) if entry.kind != Kind::Rendered => ("kept", Digest::of(bytes)),
456 Some(_) => {
457 conflicts.push(entry.destination.clone());
458 ("conflict", Digest::of(&entry.rendered))
459 }
460 };
461 Decision {
462 entry: Some(entry),
463 action,
464 record: FileRecord {
465 destination: entry.destination.clone(),
466 kind: entry.kind,
467 sha256,
468 baseline_sha256: match entry.kind {
469 Kind::State => None,
470 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
471 },
472 },
473 }
474}
475
476fn refuse_non_regular(target: &camino::Utf8Path, entries: &[Entry]) -> Result<(), RkError> {
479 for entry in entries {
480 if entry.kind != Kind::Rendered {
481 continue;
482 }
483 let path = target.join(&entry.destination);
484 if let Ok(meta) = std::fs::symlink_metadata(&path) {
485 if !meta.is_file() {
486 return Err(RkError::refusal(
487 Diagnostic::new(
488 Reason::StateDrift,
489 format!("{path} exists and is not a regular file; nothing was written"),
490 )
491 .expected("every rendered destination a regular file")
492 .target_state("unchanged"),
493 ));
494 }
495 }
496 }
497 Ok(())
498}
499
500fn collect_sentinels(entry: &Entry, found: &mut Vec<String>) {
502 let text = String::from_utf8_lossy(&entry.rendered);
503 for (idx, line) in text.lines().enumerate() {
504 if line.contains(embedded::SENTINEL) {
505 found.push(format!(
506 "{}:{}: {}",
507 entry.destination,
508 idx + 1,
509 line.trim()
510 ));
511 }
512 }
513}
514
515fn clone_record(record: &FileRecord) -> FileRecord {
517 FileRecord {
518 destination: record.destination.clone(),
519 kind: record.kind,
520 sha256: record.sha256.clone(),
521 baseline_sha256: record.baseline_sha256.clone(),
522 }
523}
524
525fn resolve_scopes(recorded: &mut Manifest, raw: Option<&str>) -> Result<(), RkError> {
528 if let Some(raw) = raw {
529 recorded.parameters.scopes = landing::parse_scopes(raw)?;
530 }
531 if recorded.parameters.scopes.is_empty() {
532 return Err(RkError::Usage(
533 "the record carries no scopes parameter; pass --scopes <list>, the Conventional Commit scopes this project accepts, and the upgrade records it".into(),
534 ));
535 }
536 Ok(())
537}
538
539#[cfg(test)]
540mod tests {
541 #![allow(clippy::expect_used)]
542
543 use super::{FileEntry, Report};
544
545 #[test]
547 fn the_upgrade_report_schema_snapshot_holds() {
548 let report = Report {
549 schema: "rk.upgrade/2",
550 mode: "preview",
551 target: "/tmp/t".into(),
552 tech: "rust".into(),
553 forge: "github".into(),
554 from_version: "0.1.0".into(),
555 to_version: "0.2.0",
556 workflow: "branches",
557 files: vec![FileEntry {
558 path: "release-plz.toml".into(),
559 kind: "seeded",
560 action: "drift",
561 }],
562 next: vec!["rk upgrade --target /tmp/t --apply writes".into()],
563 };
564 assert_eq!(
565 serde_json::to_string(&report).expect("a report serializes"),
566 r#"{"schema":"rk.upgrade/2","mode":"preview","target":"/tmp/t","tech":"rust","forge":"github","from_version":"0.1.0","to_version":"0.2.0","workflow":"branches","files":[{"path":"release-plz.toml","kind":"seeded","action":"drift"}],"next":["rk upgrade --target /tmp/t --apply writes"]}"#
567 );
568 }
569}