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 if args.apply {
217 vec![
218 "commit the upgraded files, the record included".to_owned(),
219 format!("rk status --target {} reports the result", args.target),
220 ]
221 } else if clean {
222 vec![format!(
223 "rk upgrade{workflow_flag} --target {} --apply writes",
224 args.target
225 )]
226 } else {
227 vec![format!(
228 "resolve each conflict above; rk upgrade{workflow_flag} --target {} --apply refuses until then",
229 args.target
230 )]
231 }
232}
233
234fn rewrite_record(
238 target: &camino::Utf8Path,
239 recorded: &Manifest,
240 decisions: &[Decision],
241) -> Result<(), RkError> {
242 manifest::write(
243 target,
244 &Manifest {
245 schema_version: manifest::SCHEMA_VERSION,
246 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
247 payload_sha256: crate::commands::payload::report().payload_sha256,
248 origin: recorded.origin.clone(),
249 tech: recorded.tech.clone(),
250 forge: recorded.forge.clone(),
251 landed_at: recorded.landed_at.clone(),
252 parameters: manifest::Parameters {
253 repo: recorded.parameters.repo.clone(),
254 scopes: recorded.parameters.scopes.clone(),
255 workflow: recorded.parameters.workflow,
256 style: recorded.parameters.style,
257 },
258 files: decisions
259 .iter()
260 .map(|decision| clone_record(&decision.record))
261 .collect(),
262 pins: registry::pins_for(&recorded.tech)
263 .into_iter()
264 .map(|pin| (pin.name, pin.version))
265 .collect(),
266 },
267 )
268}
269
270fn load_upgradable(target: &camino::Utf8Path) -> Result<Manifest, RkError> {
273 let Some(recorded) = manifest::load(target)? else {
274 return Err(RkError::refusal(
275 Diagnostic::new(
276 Reason::StateDrift,
277 format!(
278 "no {} at {target}: there is no baseline to upgrade against",
279 manifest::MANIFEST_PATH
280 ),
281 )
282 .expected("a recorded landing")
283 .action(
284 "rk init lands a first landing; rk adopt records one made before the record existed",
285 )
286 .target_state("unchanged"),
287 ));
288 };
289 if manifest::alignment(&recorded.rk_version, env!("CARGO_PKG_VERSION"))
290 == Alignment::TargetNewer
291 {
292 return Err(RkError::refusal(
293 Diagnostic::new(
294 Reason::StateDrift,
295 format!(
296 "this landing came from rk {}, newer than this binary's {}; downgrading a target is not an upgrade",
297 recorded.rk_version,
298 env!("CARGO_PKG_VERSION")
299 ),
300 )
301 .expected("a binary at or above the recorded rk_version")
302 .action(format!("install release-kit {} or newer", recorded.rk_version))
303 .target_state("unchanged"),
304 ));
305 }
306 Ok(recorded)
307}
308
309fn decide_all<'a>(
316 args: &UpgradeArgs,
317 recorded: &'a Manifest,
318 entries: &'a [Entry],
319) -> Result<(Vec<Decision<'a>>, Vec<String>), RkError> {
320 let mut conflicts: Vec<String> = Vec::new();
321 let mut decisions: Vec<Decision<'a>> = Vec::new();
322 if landing::hooks_file_defect(&args.target)?.is_some() {
323 conflicts.push(landing::HOOKS_DESTINATION.to_owned());
324 }
325 for entry in entries {
326 let disk = landing::read_recorded(&args.target, &entry.destination)?;
327 let mut decision = decide(
328 entry,
329 recorded.file(&entry.destination),
330 disk.as_deref(),
331 &mut conflicts,
332 );
333 if entry.destination == landing::HOOKS_DESTINATION
334 && conflicts.iter().any(|c| c == landing::HOOKS_DESTINATION)
335 {
336 decision.action = "conflict";
337 }
338 decisions.push(decision);
339 }
340 let mut seen = std::collections::HashSet::new();
341 conflicts.retain(|conflict| seen.insert(conflict.clone()));
342 Ok((decisions, conflicts))
343}
344
345fn decide<'a>(
346 entry: &'a Entry,
347 recorded: Option<&FileRecord>,
348 disk: Option<&[u8]>,
349 conflicts: &mut Vec<String>,
350) -> Decision<'a> {
351 let candidate_record = |sha256: Digest| FileRecord {
352 destination: entry.destination.clone(),
353 kind: entry.kind,
354 sha256,
355 baseline_sha256: match entry.kind {
356 Kind::State => None,
357 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
358 },
359 };
360 let Some(recorded) = recorded else {
361 return decide_added(entry, disk, conflicts);
362 };
363
364 if recorded.kind == Kind::Seeded && entry.kind == Kind::Rendered {
368 let untouched =
369 disk.is_some_and(|bytes| Some(Digest::of(bytes)) == recorded.baseline_sha256);
370 if !untouched {
371 conflicts.push(entry.destination.clone());
372 return Decision {
373 entry: Some(entry),
374 action: "conflict",
375 record: candidate_record(Digest::of(&entry.rendered)),
376 };
377 }
378 return Decision {
379 entry: Some(entry),
380 action: "updated",
381 record: candidate_record(Digest::of(&entry.rendered)),
382 };
383 }
384
385 match entry.kind {
386 Kind::Rendered => match disk {
387 Some(bytes) if Digest::of(bytes) == recorded.sha256 => Decision {
388 entry: Some(entry),
389 action: if bytes == entry.rendered {
390 "unchanged"
391 } else {
392 "updated"
393 },
394 record: candidate_record(Digest::of(&entry.rendered)),
395 },
396 Some(bytes) if bytes == entry.rendered => Decision {
397 entry: Some(entry),
398 action: "unchanged",
399 record: candidate_record(Digest::of(&entry.rendered)),
400 },
401 _ => {
404 conflicts.push(entry.destination.clone());
405 Decision {
406 entry: Some(entry),
407 action: "conflict",
408 record: candidate_record(Digest::of(&entry.rendered)),
409 }
410 }
411 },
412 Kind::Seeded => {
413 let baseline = if recorded.kind == Kind::Rendered {
420 Some(recorded.sha256.clone())
421 } else {
422 recorded.baseline_sha256.clone()
423 };
424 let (action, sha256) = disk.map_or_else(
425 || ("drift", recorded.sha256.clone()),
426 |bytes| {
427 let digest = Digest::of(bytes);
428 if Some(&digest) == baseline.as_ref() {
429 ("unchanged", digest)
430 } else {
431 ("drift", digest)
432 }
433 },
434 );
435 Decision {
436 entry: None,
437 action,
438 record: FileRecord {
439 destination: entry.destination.clone(),
440 kind: entry.kind,
441 sha256,
442 baseline_sha256: baseline,
443 },
444 }
445 }
446 Kind::State => Decision {
447 entry: None,
448 action: "state",
449 record: FileRecord {
450 destination: entry.destination.clone(),
451 kind: entry.kind,
452 sha256: recorded.sha256.clone(),
453 baseline_sha256: None,
454 },
455 },
456 }
457}
458
459fn decide_added<'a>(
464 entry: &'a Entry,
465 disk: Option<&[u8]>,
466 conflicts: &mut Vec<String>,
467) -> Decision<'a> {
468 let (action, sha256) = match disk {
469 None => ("added", Digest::of(&entry.rendered)),
470 Some(bytes) if bytes == entry.rendered => ("unchanged", Digest::of(bytes)),
471 Some(bytes) if entry.kind != Kind::Rendered => ("kept", Digest::of(bytes)),
472 Some(_) => {
473 conflicts.push(entry.destination.clone());
474 ("conflict", Digest::of(&entry.rendered))
475 }
476 };
477 Decision {
478 entry: Some(entry),
479 action,
480 record: FileRecord {
481 destination: entry.destination.clone(),
482 kind: entry.kind,
483 sha256,
484 baseline_sha256: match entry.kind {
485 Kind::State => None,
486 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
487 },
488 },
489 }
490}
491
492fn refuse_non_regular(target: &camino::Utf8Path, entries: &[Entry]) -> Result<(), RkError> {
495 for entry in entries {
496 if entry.kind != Kind::Rendered {
497 continue;
498 }
499 let path = target.join(&entry.destination);
500 if let Ok(meta) = std::fs::symlink_metadata(&path) {
501 if !meta.is_file() {
502 return Err(RkError::refusal(
503 Diagnostic::new(
504 Reason::StateDrift,
505 format!("{path} exists and is not a regular file; nothing was written"),
506 )
507 .expected("every rendered destination a regular file")
508 .target_state("unchanged"),
509 ));
510 }
511 }
512 }
513 Ok(())
514}
515
516fn collect_sentinels(entry: &Entry, found: &mut Vec<String>) {
518 let text = String::from_utf8_lossy(&entry.rendered);
519 for (idx, line) in text.lines().enumerate() {
520 if line.contains(embedded::SENTINEL) {
521 found.push(format!(
522 "{}:{}: {}",
523 entry.destination,
524 idx + 1,
525 line.trim()
526 ));
527 }
528 }
529}
530
531fn clone_record(record: &FileRecord) -> FileRecord {
533 FileRecord {
534 destination: record.destination.clone(),
535 kind: record.kind,
536 sha256: record.sha256.clone(),
537 baseline_sha256: record.baseline_sha256.clone(),
538 }
539}
540
541fn resolve_scopes(recorded: &mut Manifest, raw: Option<&str>) -> Result<(), RkError> {
544 if let Some(raw) = raw {
545 recorded.parameters.scopes = landing::parse_scopes(raw)?;
546 }
547 if recorded.parameters.scopes.is_empty() {
548 return Err(RkError::Usage(
549 "the record carries no scopes parameter; pass --scopes <list>, the Conventional Commit scopes this project accepts, and the upgrade records it".into(),
550 ));
551 }
552 Ok(())
553}
554
555#[cfg(test)]
556mod tests {
557 #![allow(clippy::expect_used)]
558
559 use super::{FileEntry, Report};
560
561 #[test]
563 fn the_upgrade_report_schema_snapshot_holds() {
564 let report = Report {
565 schema: "rk.upgrade/3",
566 mode: "preview",
567 target: "/tmp/t".into(),
568 tech: "rust".into(),
569 forge: "github".into(),
570 from_version: "0.1.0".into(),
571 to_version: "0.2.0",
572 workflow: "branches",
573 style: "trunk",
574 files: vec![FileEntry {
575 path: "release-plz.toml".into(),
576 kind: "seeded",
577 action: "drift",
578 }],
579 next: vec!["rk upgrade --target /tmp/t --apply writes".into()],
580 };
581 assert_eq!(
582 serde_json::to_string(&report).expect("a report serializes"),
583 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"]}"#
584 );
585 }
586}