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