1use serde::Serialize;
17
18use camino::Utf8Path;
19
20use crate::cli::self_depend::{
21 AddArgs, Caller, CleanArgs, SelfDependAction, SelfDependArgs, StatusArgs, SyncArgs,
22};
23use crate::diagnostic::{Diagnostic, Reason};
24use crate::error::RkError;
25use crate::output::Output;
26use crate::probes::{self, ProbeStatus};
27use crate::self_depend::discover::{self, Discovery};
28use crate::self_depend::fragments::{self, Fragment};
29use crate::self_depend::guard::{self, Acquired};
30use crate::self_depend::leftovers::{self, Action, Leftover};
31use crate::self_depend::manager::{self, Entry, Manager, PinRead};
32use crate::self_depend::matrix::{self, Mode, Pair, Support};
33use crate::self_depend::txn::{self, AbortFailure, Recovery, StepFailure};
34use crate::self_depend::venue::Venue;
35use crate::self_depend::{self, Observed, Presence, pin};
36
37#[derive(Debug, Serialize)]
39struct StatusReport<'a> {
40 schema: &'static str,
42 target: &'a str,
44 state: &'static str,
48 #[serde(skip_serializing_if = "Option::is_none")]
51 acquisition: Option<&'a crate::config::Acquisition>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 wired: Option<Manager>,
55 managers: &'a [Entry],
58 envrc: Presence,
61 envrc_sync: bool,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 stamp: Option<&'a str>,
66 pending: bool,
68 host: Host,
70 leftovers: &'a [Leftover],
72 next: &'a [String],
74}
75
76#[derive(Debug, Serialize)]
78struct AddReport<'a> {
79 schema: &'static str,
81 mode: &'static str,
83 target: &'a str,
85 tag: &'a str,
87 tag_source: &'static str,
89 manager: Manager,
91 venue: Venue,
93 support: Mode,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 reason: Option<&'static str>,
99 file: &'a str,
102 file_present: Presence,
104 envrc: Presence,
106 written: &'a [String],
109 #[serde(skip_serializing_if = "Option::is_none")]
111 refusal: Option<&'a str>,
112 fragments: &'a [Fragment],
114 next: &'a [String],
116}
117
118#[derive(Debug, Serialize)]
120struct CleanReport<'a> {
121 schema: &'static str,
123 mode: &'static str,
125 target: &'a str,
127 leftovers: &'a [Leftover],
130 removed: &'a [String],
132 rewritten: &'a [String],
134 manual: &'a [Manual],
137 next: &'a [String],
139}
140
141#[derive(Debug, Clone, Serialize)]
143struct Manual {
144 id: &'static str,
146 file: String,
148 #[serde(skip_serializing_if = "Option::is_none")]
150 line: Option<usize>,
151 #[serde(skip_serializing_if = "Option::is_none")]
153 text: Option<String>,
154 reason: &'static str,
156}
157
158#[derive(Debug, Serialize)]
160struct SyncReport<'a> {
161 schema: &'static str,
163 mode: &'static str,
165 caller: &'static str,
167 target: &'a str,
169 #[serde(skip_serializing_if = "Option::is_none")]
172 manager: Option<Manager>,
173 outcome: &'static str,
181 #[serde(skip_serializing_if = "Option::is_none")]
184 from: Option<&'a str>,
185 #[serde(skip_serializing_if = "Option::is_none")]
188 to: Option<&'a str>,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 detail: Option<&'a str>,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 steps: Option<&'a [Step]>,
195 #[serde(skip_serializing_if = "Option::is_none")]
197 restored: Option<&'a [String]>,
198 #[serde(skip_serializing_if = "Option::is_none")]
201 recovered: Option<&'a [String]>,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 stamp: Option<&'a str>,
205 next: &'a [String],
207}
208
209#[derive(Debug, Clone, Serialize)]
211#[allow(
212 clippy::struct_field_names,
213 reason = "the fields are the keys of a serialized machine shape, so they answer to the schema rather than to the struct name"
214)]
215struct Step {
216 step: &'static str,
218 status: &'static str,
220 #[serde(skip_serializing_if = "Option::is_none")]
222 detail: Option<String>,
223}
224
225#[derive(Debug, Default)]
227struct SyncRun {
228 outcome: &'static str,
229 manager: Option<Manager>,
230 from: Option<String>,
231 to: Option<String>,
232 detail: Option<String>,
233 steps: Option<Vec<Step>>,
234 restored: Option<Vec<String>>,
235 recovered: Option<Vec<String>>,
236 stamp: Option<String>,
237}
238
239#[derive(Debug, Serialize)]
241struct Host {
242 nix: &'static str,
244 direnv: &'static str,
246}
247
248pub fn run(args: &SelfDependArgs) -> Result<(), RkError> {
256 match &args.action {
257 SelfDependAction::Status(args) => status(args),
258 SelfDependAction::Add(args) => add(args),
259 SelfDependAction::Clean(args) => clean(args),
260 SelfDependAction::Sync(args) => sync(args),
261 }
262}
263
264fn status(args: &StatusArgs) -> Result<(), RkError> {
268 let out = Output::new(args.json);
269 let observed = self_depend::observe(&args.target)?;
270 let host = Host {
271 nix: probe_word(&probes::nix()),
272 direnv: probe_word(&probes::direnv()),
273 };
274 let state = observed.state();
275 let managers: Vec<Entry> = observed
276 .managers
277 .iter()
278 .filter(|entry| args.manager.is_none_or(|wanted| entry.manager == wanted))
279 .cloned()
280 .collect();
281 out.result_line(format!("state {state}"));
282 if let Some(wired) = observed.wired {
283 out.result_line(format!("wired through {}", wired.as_str()));
284 }
285 if let Some(answer) = &observed.acquisition {
286 out.result_line(format!(
287 "host install recorded, at rk {} or newer",
288 answer.minimum
289 ));
290 }
291 for entry in &managers {
292 out.result_line(manager_line(entry));
293 }
294 out.result_line(format!(
295 ".envrc {}, sync line {}",
296 word(observed.envrc),
297 if observed.envrc_sync { "yes" } else { "no" }
298 ));
299 if let Some(stamp) = &observed.stamp {
300 out.result_line(format!("last sync attempt {stamp}"));
301 }
302 if observed.pending {
303 out.result_line("an interrupted sync awaits recovery");
304 }
305 out.result_line(format!("host nix {}, direnv {}", host.nix, host.direnv));
306 for leftover in &observed.leftovers {
307 out.result_line(leftover_line(leftover));
308 }
309 let next = status_next(&observed);
310 out.next(&next);
311 out.emit(&StatusReport {
312 schema: "rk.self-depend-status/2",
313 target: observed.target.as_str(),
314 state,
315 acquisition: observed.acquisition.as_ref(),
316 wired: observed.wired,
317 managers: &managers,
318 envrc: observed.envrc,
319 envrc_sync: observed.envrc_sync,
320 stamp: observed.stamp.as_deref(),
321 pending: observed.pending,
322 host,
323 leftovers: &observed.leftovers,
324 next: &next,
325 })
326}
327
328#[derive(Debug, Serialize)]
330struct AddHostReport<'a> {
331 schema: &'static str,
333 mode: &'static str,
335 target: &'a str,
337 acquisition: &'a crate::config::Acquisition,
339 config: Presence,
341 written: bool,
343 next: &'a [String],
345}
346
347fn add_host(args: &AddArgs) -> Result<(), RkError> {
357 let out = Output::new(args.json);
358 let observed = self_depend::observe(&args.target)?;
359 let pinning: Vec<&str> = observed
363 .managers
364 .iter()
365 .filter(|entry| entry.read.names())
366 .map(|entry| entry.manager.as_str())
367 .collect();
368 if !pinning.is_empty() {
369 return Err(RkError::refusal(
370 Diagnostic::new(
371 Reason::DestructiveRefusal,
372 format!(
373 "the target already pins release-kit through {}; a host install beside a pin is a second mechanism",
374 pinning.join(" and ")
375 ),
376 )
377 .expected("no manager naming release-kit, or no host answer")
378 .target_state("nothing was written"),
379 ));
380 }
381 let answer = crate::config::Acquisition::host_at_this_release();
382 let config = Presence::of(&observed.target.join(crate::config::CONFIG_PATH));
383 let (content, changed) =
384 crate::config::with_acquisition(observed.target.as_std_path(), &answer)?;
385 let written = args.apply && changed;
386 if written {
387 crate::atomic::write(
388 observed
389 .target
390 .join(crate::config::CONFIG_PATH)
391 .as_std_path(),
392 content.as_bytes(),
393 )?;
394 out.result_line(format!("wrote {}", crate::config::CONFIG_PATH));
395 } else if !args.apply {
396 out.result_line("DRY RUN: --apply records the answer");
397 }
398 out.result_line(format!(
399 "acquisition: every host installs rk {} or newer",
400 answer.minimum
401 ));
402 let target = &observed.target;
403 let next = vec![format!(
404 "rk self-depend status --target {target} reports the state host once the answer is recorded"
405 )];
406 out.next(&next);
407 out.emit(&AddHostReport {
408 schema: "rk.self-depend-add-host/1",
409 mode: if args.apply { "apply" } else { "preview" },
410 target: observed.target.as_str(),
411 acquisition: &answer,
412 config,
413 written,
414 next: &next,
415 })
416}
417
418fn leftover_line(leftover: &Leftover) -> String {
420 use std::fmt::Write as _;
421 let action = match leftover.action {
422 Action::RemoveFile => "remove-file",
423 Action::ReplaceLine => "replace-line",
424 Action::Manual => "manual",
425 };
426 let mut line = format!("leftover {action} {}", leftover.file);
427 if let Some(number) = leftover.line {
428 let _ = write!(line, ":{number}");
429 }
430 if let Some(text) = &leftover.text {
431 let _ = write!(line, " {text}");
432 }
433 let _ = write!(line, " ({}: {})", leftover.id, leftover.reason);
434 line
435}
436
437#[allow(
440 clippy::too_many_lines,
441 reason = "one pass from the observation to the report, so every refusal is judged before any write"
442)]
443fn add(args: &AddArgs) -> Result<(), RkError> {
444 if args.host {
445 return add_host(args);
446 }
447 let out = Output::new(args.json);
448 let observed = self_depend::observe(&args.target)?;
449 let (tag, tag_source) = resolve_tag(args.tag.as_deref())?;
450 let pair = matrix::choose(&observed, args.manager, args.venue)?;
451 let entry = observed.entry(pair.manager);
452 let file = entry
453 .and_then(|entry| entry.file.clone())
454 .unwrap_or_else(|| pair.manager.default_file().to_owned());
455 let file_present = entry.map_or(Presence::Absent, |entry| entry.present);
456 let (support, reason) = match matrix::support(pair.manager, pair.venue) {
457 Support::Fragment if file_present.is_present() => (Mode::Fragment, None),
458 Support::Fragment => (Mode::Seed, None),
459 Support::Manual(reason) => (Mode::Manual, Some(reason)),
460 };
461 if let Some(wired) = observed.wired.filter(|wired| *wired != pair.manager) {
464 return Err(RkError::refusal(
465 Diagnostic::new(
466 Reason::DestructiveRefusal,
467 format!(
468 "the target already pins release-kit through {}; a second manager is a second bump mechanism",
469 wired.as_str()
470 ),
471 )
472 .expected(format!(
473 "one manager naming release-kit; rk self-depend add --manager {} serves that one",
474 wired.as_str()
475 ))
476 .target_state("nothing was written"),
477 ));
478 }
479 let fragments = fragments::fragments(pair, &tag, &observed);
480 let mode = if args.apply { "apply" } else { "preview" };
481 let flake_pair = pair.manager == Manager::Flake && pair.venue == Venue::Flake;
482 let mut written = Vec::new();
483 let mut owned = Vec::new();
484 if args.apply && support != Mode::Manual {
485 let mut seeds = vec![(file.clone(), file_present, fragments::seed(pair, &tag))];
486 if flake_pair {
487 seeds.push((
488 ".envrc".to_owned(),
489 observed.envrc,
490 Some(fragments::seed_envrc()),
491 ));
492 }
493 for (name, present, seed) in seeds {
494 let Some(seed) = seed else {
495 continue;
496 };
497 if present.is_present() {
498 owned.push(name);
499 } else {
500 crate::atomic::write(observed.target.join(&name).as_std_path(), seed.as_bytes())?;
501 written.push(name);
502 }
503 }
504 }
505 let refusal = (!owned.is_empty()).then(|| {
506 format!(
507 "the target already carries {}; rk self-depend add never edits a file the target owns",
508 owned.join(" and ")
509 )
510 });
511 if args.apply {
512 for name in &written {
513 out.result_line(format!("wrote {name}"));
514 }
515 } else {
516 out.result_line("DRY RUN: rk self-depend add prints the fragments; --apply seeds only the files the target lacks");
517 }
518 out.result_line(format!("tag {tag} (from the {tag_source})"));
519 out.result_line(match (support, reason) {
520 (Mode::Manual, Some(reason)) => format!(
521 "pair {} via {}: manual ({reason}); nothing is written",
522 pair.manager.as_str(),
523 pair.venue.as_str()
524 ),
525 (mode, _) => format!(
526 "pair {} via {}: {}",
527 pair.manager.as_str(),
528 pair.venue.as_str(),
529 match mode {
530 Mode::Fragment => "fragments into the present file",
531 Mode::Seed => "seeded on --apply",
532 Mode::Manual => "manual",
533 }
534 ),
535 });
536 if support != Mode::Manual {
537 out.result_line(match file_present {
538 Presence::Present => {
539 format!("{file} present: the target owns it, so its fragments are applied by hand")
540 }
541 Presence::Absent => format!("{file} absent: --apply seeds it"),
542 });
543 }
544 out.result_line(match (observed.envrc, flake_pair) {
545 (Presence::Present, _) => {
546 ".envrc present: the target owns it, so the sync line is applied by hand".to_owned()
547 }
548 (Presence::Absent, true) => ".envrc absent: --apply seeds it".to_owned(),
549 (Presence::Absent, false) => {
550 ".envrc absent: the sync line is applied by hand once the shell loads rk".to_owned()
551 }
552 });
553 for fragment in &fragments {
554 out.result_line(format!(
555 "--- {} into {} ({} at {}){}",
556 fragment.id,
557 fragment.file,
558 fragment.placement,
559 fragment.anchor.path,
560 match fragment.present {
561 Some(true) => ": already present",
562 Some(false) => ": missing",
563 None => ": not judged",
564 }
565 ));
566 out.result_line(&fragment.text);
567 }
568 let next = add_next(&observed, pair, &file, args.apply, &written);
569 out.next(&next);
570 out.emit(&AddReport {
571 schema: "rk.self-depend-add/2",
572 mode,
573 target: observed.target.as_str(),
574 tag: &tag,
575 tag_source,
576 manager: pair.manager,
577 venue: pair.venue,
578 support,
579 reason,
580 file: &file,
581 file_present,
582 envrc: observed.envrc,
583 written: &written,
584 refusal: refusal.as_deref(),
585 fragments: &fragments,
586 next: &next,
587 })?;
588 if args.apply && support == Mode::Manual {
589 return Err(RkError::Usage(format!(
590 "{} via {} is manual ({}); nothing can be written, and the report names the edit",
591 pair.manager.as_str(),
592 pair.venue.as_str(),
593 reason.unwrap_or_default()
594 )));
595 }
596 let Some(message) = refusal else {
597 return Ok(());
598 };
599 let state = if written.is_empty() {
600 "nothing was written".to_owned()
601 } else {
602 format!(
603 "wrote {}; the owned file is byte-identical",
604 written.join(", ")
605 )
606 };
607 Err(RkError::refusal(
608 Diagnostic::new(Reason::DestructiveRefusal, message)
609 .expected("a target with no file for the pair, or the fragments applied by hand")
610 .target_state(state),
611 ))
612}
613
614fn sync(args: &SyncArgs) -> Result<(), RkError> {
622 let out = Output::new(args.json);
623 let mut observed = self_depend::observe(&args.target)?;
624 let key = observed.key();
625 let mut run = SyncRun {
626 stamp: observed.stamp.clone(),
627 ..SyncRun::default()
628 };
629 let mut held = None;
630 if guard::switched_off() {
631 run.outcome = "skipped-disabled";
632 run.detail = Some(format!("{}=0 is set", guard::SWITCH_VAR));
633 } else if guard::in_ci() {
634 run.outcome = "skipped-ci";
635 run.detail = Some("a CI variable is set; the sync never runs on a runner".to_owned());
636 } else {
637 gate_and_decide(args, &mut observed, &key, &mut run, &mut held, out)?;
638 }
639 render_sync(out, args, &observed, &run)?;
640 drop(held);
641 exit_for(args.caller, &run)
642}
643
644fn gate_and_decide(
649 args: &SyncArgs,
650 observed: &mut Observed,
651 key: &str,
652 run: &mut SyncRun,
653 held: &mut Option<guard::Lock>,
654 out: Output,
655) -> Result<(), RkError> {
656 let envrc = args.caller == Caller::Envrc;
657 let today = guard::today();
658 if args.apply && envrc && !observed.pending && observed.stamp.as_deref() == Some(today.as_str())
661 {
662 run.outcome = "skipped-stamped";
663 run.detail = Some(format!("today's attempt already happened ({today})"));
664 return Ok(());
665 }
666 if args.apply && envrc {
667 if guard::write_stamp(key).is_ok() {
670 run.stamp = Some(today);
671 }
672 }
673 if args.apply {
674 match guard::acquire(key) {
675 Acquired::Held(lock) => *held = Some(lock),
676 Acquired::Contended => {
677 run.outcome = "skipped-locked";
678 run.detail = Some("another run holds this checkout".to_owned());
679 return Ok(());
680 }
681 Acquired::Unavailable(source) => {
682 run.outcome = "lock-unavailable";
683 run.detail = Some(format!("the lock cannot be taken: {source}"));
684 out.warn(format!(
685 "rk self-depend sync: the lock cannot be taken: {source}"
686 ));
687 return Ok(());
688 }
689 }
690 }
691 if args.apply {
692 let recovery = match txn::recover_pending(&observed.target, key) {
695 Ok(recovery) => recovery,
696 Err(source) => {
697 run.outcome = "recovery-failed";
698 run.detail = Some(format!(
699 "the transaction marker under the state root cannot be read: {source}; remove or repair it by hand"
700 ));
701 return Ok(());
702 }
703 };
704 match recovery {
705 Some(Recovery::Restored(restored)) => {
706 run.recovered = Some(restored);
707 *observed = self_depend::observe(&args.target)?;
708 }
709 Some(Recovery::Failed(failure)) => {
710 run.outcome = "recovery-failed";
711 run.detail = Some(format!(
712 "{failure}; the backups stay under the state root for the next attempt"
713 ));
714 return Ok(());
715 }
716 Some(Recovery::Unfinished(failure)) => {
717 run.outcome = "cleanup-failed";
718 run.detail = Some(format!("both files are back, but {failure}"));
719 return Ok(());
720 }
721 Some(Recovery::Finished) => {
722 *observed = self_depend::observe(&args.target)?;
723 }
724 None => {}
725 }
726 }
727 decide(args, observed, key, run)
728}
729
730fn sync_manager(args: &SyncArgs, observed: &Observed, run: &mut SyncRun) -> Option<Manager> {
734 if let Some(manager) = args.manager {
735 return Some(manager);
736 }
737 if let Some(wired) = observed.wired {
738 return Some(wired);
739 }
740 if observed
741 .managers
742 .iter()
743 .all(|entry| !entry.present.is_present())
744 {
745 run.outcome = "no-manager";
746 return None;
747 }
748 let named: Vec<&str> = observed
749 .managers
750 .iter()
751 .filter(|entry| entry.read.names())
752 .map(|entry| entry.manager.as_str())
753 .collect();
754 if named.len() > 1 {
755 run.outcome = "ambiguous-pin";
756 run.detail = Some(format!(
757 "{} manager files name release-kit: {}",
758 named.len(),
759 named.join(" and ")
760 ));
761 } else {
762 run.outcome = "not-wired";
763 }
764 None
765}
766
767fn sync_files(manager: Manager, file: &str) -> Vec<&str> {
770 if manager == Manager::Flake {
771 vec!["flake.nix", "flake.lock"]
772 } else {
773 vec![file]
774 }
775}
776
777fn decide(
780 args: &SyncArgs,
781 observed: &Observed,
782 key: &str,
783 run: &mut SyncRun,
784) -> Result<(), RkError> {
785 if observed.pending {
786 run.outcome = "pending-recovery";
787 run.detail =
788 Some("an interrupted run left its marker; --apply recovers it first".to_owned());
789 return Ok(());
790 }
791 let Some(manager) = sync_manager(args, observed, run) else {
792 return Ok(());
793 };
794 run.manager = Some(manager);
795 let Some(entry) = observed
796 .entry(manager)
797 .filter(|entry| entry.present.is_present())
798 else {
799 run.outcome = "no-manager";
800 run.detail = Some(format!("the target carries no {} file", manager.as_str()));
801 return Ok(());
802 };
803 let file = entry.file.clone().unwrap_or_default();
804 let (line, from) = match &entry.read {
805 PinRead::Many { count } => {
806 run.outcome = "ambiguous-pin";
807 run.detail = Some(format!("{count} lines name release-kit in {file}"));
808 return Ok(());
809 }
810 PinRead::Absent => {
811 run.outcome = "not-wired";
812 return Ok(());
813 }
814 PinRead::Unpinned { line } => {
815 run.outcome = "unpinned";
816 run.detail = Some(format!(
817 "{file} line {line} names release-kit with no version"
818 ));
819 return Ok(());
820 }
821 PinRead::One { line, version } => (*line, version.clone()),
822 };
823 run.from = Some(from.clone());
824 if guard::files_dirty(&observed.target, &sync_files(manager, &file)) {
825 run.outcome = "refused-dirty";
826 run.detail = Some(format!(
827 "{} carries uncommitted edits; commit or stash them first",
828 sync_files(manager, &file).join(" or ")
829 ));
830 return Ok(());
831 }
832 let to = match args.tag.as_deref() {
833 Some(raw) => self_depend::normalize_tag(raw).ok_or_else(|| {
834 RkError::Usage(format!(
835 "--tag {raw} is not a release tag; pass v0.2.16, 0.2.16, or the release URL"
836 ))
837 })?,
838 None => match discover::latest_tag() {
839 Discovery::Tag(tag) => tag,
840 Discovery::Unreachable(detail) => {
841 run.outcome = "unreachable";
842 run.detail = Some(detail);
843 return Ok(());
844 }
845 Discovery::Unparsable(answer) => {
846 run.outcome = "unparsable";
847 run.detail = Some(format!("the release page answered no tag: {answer}"));
848 return Ok(());
849 }
850 },
851 };
852 let to = manager.recorded(&to);
853 run.to = Some(to.clone());
854 match discover::version_order(&from, &to) {
855 std::cmp::Ordering::Equal => {
856 run.outcome = "current";
857 return Ok(());
858 }
859 std::cmp::Ordering::Greater if args.tag.is_none() => {
862 run.outcome = "ahead";
863 run.detail = Some(
864 "the pin is ahead of the latest release and is never moved backward".to_owned(),
865 );
866 return Ok(());
867 }
868 std::cmp::Ordering::Greater | std::cmp::Ordering::Less => {}
869 }
870 if !args.apply {
871 run.outcome = "would-bump";
872 return Ok(());
873 }
874 if manager == Manager::Flake {
875 let pin::Scan::One(pin) = &observed.scan else {
876 run.outcome = "not-wired";
877 return Ok(());
878 };
879 return apply_bump(observed, key, pin, &to, run);
880 }
881 move_one_fact(observed, entry, &file, line, &from, &to, run);
882 Ok(())
883}
884
885fn move_one_fact(
888 observed: &Observed,
889 entry: &Entry,
890 file: &str,
891 line: usize,
892 from: &str,
893 to: &str,
894 run: &mut SyncRun,
895) {
896 let text = entry.text.as_deref().unwrap_or_default();
897 let rewritten = manager::rewrite_line(text, line, from, to);
898 let step = match crate::atomic::write(
899 observed.target.join(file).as_std_path(),
900 rewritten.as_bytes(),
901 ) {
902 Ok(()) => {
903 run.outcome = "bumped";
904 Step {
905 step: "rewrite-pin",
906 status: "ok",
907 detail: None,
908 }
909 }
910 Err(source) => {
911 run.outcome = "update-failed";
912 run.detail = Some(format!("rewrite-pin failed: {source}"));
913 run.restored = Some(Vec::new());
914 Step {
915 step: "rewrite-pin",
916 status: "failed",
917 detail: Some(source.to_string()),
918 }
919 }
920 };
921 run.steps = Some(vec![step]);
922}
923
924fn apply_bump(
927 observed: &Observed,
928 key: &str,
929 pin: &pin::Pin,
930 to: &str,
931 run: &mut SyncRun,
932) -> Result<(), RkError> {
933 let flake_text = observed.flake_text.as_deref().unwrap_or_default();
934 let rewritten = pin::rewrite(flake_text, pin, to);
935 let transaction = txn::open(&observed.target, key)?;
936 let mut steps = Vec::new();
937 let failure = transact(&observed.target, &rewritten, &mut steps);
938 match failure {
939 None => {
940 run.outcome = "bumped";
941 if let Err(failure) = transaction.commit() {
942 run.outcome = "cleanup-failed";
943 run.detail = Some(format!("the pin moved, but {failure}"));
944 }
945 }
946 Some(failed) => {
947 run.outcome = match failed.step {
948 "rewrite-pin" | "flake-update" => "update-failed",
949 _ => "build-failed",
950 };
951 match transaction.abort() {
952 Ok(restored) => {
953 run.restored = Some(restored);
954 run.detail = Some(format!("{} failed: {}", failed.step, failed.detail));
955 }
956 Err(AbortFailure::Restore(failure)) => {
957 run.outcome = "restore-failed";
958 run.detail = Some(format!(
959 "{} failed: {}; then {failure}; the backups stay under the state root for the next run",
960 failed.step, failed.detail
961 ));
962 }
963 Err(AbortFailure::Finish(failure)) => {
964 run.outcome = "cleanup-failed";
965 run.detail = Some(format!(
966 "{} failed: {}; both files are back, but {failure}",
967 failed.step, failed.detail
968 ));
969 }
970 }
971 }
972 }
973 run.steps = Some(steps);
974 Ok(())
975}
976
977type Attempt<'a> = Box<dyn Fn() -> Result<(), StepFailure> + 'a>;
979
980fn transact(target: &Utf8Path, rewritten: &str, steps: &mut Vec<Step>) -> Option<StepFailure> {
983 let attempts: [(&'static str, Attempt<'_>); 3] = [
984 (
985 "rewrite-pin",
986 Box::new(|| {
987 crate::atomic::write(target.join("flake.nix").as_std_path(), rewritten.as_bytes())
988 .map_err(|source| StepFailure {
989 step: "rewrite-pin",
990 detail: source.to_string(),
991 })
992 }),
993 ),
994 ("flake-update", Box::new(|| txn::flake_update(target))),
995 (
996 "build",
997 Box::new(|| {
998 let system = txn::current_system(target)?;
999 txn::build_devshell(target, &system)
1000 }),
1001 ),
1002 ];
1003 for (name, attempt) in attempts {
1004 match attempt() {
1005 Ok(()) => steps.push(Step {
1006 step: name,
1007 status: "ok",
1008 detail: None,
1009 }),
1010 Err(failed) => {
1011 steps.push(Step {
1012 step: failed.step,
1013 status: "failed",
1014 detail: Some(failed.detail.clone()),
1015 });
1016 return Some(failed);
1017 }
1018 }
1019 }
1020 None
1021}
1022
1023const fn is_quiet(outcome: &str) -> bool {
1026 matches!(
1027 outcome.as_bytes(),
1028 b"current"
1029 | b"ahead"
1030 | b"no-manager"
1031 | b"not-wired"
1032 | b"unpinned"
1033 | b"skipped-ci"
1034 | b"skipped-disabled"
1035 | b"skipped-stamped"
1036 | b"skipped-locked"
1037 )
1038}
1039
1040fn render_sync(
1042 out: Output,
1043 args: &SyncArgs,
1044 observed: &Observed,
1045 run: &SyncRun,
1046) -> Result<(), RkError> {
1047 use std::fmt::Write as _;
1048 let quiet = args.caller == Caller::Envrc && is_quiet(run.outcome);
1049 if let Some(recovered) = &run.recovered {
1050 out.result_line(format!(
1051 "recovered an interrupted sync: restored {}",
1052 recovered.join(", ")
1053 ));
1054 }
1055 if !quiet {
1056 out.result_line(sync_line(run));
1057 if let Some(steps) = &run.steps {
1058 for step in steps {
1059 let mut line = format!(" {} {}", step.status, step.step);
1060 if let Some(detail) = &step.detail {
1061 let _ = write!(line, ": {detail}");
1062 }
1063 out.result_line(line);
1064 }
1065 }
1066 if let Some(restored) = &run.restored {
1067 out.result_line(format!("restored {}", restored.join(", ")));
1068 }
1069 }
1070 let next = if quiet {
1071 Vec::new()
1072 } else {
1073 sync_next(observed, run)
1074 };
1075 out.next(&next);
1076 out.emit(&SyncReport {
1077 schema: "rk.self-depend-sync/2",
1078 mode: if args.apply { "apply" } else { "preview" },
1079 caller: match args.caller {
1080 Caller::Envrc => "envrc",
1081 Caller::Operator => "operator",
1082 },
1083 target: observed.target.as_str(),
1084 manager: run.manager,
1085 outcome: run.outcome,
1086 from: run.from.as_deref(),
1087 to: run.to.as_deref(),
1088 detail: run.detail.as_deref(),
1089 steps: run.steps.as_deref(),
1090 restored: run.restored.as_deref(),
1091 recovered: run.recovered.as_deref(),
1092 stamp: run.stamp.as_deref(),
1093 next: &next,
1094 })
1095}
1096
1097fn sync_line(run: &SyncRun) -> String {
1099 use std::fmt::Write as _;
1100 let movement = match (&run.from, &run.to) {
1101 (Some(from), Some(to)) if from != to => format!(" {from} -> {to}"),
1102 (Some(from), _) => format!(" {from}"),
1103 _ => String::new(),
1104 };
1105 let mut line = format!("{}{movement}", run.outcome);
1106 if let Some(detail) = &run.detail {
1107 let _ = write!(line, ": {detail}");
1108 }
1109 line
1110}
1111
1112fn sync_next(observed: &Observed, run: &SyncRun) -> Vec<String> {
1114 let target = &observed.target;
1115 let files = run
1116 .manager
1117 .map(|manager| {
1118 let file = observed
1119 .entry(manager)
1120 .and_then(|entry| entry.file.clone())
1121 .unwrap_or_else(|| manager.default_file().to_owned());
1122 sync_files(manager, &file).join(" ")
1123 })
1124 .unwrap_or_default();
1125 let mut next = match run.outcome {
1126 "bumped" => vec![
1127 format!("git -C {target} diff -- {files} shows the change to review and commit"),
1128 "the next shell reload takes the new rk; nothing here commits".to_owned(),
1129 ],
1130 "would-bump" => vec![format!(
1131 "rk self-depend sync --caller operator --apply --target {target} moves the pin, locks it, and proves the build"
1132 )],
1133 "current" => vec![format!(
1134 "rk self-depend status --target {target} reports the wiring"
1135 )],
1136 "ahead" => vec![
1137 "a pin past the latest release is a deliberate state; nothing moves it back".to_owned(),
1138 ],
1139 "pending-recovery" => vec![format!(
1140 "rk self-depend sync --caller operator --apply --target {target} restores both files first"
1141 )],
1142 "no-manager" | "not-wired" | "unpinned" => vec![format!(
1143 "rk self-depend add --target {target} prints the fragments; --apply seeds the files a target lacks"
1144 )],
1145 "ambiguous-pin" => vec![format!(
1146 "leave exactly one line naming release-kit, in one manager file under {target}, then rerun"
1147 )],
1148 "refused-dirty" => vec![format!(
1149 "git -C {target} status -- {files} names the edits; commit or stash them, then rerun"
1150 )],
1151 "skipped-disabled" => vec![format!(
1152 "unset {} to let the sync run again",
1153 guard::SWITCH_VAR
1154 )],
1155 "skipped-stamped" => vec![format!(
1156 "rk self-depend sync --caller operator --apply --target {target} runs the attempt now, whatever the stamp says"
1157 )],
1158 "skipped-locked" => vec!["let the other run finish; nothing here is owed".to_owned()],
1159 "lock-unavailable" => vec!["make the state root writable: rk doctor reports it".to_owned()],
1160 "unreachable" | "unparsable" => vec![
1161 "retry when the release page answers; --tag <TAG> makes no request at all".to_owned(),
1162 ],
1163 "cleanup-failed" => vec![
1164 "remove the named marker by hand before the next entry; an active marker beside its backups would overwrite later edits".to_owned(),
1165 ],
1166 "recovery-failed" | "restore-failed" => vec![
1167 "a file is not back: free the path the detail names, then rerun; the backups wait under the state root".to_owned(),
1168 ],
1169 "update-failed" | "build-failed" => vec![
1170 "both files are as they were; the failing step's last line is above".to_owned(),
1171 format!(
1172 "rk self-depend sync --caller operator --apply --target {target} retries after the fix"
1173 ),
1174 ],
1175 _ => Vec::new(),
1176 };
1177 if !observed.leftovers.is_empty() {
1178 next.push(format!(
1179 "rk self-depend clean --target {target}: the target still carries a predecessor bump mechanism"
1180 ));
1181 }
1182 next
1183}
1184
1185fn exit_for(caller: Caller, run: &SyncRun) -> Result<(), RkError> {
1188 if caller == Caller::Envrc {
1189 return Ok(());
1190 }
1191 let detail = run.detail.clone().unwrap_or_default();
1192 match run.outcome {
1193 "ambiguous-pin" | "refused-dirty" => Err(RkError::refusal(
1194 Diagnostic::new(Reason::StateDrift, detail)
1195 .expected("exactly one committed pin line in one manager file")
1196 .target_state("nothing was written"),
1197 )),
1198 "unreachable" | "unparsable" => {
1199 let mut diagnostic = Diagnostic::new(Reason::ForgeTemporary, detail)
1200 .action("rerun when the release page answers, or pass --tag")
1201 .target_state("nothing was written");
1202 diagnostic.retry = Some(true);
1203 Err(RkError::subprocess(diagnostic))
1204 }
1205 "update-failed" | "build-failed" => {
1206 let step = run
1207 .steps
1208 .as_ref()
1209 .and_then(|steps| steps.iter().find(|s| s.status == "failed"))
1210 .map_or("transaction", |s| s.step);
1211 Err(RkError::subprocess(
1212 Diagnostic::new(Reason::SubprocessFailed, detail)
1213 .step(step)
1214 .target_state(format!(
1215 "restored {}",
1216 run.restored.as_deref().unwrap_or_default().join(" and ")
1217 )),
1218 ))
1219 }
1220 "lock-unavailable" | "restore-failed" | "recovery-failed" | "cleanup-failed" => {
1221 Err(RkError::Io(std::io::Error::other(detail)))
1222 }
1223 _ => Ok(()),
1224 }
1225}
1226
1227fn clean(args: &CleanArgs) -> Result<(), RkError> {
1229 let out = Output::new(args.json);
1230 let observed = self_depend::observe(&args.target)?;
1231 let target = &observed.target;
1232 let mut leftovers = observed.leftovers.clone();
1233 for path in &args.also {
1234 leftovers.push(also_leftover(target, path)?);
1235 }
1236 let mode = if args.apply { "apply" } else { "preview" };
1237 let mut removed = Vec::new();
1238 let mut rewritten = Vec::new();
1239 let mut manual = Vec::new();
1240 if args.apply {
1241 for leftover in &leftovers {
1242 match leftover.action {
1243 Action::RemoveFile => {
1244 std::fs::remove_file(target.join(&leftover.file))?;
1245 removed.push(leftover.file.clone());
1246 }
1247 Action::ReplaceLine => {}
1248 Action::Manual => manual.push(Manual {
1249 id: leftover.id,
1250 file: leftover.file.clone(),
1251 line: leftover.line,
1252 text: leftover.text.clone(),
1253 reason: leftover.reason,
1254 }),
1255 }
1256 }
1257 if leftovers.iter().any(|l| l.action == Action::ReplaceLine) {
1258 let envrc = target.join(".envrc");
1259 let text = std::fs::read_to_string(&envrc)?;
1260 if let Some(swapped) = leftovers::swap_envrc(&text, &fragments::envrc_line()) {
1261 crate::atomic::write(envrc.as_std_path(), swapped.as_bytes())?;
1262 rewritten.push(".envrc".to_owned());
1263 }
1264 }
1265 }
1266 if args.apply {
1267 for file in &removed {
1268 out.result_line(format!("removed {file}"));
1269 }
1270 for file in &rewritten {
1271 out.result_line(format!(
1272 "rewrote {file}: the sync line replaces the invocation"
1273 ));
1274 }
1275 for entry in &manual {
1276 out.result_line(format!(
1277 "manual {}{} {} ({}: {})",
1278 entry.file,
1279 entry.line.map(|n| format!(":{n}")).unwrap_or_default(),
1280 entry.text.as_deref().unwrap_or_default(),
1281 entry.id,
1282 entry.reason
1283 ));
1284 }
1285 if removed.is_empty() && rewritten.is_empty() && manual.is_empty() {
1286 out.result_line("nothing to remove: the target carries no predecessor mechanism");
1287 }
1288 } else {
1289 out.result_line(
1290 "DRY RUN: rk self-depend clean removes and rewrites these on --apply, and names the rest",
1291 );
1292 for leftover in &leftovers {
1293 out.result_line(leftover_line(leftover));
1294 }
1295 if leftovers.is_empty() {
1296 out.result_line("nothing to remove: the target carries no predecessor mechanism");
1297 }
1298 }
1299 let next = clean_next(&observed, args.apply, &leftovers, &manual);
1300 out.next(&next);
1301 out.emit(&CleanReport {
1302 schema: "rk.self-depend-clean/1",
1303 mode,
1304 target: target.as_str(),
1305 leftovers: &leftovers,
1306 removed: &removed,
1307 rewritten: &rewritten,
1308 manual: &manual,
1309 next: &next,
1310 })
1311}
1312
1313fn also_leftover(target: &Utf8Path, path: &Utf8Path) -> Result<Leftover, RkError> {
1316 let absolute = if path.is_absolute() {
1317 path.to_owned()
1318 } else {
1319 target.join(path)
1320 };
1321 let refuse = |why: &str| {
1322 RkError::refusal(
1323 Diagnostic::new(
1324 Reason::DestructiveRefusal,
1325 format!("--also {path} is {why}; nothing was removed"),
1326 )
1327 .expected("a regular file inside the target, named for removal"),
1328 )
1329 };
1330 let Ok(meta) = std::fs::symlink_metadata(&absolute) else {
1331 return Err(refuse("not a file that exists"));
1332 };
1333 if meta.file_type().is_symlink() {
1334 return Err(refuse("a symlink, which a file removal never follows"));
1335 }
1336 if meta.is_dir() {
1337 return Err(refuse("a directory, and the cleanup removes files alone"));
1338 }
1339 let canonical = absolute.canonicalize_utf8()?;
1340 let Ok(relative) = canonical.strip_prefix(target) else {
1341 return Err(refuse("outside the target"));
1342 };
1343 Ok(Leftover {
1344 id: "also",
1345 file: relative.to_string(),
1346 line: None,
1347 text: None,
1348 action: Action::RemoveFile,
1349 reason: "named by the operator as a predecessor file the catalog does not know",
1350 })
1351}
1352
1353fn clean_next(
1355 observed: &Observed,
1356 apply: bool,
1357 leftovers: &[Leftover],
1358 manual: &[Manual],
1359) -> Vec<String> {
1360 let target = &observed.target;
1361 let mut next = Vec::new();
1362 if !apply && !leftovers.is_empty() {
1363 next.push(format!(
1364 "rk self-depend clean --target {target} --apply removes the files and rewrites .envrc"
1365 ));
1366 }
1367 let by_hand: Vec<String> = if apply {
1368 manual
1369 .iter()
1370 .map(|entry| entry.file.clone())
1371 .collect::<std::collections::BTreeSet<_>>()
1372 .into_iter()
1373 .collect()
1374 } else {
1375 leftovers
1376 .iter()
1377 .filter(|l| l.action == Action::Manual)
1378 .map(|l| l.file.clone())
1379 .collect::<std::collections::BTreeSet<_>>()
1380 .into_iter()
1381 .collect()
1382 };
1383 if !by_hand.is_empty() {
1384 next.push(format!(
1385 "edit by hand what a line scan must not touch: {}",
1386 by_hand.join(", ")
1387 ));
1388 }
1389 next.push(format!(
1390 "rk self-depend status --target {target} reports ready once the leftovers list is empty"
1391 ));
1392 if matches!(observed.scan, pin::Scan::None) {
1393 next.push(format!(
1394 "rk self-depend add --target {target} wires the native mechanism once the predecessor is gone"
1395 ));
1396 }
1397 next
1398}
1399
1400fn resolve_tag(argument: Option<&str>) -> Result<(String, &'static str), RkError> {
1403 let Some(raw) = argument else {
1404 return Ok((format!("v{}", env!("CARGO_PKG_VERSION")), "binary"));
1405 };
1406 self_depend::normalize_tag(raw)
1407 .map(|tag| (tag, "argument"))
1408 .ok_or_else(|| {
1409 RkError::Usage(format!(
1410 "--tag {raw} is not a release tag; pass v0.2.16, 0.2.16, or the release URL"
1411 ))
1412 })
1413}
1414
1415fn add_next(
1417 observed: &Observed,
1418 pair: Pair,
1419 file: &str,
1420 apply: bool,
1421 written: &[String],
1422) -> Vec<String> {
1423 let target = &observed.target;
1424 let flake_pair = pair.manager == Manager::Flake;
1425 let mut next = Vec::new();
1426 if !observed.leftovers.is_empty() {
1427 next.push(format!(
1428 "rk self-depend clean --target {target} first: the target carries a predecessor bump mechanism, and one project runs one"
1429 ));
1430 }
1431 if let Support::Manual(reason) = matrix::support(pair.manager, pair.venue) {
1432 next.push(format!(
1433 "{} via {} is manual ({reason}): apply the edit by hand, or pick a pair the matrix renders",
1434 pair.manager.as_str(),
1435 pair.venue.as_str()
1436 ));
1437 return next;
1438 }
1439 if !apply {
1440 next.push(format!(
1441 "rk self-depend add --target {target} --apply seeds the files the target lacks; an owned file takes its fragments by hand, in the order above"
1442 ));
1443 if flake_pair {
1444 next.push(
1445 "run rk init --nix before the apply where the landed packaging capability is also wanted: a seeded flake.nix withholds it later".to_owned(),
1446 );
1447 }
1448 }
1449 if !written.is_empty() {
1450 next.push(format!(
1451 "commit {} first: the sync refuses uncommitted edits to the file it moves",
1452 written.join(" and ")
1453 ));
1454 }
1455 if flake_pair {
1456 next.push(format!(
1457 "rk self-depend sync --caller operator --apply --target {target} writes the lock and proves the build; commit flake.lock, then direnv allow"
1458 ));
1459 } else {
1460 next.push(format!(
1461 "rk self-depend sync --caller operator --apply --target {target} moves the pin in {file}; the manager's own install takes it from there"
1462 ));
1463 }
1464 next
1465}
1466
1467fn manager_line(entry: &Entry) -> String {
1469 use std::fmt::Write as _;
1470 let mut line = format!("manager {} {}", entry.manager.as_str(), word(entry.present));
1471 let Some(file) = &entry.file else {
1472 return line;
1473 };
1474 let _ = write!(line, " ({file})");
1475 match (entry.pin, entry.pin_lines) {
1476 ("absent", _) => line.push_str(", not named"),
1477 ("unpinned", _) => line.push_str(", named with no version"),
1478 ("ambiguous", Some(count)) => {
1479 let _ = write!(line, ", ambiguous: {count} lines name it");
1480 }
1481 _ => {
1482 let _ = write!(
1483 line,
1484 ", pinned {}",
1485 entry.version.as_deref().unwrap_or_default()
1486 );
1487 if let Some(freshness) = entry.freshness {
1488 let _ = write!(
1489 line,
1490 " ({} this binary)",
1491 match freshness {
1492 crate::self_depend::manager::Freshness::Behind => "behind",
1493 crate::self_depend::manager::Freshness::Current => "same as",
1494 crate::self_depend::manager::Freshness::Ahead => "ahead of",
1495 }
1496 );
1497 }
1498 }
1499 }
1500 if let Some(lock) = entry.lock {
1501 let _ = write!(line, ", lock {}", word(lock));
1502 }
1503 if let Some(rev) = &entry.locked_rev {
1504 let _ = write!(line, ", locked at {rev}");
1505 }
1506 line
1507}
1508
1509const fn word(presence: Presence) -> &'static str {
1511 match presence {
1512 Presence::Present => "present",
1513 Presence::Absent => "absent",
1514 }
1515}
1516
1517const fn probe_word(probe: &probes::ProbeResult) -> &'static str {
1519 match probe.status {
1520 ProbeStatus::Ok => "ok",
1521 ProbeStatus::Failed => "failed",
1522 }
1523}
1524
1525fn status_next(observed: &Observed) -> Vec<String> {
1527 let target = &observed.target;
1528 match observed.state() {
1529 "pending-recovery" => vec![format!(
1530 "rk self-depend sync --caller operator --target {target} recovers the interrupted run"
1531 )],
1532 "host" => vec![format!(
1533 "each host installs rk {} or newer itself; rk self-depend add --target {target} --manager <manager> moves the project onto a pin",
1534 observed
1535 .acquisition
1536 .as_ref()
1537 .map_or("", |answer| answer.minimum.as_str())
1538 )],
1539 "no-manager" | "not-wired" | "unpinned" => vec![format!(
1540 "rk self-depend add --target {target} prints the fragments; --apply seeds the files a target lacks"
1541 )],
1542 "ambiguous-pin" => vec![format!(
1543 "leave exactly one line naming release-kit, in one manager file under {target}, then rerun"
1544 )],
1545 "superseded" => vec![format!(
1546 "rk self-depend clean --target {target} previews the removal of the predecessor mechanism; --apply removes it"
1547 )],
1548 _ => vec![format!(
1549 "rk self-depend sync --caller operator --target {target} reports whether the pin is current"
1550 )],
1551 }
1552}
1553
1554#[cfg(test)]
1555mod tests {
1556 use super::{
1557 AddHostReport, AddReport, CleanReport, Host, Manual, Mode, StatusReport, Step, SyncReport,
1558 Venue,
1559 };
1560
1561 #[test]
1563 fn the_self_depend_sync_schema_snapshot_holds() {
1564 let steps = vec![
1565 Step {
1566 step: "rewrite-pin",
1567 status: "ok",
1568 detail: None,
1569 },
1570 Step {
1571 step: "build",
1572 status: "failed",
1573 detail: Some("error: builder failed".to_owned()),
1574 },
1575 ];
1576 let restored = vec!["flake.nix".to_owned(), "flake.lock".to_owned()];
1577 let recovered = vec!["flake.nix".to_owned()];
1578 let next = vec!["both files are as they were".to_owned()];
1579 let report = SyncReport {
1580 schema: "rk.self-depend-sync/2",
1581 mode: "apply",
1582 caller: "operator",
1583 target: "/srv/widget",
1584 manager: Some(Manager::Flake),
1585 outcome: "build-failed",
1586 from: Some("v0.2.15"),
1587 to: Some("v0.2.16"),
1588 detail: Some("build failed: error: builder failed"),
1589 steps: Some(&steps),
1590 restored: Some(&restored),
1591 recovered: Some(&recovered),
1592 stamp: Some("2026-09-04"),
1593 next: &next,
1594 };
1595 assert_eq!(
1596 serde_json::to_string(&report).expect("a report serializes"),
1597 r#"{"schema":"rk.self-depend-sync/2","mode":"apply","caller":"operator","target":"/srv/widget","manager":"flake","outcome":"build-failed","from":"v0.2.15","to":"v0.2.16","detail":"build failed: error: builder failed","steps":[{"step":"rewrite-pin","status":"ok"},{"step":"build","status":"failed","detail":"error: builder failed"}],"restored":["flake.nix","flake.lock"],"recovered":["flake.nix"],"stamp":"2026-09-04","next":["both files are as they were"]}"#
1598 );
1599 let bare = SyncReport {
1600 schema: "rk.self-depend-sync/2",
1601 mode: "preview",
1602 caller: "envrc",
1603 target: "/srv/widget",
1604 manager: None,
1605 outcome: "no-manager",
1606 from: None,
1607 to: None,
1608 detail: None,
1609 steps: None,
1610 restored: None,
1611 recovered: None,
1612 stamp: None,
1613 next: &[],
1614 };
1615 assert_eq!(
1616 serde_json::to_string(&bare).expect("a report serializes"),
1617 r#"{"schema":"rk.self-depend-sync/2","mode":"preview","caller":"envrc","target":"/srv/widget","outcome":"no-manager","next":[]}"#,
1618 "an unknown value is omitted, never null"
1619 );
1620 }
1621
1622 #[test]
1624 fn the_self_depend_clean_schema_snapshot_holds() {
1625 let leftovers = vec![Leftover {
1626 id: "bump-script",
1627 file: "scripts/rk-bump.sh".to_owned(),
1628 line: None,
1629 text: None,
1630 action: Action::RemoveFile,
1631 reason: "the file exists only for the predecessor bump mechanism",
1632 }];
1633 let removed = vec!["scripts/rk-bump.sh".to_owned()];
1634 let rewritten = vec![".envrc".to_owned()];
1635 let manual = vec![Manual {
1636 id: "just-recipe",
1637 file: "justfile".to_owned(),
1638 line: Some(42),
1639 text: Some("rk-bump:".to_owned()),
1640 reason: "a recipe body carries structure a line scan cannot judge",
1641 }];
1642 let next = vec!["rk self-depend status".to_owned()];
1643 let report = CleanReport {
1644 schema: "rk.self-depend-clean/1",
1645 mode: "apply",
1646 target: "/srv/widget",
1647 leftovers: &leftovers,
1648 removed: &removed,
1649 rewritten: &rewritten,
1650 manual: &manual,
1651 next: &next,
1652 };
1653 assert_eq!(
1654 serde_json::to_string(&report).expect("a report serializes"),
1655 r#"{"schema":"rk.self-depend-clean/1","mode":"apply","target":"/srv/widget","leftovers":[{"id":"bump-script","file":"scripts/rk-bump.sh","action":"remove-file","reason":"the file exists only for the predecessor bump mechanism"}],"removed":["scripts/rk-bump.sh"],"rewritten":[".envrc"],"manual":[{"id":"just-recipe","file":"justfile","line":42,"text":"rk-bump:","reason":"a recipe body carries structure a line scan cannot judge"}],"next":["rk self-depend status"]}"#
1656 );
1657 let bare = Manual {
1658 id: "also",
1659 file: "old.sh".to_owned(),
1660 line: None,
1661 text: None,
1662 reason: "named by the operator",
1663 };
1664 assert_eq!(
1665 serde_json::to_string(&bare).expect("an entry serializes"),
1666 r#"{"id":"also","file":"old.sh","reason":"named by the operator"}"#,
1667 "an absent line and text are omitted, never null"
1668 );
1669 }
1670 use crate::self_depend::Presence;
1671 use crate::self_depend::fragments::{Anchor, Fragment};
1672 use crate::self_depend::leftovers::{Action, Leftover};
1673 use crate::self_depend::manager::{Entry, Freshness, Manager, PinRead};
1674
1675 #[test]
1678 fn the_self_depend_add_schema_snapshot_holds() {
1679 let fragments = vec![Fragment {
1680 id: "flake-input",
1681 file: "flake.nix".to_owned(),
1682 role: "the pinned release-kit input",
1683 placement: "insert-into-attrset",
1684 anchor: Anchor {
1685 kind: "attrset",
1686 path: "inputs",
1687 needle: Some("inputs = {"),
1688 },
1689 text: "release-kit = {};".to_owned(),
1690 present: Some(false),
1691 }];
1692 let written = vec![".envrc".to_owned()];
1693 let next = vec!["direnv allow".to_owned()];
1694 let report = AddReport {
1695 schema: "rk.self-depend-add/2",
1696 mode: "apply",
1697 target: "/srv/widget",
1698 tag: "v0.2.16",
1699 tag_source: "binary",
1700 manager: Manager::Flake,
1701 venue: Venue::Flake,
1702 support: Mode::Fragment,
1703 reason: None,
1704 file: "flake.nix",
1705 file_present: Presence::Present,
1706 envrc: Presence::Absent,
1707 written: &written,
1708 refusal: Some("the target already carries flake.nix"),
1709 fragments: &fragments,
1710 next: &next,
1711 };
1712 assert_eq!(
1713 serde_json::to_string(&report).expect("a report serializes"),
1714 r#"{"schema":"rk.self-depend-add/2","mode":"apply","target":"/srv/widget","tag":"v0.2.16","tag_source":"binary","manager":"flake","venue":"flake","support":"fragment","file":"flake.nix","file_present":"present","envrc":"absent","written":[".envrc"],"refusal":"the target already carries flake.nix","fragments":[{"id":"flake-input","file":"flake.nix","role":"the pinned release-kit input","placement":"insert-into-attrset","anchor":{"kind":"attrset","path":"inputs","needle":"inputs = {"},"text":"release-kit = {};","present":false}],"next":["direnv allow"]}"#
1715 );
1716 let manual = AddReport {
1717 schema: "rk.self-depend-add/2",
1718 mode: "preview",
1719 target: "/srv/widget",
1720 tag: "v0.2.16",
1721 tag_source: "binary",
1722 manager: Manager::Asdf,
1723 venue: Venue::Crates,
1724 support: Mode::Manual,
1725 reason: Some("asdf-plugin-unknown"),
1726 file: ".tool-versions",
1727 file_present: Presence::Absent,
1728 envrc: Presence::Absent,
1729 written: &[],
1730 refusal: None,
1731 fragments: &[],
1732 next: &[],
1733 };
1734 assert_eq!(
1735 serde_json::to_string(&manual).expect("a report serializes"),
1736 r#"{"schema":"rk.self-depend-add/2","mode":"preview","target":"/srv/widget","tag":"v0.2.16","tag_source":"binary","manager":"asdf","venue":"crates","support":"manual","reason":"asdf-plugin-unknown","file":".tool-versions","file_present":"absent","envrc":"absent","written":[],"fragments":[],"next":[]}"#,
1737 "a manual pair carries its reason and no refusal"
1738 );
1739 let bare = Fragment {
1740 id: "envrc-sync",
1741 file: ".envrc".to_owned(),
1742 role: "the daily sync on directory entry",
1743 placement: "append-line",
1744 anchor: Anchor {
1745 kind: "file",
1746 path: ".envrc",
1747 needle: None,
1748 },
1749 text: "line".to_owned(),
1750 present: None,
1751 };
1752 assert_eq!(
1753 serde_json::to_string(&bare).expect("a fragment serializes"),
1754 r#"{"id":"envrc-sync","file":".envrc","role":"the daily sync on directory entry","placement":"append-line","anchor":{"kind":"file","path":".envrc"},"text":"line"}"#,
1755 "an unjudged presence and a missing needle are omitted, never null"
1756 );
1757 }
1758
1759 #[test]
1762 fn the_status_schema_is_versioned_and_snapshot_tested() {
1763 let leftovers = vec![
1764 Leftover {
1765 id: "just-recipe",
1766 file: "justfile".to_owned(),
1767 line: Some(42),
1768 text: Some("rk-bump:".to_owned()),
1769 action: Action::Manual,
1770 reason: "a recipe body carries structure a line scan cannot judge",
1771 },
1772 Leftover {
1773 id: "bump-script",
1774 file: "scripts/rk-bump.sh".to_owned(),
1775 line: None,
1776 text: None,
1777 action: Action::RemoveFile,
1778 reason: "the file exists only for the predecessor bump mechanism",
1779 },
1780 ];
1781 let managers = vec![
1782 Entry {
1783 manager: Manager::Flake,
1784 present: Presence::Present,
1785 file: Some("flake.nix".to_owned()),
1786 pin: "pinned",
1787 version: Some("v0.2.16".to_owned()),
1788 pin_lines: Some(1),
1789 freshness: Some(Freshness::Behind),
1790 lock: Some(Presence::Present),
1791 locked_ref: Some("refs/tags/v0.2.16".to_owned()),
1792 locked_rev: Some("9f3c".to_owned()),
1793 read: PinRead::One {
1794 line: 4,
1795 version: "v0.2.16".to_owned(),
1796 },
1797 text: None,
1798 },
1799 Entry::absent(Manager::Mise),
1800 ];
1801 let next = vec!["rk self-depend sync --caller operator --target /srv/widget reports whether the pin is current".to_owned()];
1802 let report = StatusReport {
1803 schema: "rk.self-depend-status/2",
1804 target: "/srv/widget",
1805 state: "ready",
1806 acquisition: None,
1807 wired: Some(Manager::Flake),
1808 managers: &managers,
1809 envrc: Presence::Present,
1810 envrc_sync: true,
1811 stamp: Some("2026-09-04"),
1812 pending: false,
1813 host: Host {
1814 nix: "ok",
1815 direnv: "failed",
1816 },
1817 leftovers: &leftovers,
1818 next: &next,
1819 };
1820 assert_eq!(
1821 serde_json::to_string(&report).expect("a report serializes"),
1822 r#"{"schema":"rk.self-depend-status/2","target":"/srv/widget","state":"ready","wired":"flake","managers":[{"manager":"flake","present":"present","file":"flake.nix","pin":"pinned","version":"v0.2.16","pin_lines":1,"freshness":"behind","lock":"present","locked_ref":"refs/tags/v0.2.16","locked_rev":"9f3c"},{"manager":"mise","present":"absent","pin":"absent"}],"envrc":"present","envrc_sync":true,"stamp":"2026-09-04","pending":false,"host":{"nix":"ok","direnv":"failed"},"leftovers":[{"id":"just-recipe","file":"justfile","line":42,"text":"rk-bump:","action":"manual","reason":"a recipe body carries structure a line scan cannot judge"},{"id":"bump-script","file":"scripts/rk-bump.sh","action":"remove-file","reason":"the file exists only for the predecessor bump mechanism"}],"next":["rk self-depend sync --caller operator --target /srv/widget reports whether the pin is current"]}"#
1823 );
1824 let bare = StatusReport {
1825 schema: "rk.self-depend-status/2",
1826 target: "/srv/widget",
1827 state: "no-manager",
1828 acquisition: None,
1829 wired: None,
1830 managers: &[],
1831 envrc: Presence::Absent,
1832 envrc_sync: false,
1833 stamp: None,
1834 pending: false,
1835 host: Host {
1836 nix: "failed",
1837 direnv: "failed",
1838 },
1839 leftovers: &[],
1840 next: &[],
1841 };
1842 assert_eq!(
1843 serde_json::to_string(&bare).expect("a report serializes"),
1844 r#"{"schema":"rk.self-depend-status/2","target":"/srv/widget","state":"no-manager","managers":[],"envrc":"absent","envrc_sync":false,"pending":false,"host":{"nix":"failed","direnv":"failed"},"leftovers":[],"next":[]}"#,
1845 "an unknown value must be omitted, not serialized as null"
1846 );
1847 }
1848
1849 #[test]
1851 fn the_host_answer_schema_snapshots_hold() {
1852 let answer = crate::config::Acquisition {
1853 source: crate::config::AcquisitionSource::Host,
1854 minimum: "0.8.7".to_owned(),
1855 };
1856 let host = StatusReport {
1857 schema: "rk.self-depend-status/2",
1858 target: "/srv/widget",
1859 state: "host",
1860 acquisition: Some(&answer),
1861 wired: None,
1862 managers: &[],
1863 envrc: Presence::Absent,
1864 envrc_sync: false,
1865 stamp: None,
1866 pending: false,
1867 host: Host {
1868 nix: "failed",
1869 direnv: "failed",
1870 },
1871 leftovers: &[],
1872 next: &[],
1873 };
1874 assert_eq!(
1875 serde_json::to_string(&host).expect("a report serializes"),
1876 r#"{"schema":"rk.self-depend-status/2","target":"/srv/widget","state":"host","acquisition":{"source":"host","minimum":"0.8.7"},"managers":[],"envrc":"absent","envrc_sync":false,"pending":false,"host":{"nix":"failed","direnv":"failed"},"leftovers":[],"next":[]}"#
1877 );
1878 let added = AddHostReport {
1879 schema: "rk.self-depend-add-host/1",
1880 mode: "apply",
1881 target: "/srv/widget",
1882 acquisition: &answer,
1883 config: Presence::Absent,
1884 written: true,
1885 next: &[],
1886 };
1887 assert_eq!(
1888 serde_json::to_string(&added).expect("a report serializes"),
1889 r#"{"schema":"rk.self-depend-add-host/1","mode":"apply","target":"/srv/widget","acquisition":{"source":"host","minimum":"0.8.7"},"config":"absent","written":true,"next":[]}"#
1890 );
1891 }
1892}