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