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