1use serde::Serialize;
16
17use camino::Utf8Path;
18
19use crate::cli::self_depend::{
20 AddArgs, Caller, CleanArgs, SelfDependAction, SelfDependArgs, StatusArgs, SyncArgs,
21};
22use crate::diagnostic::{Diagnostic, Reason};
23use crate::error::RkError;
24use crate::output::Output;
25use crate::probes::{self, ProbeStatus};
26use crate::self_depend::discover::{self, Discovery};
27use crate::self_depend::fragments::{self, Fragment};
28use crate::self_depend::guard::{self, Acquired};
29use crate::self_depend::leftovers::{self, Action, Leftover};
30use crate::self_depend::manager::{Entry, Manager};
31use crate::self_depend::txn::{self, AbortFailure, Recovery, StepFailure};
32use crate::self_depend::{self, Observed, Presence, pin};
33
34#[derive(Debug, Serialize)]
36struct StatusReport<'a> {
37 schema: &'static str,
39 target: &'a str,
41 state: &'static str,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 wired: Option<Manager>,
48 managers: &'a [Entry],
51 envrc: Presence,
54 envrc_sync: bool,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 stamp: Option<&'a str>,
59 pending: bool,
61 host: Host,
63 leftovers: &'a [Leftover],
65 next: &'a [String],
67}
68
69#[derive(Debug, Serialize)]
71struct AddReport<'a> {
72 schema: &'static str,
74 mode: &'static str,
76 target: &'a str,
78 tag: &'a str,
80 tag_source: &'static str,
82 flake: Presence,
84 envrc: Presence,
86 written: &'a [String],
89 #[serde(skip_serializing_if = "Option::is_none")]
91 refusal: Option<&'a str>,
92 fragments: &'a [Fragment],
94 next: &'a [String],
96}
97
98#[derive(Debug, Serialize)]
100struct CleanReport<'a> {
101 schema: &'static str,
103 mode: &'static str,
105 target: &'a str,
107 leftovers: &'a [Leftover],
110 removed: &'a [String],
112 rewritten: &'a [String],
114 manual: &'a [Manual],
117 next: &'a [String],
119}
120
121#[derive(Debug, Clone, Serialize)]
123struct Manual {
124 id: &'static str,
126 file: String,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 line: Option<usize>,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 text: Option<String>,
134 reason: &'static str,
136}
137
138#[derive(Debug, Serialize)]
140struct SyncReport<'a> {
141 schema: &'static str,
143 mode: &'static str,
145 caller: &'static str,
147 target: &'a str,
149 outcome: &'static str,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 from: Option<&'a str>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 to: Option<&'a str>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 detail: Option<&'a str>,
166 #[serde(skip_serializing_if = "Option::is_none")]
168 steps: Option<&'a [Step]>,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 restored: Option<&'a [String]>,
172 #[serde(skip_serializing_if = "Option::is_none")]
175 recovered: Option<&'a [String]>,
176 #[serde(skip_serializing_if = "Option::is_none")]
178 stamp: Option<&'a str>,
179 next: &'a [String],
181}
182
183#[derive(Debug, Clone, Serialize)]
185#[allow(
186 clippy::struct_field_names,
187 reason = "the fields are the keys of a serialized machine shape, so they answer to the schema rather than to the struct name"
188)]
189struct Step {
190 step: &'static str,
192 status: &'static str,
194 #[serde(skip_serializing_if = "Option::is_none")]
196 detail: Option<String>,
197}
198
199#[derive(Debug, Default)]
201struct SyncRun {
202 outcome: &'static str,
203 from: Option<String>,
204 to: Option<String>,
205 detail: Option<String>,
206 steps: Option<Vec<Step>>,
207 restored: Option<Vec<String>>,
208 recovered: Option<Vec<String>>,
209 stamp: Option<String>,
210}
211
212#[derive(Debug, Serialize)]
214struct Host {
215 nix: &'static str,
217 direnv: &'static str,
219}
220
221pub fn run(args: &SelfDependArgs) -> Result<(), RkError> {
229 match &args.action {
230 SelfDependAction::Status(args) => status(args),
231 SelfDependAction::Add(args) => add(args),
232 SelfDependAction::Clean(args) => clean(args),
233 SelfDependAction::Sync(args) => sync(args),
234 }
235}
236
237fn status(args: &StatusArgs) -> Result<(), RkError> {
241 let out = Output::new(args.json);
242 let observed = self_depend::observe(&args.target)?;
243 let host = Host {
244 nix: probe_word(&probes::nix()),
245 direnv: probe_word(&probes::direnv()),
246 };
247 let state = observed.state();
248 let managers: Vec<Entry> = observed
249 .managers
250 .iter()
251 .filter(|entry| args.manager.is_none_or(|wanted| entry.manager == wanted))
252 .cloned()
253 .collect();
254 out.result_line(format!("state {state}"));
255 if let Some(wired) = observed.wired {
256 out.result_line(format!("wired through {}", wired.as_str()));
257 }
258 for entry in &managers {
259 out.result_line(manager_line(entry));
260 }
261 out.result_line(format!(
262 ".envrc {}, sync line {}",
263 word(observed.envrc),
264 if observed.envrc_sync { "yes" } else { "no" }
265 ));
266 if let Some(stamp) = &observed.stamp {
267 out.result_line(format!("last sync attempt {stamp}"));
268 }
269 if observed.pending {
270 out.result_line("an interrupted sync awaits recovery");
271 }
272 out.result_line(format!("host nix {}, direnv {}", host.nix, host.direnv));
273 for leftover in &observed.leftovers {
274 out.result_line(leftover_line(leftover));
275 }
276 let next = status_next(&observed);
277 out.next(&next);
278 out.emit(&StatusReport {
279 schema: "rk.self-depend-status/2",
280 target: observed.target.as_str(),
281 state,
282 wired: observed.wired,
283 managers: &managers,
284 envrc: observed.envrc,
285 envrc_sync: observed.envrc_sync,
286 stamp: observed.stamp.as_deref(),
287 pending: observed.pending,
288 host,
289 leftovers: &observed.leftovers,
290 next: &next,
291 })
292}
293
294fn leftover_line(leftover: &Leftover) -> String {
296 use std::fmt::Write as _;
297 let action = match leftover.action {
298 Action::RemoveFile => "remove-file",
299 Action::ReplaceLine => "replace-line",
300 Action::Manual => "manual",
301 };
302 let mut line = format!("leftover {action} {}", leftover.file);
303 if let Some(number) = leftover.line {
304 let _ = write!(line, ":{number}");
305 }
306 if let Some(text) = &leftover.text {
307 let _ = write!(line, " {text}");
308 }
309 let _ = write!(line, " ({}: {})", leftover.id, leftover.reason);
310 line
311}
312
313fn add(args: &AddArgs) -> Result<(), RkError> {
315 let out = Output::new(args.json);
316 let observed = self_depend::observe(&args.target)?;
317 let (tag, tag_source) = resolve_tag(args.tag.as_deref())?;
318 let fragments = fragments::fragments(&tag, &observed);
319 let mode = if args.apply { "apply" } else { "preview" };
320 let mut written = Vec::new();
321 let mut owned = Vec::new();
322 if args.apply {
323 for (name, present, seed) in [
324 ("flake.nix", observed.flake, fragments::seed_flake(&tag)),
325 (".envrc", observed.envrc, fragments::seed_envrc()),
326 ] {
327 if present.is_present() {
328 owned.push(name);
329 } else {
330 crate::atomic::write(observed.target.join(name).as_std_path(), seed.as_bytes())?;
331 written.push(name.to_owned());
332 }
333 }
334 }
335 let refusal = (!owned.is_empty()).then(|| {
336 format!(
337 "the target already carries {}; rk self-depend add never edits a file the target owns",
338 owned.join(" and ")
339 )
340 });
341 if args.apply {
342 for name in &written {
343 out.result_line(format!("wrote {name}"));
344 }
345 } else {
346 out.result_line("DRY RUN: rk self-depend add prints the fragments; --apply seeds only the files the target lacks");
347 }
348 out.result_line(format!("tag {tag} (from the {tag_source})"));
349 for (name, present) in [("flake.nix", observed.flake), (".envrc", observed.envrc)] {
350 out.result_line(match present {
351 Presence::Present => {
352 format!("{name} present: the target owns it, so its fragments are applied by hand")
353 }
354 Presence::Absent => format!("{name} absent: --apply seeds it"),
355 });
356 }
357 for fragment in &fragments {
358 out.result_line(format!(
359 "--- {} into {} ({} at {}){}",
360 fragment.id,
361 fragment.file,
362 fragment.placement,
363 fragment.anchor.path,
364 match fragment.present {
365 Some(true) => ": already present",
366 Some(false) => ": missing",
367 None => ": not judged",
368 }
369 ));
370 out.result_line(&fragment.text);
371 }
372 let next = add_next(&observed, args.apply, &written);
373 out.next(&next);
374 out.emit(&AddReport {
375 schema: "rk.self-depend-add/1",
376 mode,
377 target: observed.target.as_str(),
378 tag: &tag,
379 tag_source,
380 flake: observed.flake,
381 envrc: observed.envrc,
382 written: &written,
383 refusal: refusal.as_deref(),
384 fragments: &fragments,
385 next: &next,
386 })?;
387 let Some(message) = refusal else {
388 return Ok(());
389 };
390 let state = if written.is_empty() {
391 "nothing was written".to_owned()
392 } else {
393 format!(
394 "wrote {}; the owned file is byte-identical",
395 written.join(", ")
396 )
397 };
398 Err(RkError::refusal(
399 Diagnostic::new(Reason::DestructiveRefusal, message)
400 .expected("a target with no flake.nix and no .envrc, or the fragments applied by hand")
401 .target_state(state),
402 ))
403}
404
405fn sync(args: &SyncArgs) -> Result<(), RkError> {
413 let out = Output::new(args.json);
414 let mut observed = self_depend::observe(&args.target)?;
415 let key = observed.key();
416 let mut run = SyncRun {
417 stamp: observed.stamp.clone(),
418 ..SyncRun::default()
419 };
420 let mut held = None;
421 if guard::switched_off() {
422 run.outcome = "skipped-disabled";
423 run.detail = Some(format!("{}=0 is set", guard::SWITCH_VAR));
424 } else if guard::in_ci() {
425 run.outcome = "skipped-ci";
426 run.detail = Some("a CI variable is set; the sync never runs on a runner".to_owned());
427 } else {
428 gate_and_decide(args, &mut observed, &key, &mut run, &mut held, out)?;
429 }
430 render_sync(out, args, &observed, &run)?;
431 drop(held);
432 exit_for(args.caller, &run)
433}
434
435fn gate_and_decide(
440 args: &SyncArgs,
441 observed: &mut Observed,
442 key: &str,
443 run: &mut SyncRun,
444 held: &mut Option<guard::Lock>,
445 out: Output,
446) -> Result<(), RkError> {
447 let envrc = args.caller == Caller::Envrc;
448 let today = guard::today();
449 if args.apply && envrc && !observed.pending && observed.stamp.as_deref() == Some(today.as_str())
452 {
453 run.outcome = "skipped-stamped";
454 run.detail = Some(format!("today's attempt already happened ({today})"));
455 return Ok(());
456 }
457 if args.apply && envrc {
458 if guard::write_stamp(key).is_ok() {
461 run.stamp = Some(today);
462 }
463 }
464 if args.apply {
465 match guard::acquire(key) {
466 Acquired::Held(lock) => *held = Some(lock),
467 Acquired::Contended => {
468 run.outcome = "skipped-locked";
469 run.detail = Some("another run holds this checkout".to_owned());
470 return Ok(());
471 }
472 Acquired::Unavailable(source) => {
473 run.outcome = "lock-unavailable";
474 run.detail = Some(format!("the lock cannot be taken: {source}"));
475 out.warn(format!(
476 "rk self-depend sync: the lock cannot be taken: {source}"
477 ));
478 return Ok(());
479 }
480 }
481 }
482 if args.apply {
483 let recovery = match txn::recover_pending(&observed.target, key) {
486 Ok(recovery) => recovery,
487 Err(source) => {
488 run.outcome = "recovery-failed";
489 run.detail = Some(format!(
490 "the transaction marker under the state root cannot be read: {source}; remove or repair it by hand"
491 ));
492 return Ok(());
493 }
494 };
495 match recovery {
496 Some(Recovery::Restored(restored)) => {
497 run.recovered = Some(restored);
498 *observed = self_depend::observe(&args.target)?;
499 }
500 Some(Recovery::Failed(failure)) => {
501 run.outcome = "recovery-failed";
502 run.detail = Some(format!(
503 "{failure}; the backups stay under the state root for the next attempt"
504 ));
505 return Ok(());
506 }
507 Some(Recovery::Unfinished(failure)) => {
508 run.outcome = "cleanup-failed";
509 run.detail = Some(format!("both files are back, but {failure}"));
510 return Ok(());
511 }
512 Some(Recovery::Finished) => {
513 *observed = self_depend::observe(&args.target)?;
514 }
515 None => {}
516 }
517 }
518 if observed.pending {
519 return decide(args, observed, key, run);
520 }
521 if observed.flake.is_present() && guard::two_files_dirty(&observed.target) {
522 run.outcome = "refused-dirty";
523 run.from = observed.pin_tag().map(str::to_owned);
524 run.detail = Some(
525 "flake.nix or flake.lock carries uncommitted edits; commit or stash them first"
526 .to_owned(),
527 );
528 return Ok(());
529 }
530 decide(args, observed, key, run)
531}
532
533fn decide(
536 args: &SyncArgs,
537 observed: &Observed,
538 key: &str,
539 run: &mut SyncRun,
540) -> Result<(), RkError> {
541 if observed.pending {
542 run.outcome = "pending-recovery";
543 run.detail =
544 Some("an interrupted run left its marker; --apply recovers it first".to_owned());
545 return Ok(());
546 }
547 if !observed.flake.is_present() {
548 run.outcome = "no-flake";
549 return Ok(());
550 }
551 let pin = match &observed.scan {
552 pin::Scan::Many(count) => {
553 run.outcome = "ambiguous-pin";
554 run.detail = Some(format!(
555 "{count} lines name the release-kit input in flake.nix"
556 ));
557 return Ok(());
558 }
559 pin::Scan::None => {
560 run.outcome = "not-wired";
561 return Ok(());
562 }
563 pin::Scan::Unpinned(line) => {
564 run.outcome = "unpinned";
565 run.detail = Some(format!("flake.nix line {line} names the input with no tag"));
566 return Ok(());
567 }
568 pin::Scan::One(pin) => pin,
569 };
570 run.from = Some(pin.tag.clone());
571 let to = match args.tag.as_deref() {
572 Some(raw) => self_depend::normalize_tag(raw).ok_or_else(|| {
573 RkError::Usage(format!(
574 "--tag {raw} is not a release tag; pass v0.2.16, 0.2.16, or the release URL"
575 ))
576 })?,
577 None => match discover::latest_tag() {
578 Discovery::Tag(tag) => tag,
579 Discovery::Unreachable(detail) => {
580 run.outcome = "unreachable";
581 run.detail = Some(detail);
582 return Ok(());
583 }
584 Discovery::Unparsable(answer) => {
585 run.outcome = "unparsable";
586 run.detail = Some(format!("the release page answered no tag: {answer}"));
587 return Ok(());
588 }
589 },
590 };
591 run.to = Some(to.clone());
592 match discover::version_order(&pin.tag, &to) {
593 std::cmp::Ordering::Equal => {
594 run.outcome = "current";
595 return Ok(());
596 }
597 std::cmp::Ordering::Greater if args.tag.is_none() => {
600 run.outcome = "ahead";
601 run.detail = Some(
602 "the pin is ahead of the latest release and is never moved backward".to_owned(),
603 );
604 return Ok(());
605 }
606 std::cmp::Ordering::Greater | std::cmp::Ordering::Less => {}
607 }
608 if !args.apply {
609 run.outcome = "would-bump";
610 return Ok(());
611 }
612 apply_bump(observed, key, pin, &to, run)
613}
614
615fn apply_bump(
618 observed: &Observed,
619 key: &str,
620 pin: &pin::Pin,
621 to: &str,
622 run: &mut SyncRun,
623) -> Result<(), RkError> {
624 let flake_text = observed.flake_text.as_deref().unwrap_or_default();
625 let rewritten = pin::rewrite(flake_text, pin, to);
626 let transaction = txn::open(&observed.target, key)?;
627 let mut steps = Vec::new();
628 let failure = transact(&observed.target, &rewritten, &mut steps);
629 match failure {
630 None => {
631 run.outcome = "bumped";
632 if let Err(failure) = transaction.commit() {
633 run.outcome = "cleanup-failed";
634 run.detail = Some(format!("the pin moved, but {failure}"));
635 }
636 }
637 Some(failed) => {
638 run.outcome = match failed.step {
639 "rewrite-pin" | "flake-update" => "update-failed",
640 _ => "build-failed",
641 };
642 match transaction.abort() {
643 Ok(restored) => {
644 run.restored = Some(restored);
645 run.detail = Some(format!("{} failed: {}", failed.step, failed.detail));
646 }
647 Err(AbortFailure::Restore(failure)) => {
648 run.outcome = "restore-failed";
649 run.detail = Some(format!(
650 "{} failed: {}; then {failure}; the backups stay under the state root for the next run",
651 failed.step, failed.detail
652 ));
653 }
654 Err(AbortFailure::Finish(failure)) => {
655 run.outcome = "cleanup-failed";
656 run.detail = Some(format!(
657 "{} failed: {}; both files are back, but {failure}",
658 failed.step, failed.detail
659 ));
660 }
661 }
662 }
663 }
664 run.steps = Some(steps);
665 Ok(())
666}
667
668type Attempt<'a> = Box<dyn Fn() -> Result<(), StepFailure> + 'a>;
670
671fn transact(target: &Utf8Path, rewritten: &str, steps: &mut Vec<Step>) -> Option<StepFailure> {
674 let attempts: [(&'static str, Attempt<'_>); 3] = [
675 (
676 "rewrite-pin",
677 Box::new(|| {
678 crate::atomic::write(target.join("flake.nix").as_std_path(), rewritten.as_bytes())
679 .map_err(|source| StepFailure {
680 step: "rewrite-pin",
681 detail: source.to_string(),
682 })
683 }),
684 ),
685 ("flake-update", Box::new(|| txn::flake_update(target))),
686 (
687 "build",
688 Box::new(|| {
689 let system = txn::current_system(target)?;
690 txn::build_devshell(target, &system)
691 }),
692 ),
693 ];
694 for (name, attempt) in attempts {
695 match attempt() {
696 Ok(()) => steps.push(Step {
697 step: name,
698 status: "ok",
699 detail: None,
700 }),
701 Err(failed) => {
702 steps.push(Step {
703 step: failed.step,
704 status: "failed",
705 detail: Some(failed.detail.clone()),
706 });
707 return Some(failed);
708 }
709 }
710 }
711 None
712}
713
714const fn is_quiet(outcome: &str) -> bool {
717 matches!(
718 outcome.as_bytes(),
719 b"current"
720 | b"ahead"
721 | b"no-flake"
722 | b"not-wired"
723 | b"unpinned"
724 | b"skipped-ci"
725 | b"skipped-disabled"
726 | b"skipped-stamped"
727 | b"skipped-locked"
728 )
729}
730
731fn render_sync(
733 out: Output,
734 args: &SyncArgs,
735 observed: &Observed,
736 run: &SyncRun,
737) -> Result<(), RkError> {
738 use std::fmt::Write as _;
739 let quiet = args.caller == Caller::Envrc && is_quiet(run.outcome);
740 if let Some(recovered) = &run.recovered {
741 out.result_line(format!(
742 "recovered an interrupted sync: restored {}",
743 recovered.join(", ")
744 ));
745 }
746 if !quiet {
747 out.result_line(sync_line(run));
748 if let Some(steps) = &run.steps {
749 for step in steps {
750 let mut line = format!(" {} {}", step.status, step.step);
751 if let Some(detail) = &step.detail {
752 let _ = write!(line, ": {detail}");
753 }
754 out.result_line(line);
755 }
756 }
757 if let Some(restored) = &run.restored {
758 out.result_line(format!("restored {}", restored.join(", ")));
759 }
760 }
761 let next = if quiet {
762 Vec::new()
763 } else {
764 sync_next(observed, run)
765 };
766 out.next(&next);
767 out.emit(&SyncReport {
768 schema: "rk.self-depend-sync/1",
769 mode: if args.apply { "apply" } else { "preview" },
770 caller: match args.caller {
771 Caller::Envrc => "envrc",
772 Caller::Operator => "operator",
773 },
774 target: observed.target.as_str(),
775 outcome: run.outcome,
776 from: run.from.as_deref(),
777 to: run.to.as_deref(),
778 detail: run.detail.as_deref(),
779 steps: run.steps.as_deref(),
780 restored: run.restored.as_deref(),
781 recovered: run.recovered.as_deref(),
782 stamp: run.stamp.as_deref(),
783 next: &next,
784 })
785}
786
787fn sync_line(run: &SyncRun) -> String {
789 use std::fmt::Write as _;
790 let movement = match (&run.from, &run.to) {
791 (Some(from), Some(to)) if from != to => format!(" {from} -> {to}"),
792 (Some(from), _) => format!(" {from}"),
793 _ => String::new(),
794 };
795 let mut line = format!("{}{movement}", run.outcome);
796 if let Some(detail) = &run.detail {
797 let _ = write!(line, ": {detail}");
798 }
799 line
800}
801
802fn sync_next(observed: &Observed, run: &SyncRun) -> Vec<String> {
804 let target = &observed.target;
805 let mut next = match run.outcome {
806 "bumped" => vec![
807 format!(
808 "git -C {target} diff -- flake.nix flake.lock shows the two-file change to review and commit"
809 ),
810 "the next direnv reload takes the new rk; nothing here commits".to_owned(),
811 ],
812 "would-bump" => vec![format!(
813 "rk self-depend sync --caller operator --apply --target {target} moves the pin, locks it, and proves the build"
814 )],
815 "current" => vec![format!(
816 "rk self-depend status --target {target} reports the wiring"
817 )],
818 "ahead" => vec![
819 "a pin past the latest release is a deliberate state; nothing moves it back".to_owned(),
820 ],
821 "pending-recovery" => vec![format!(
822 "rk self-depend sync --caller operator --apply --target {target} restores both files first"
823 )],
824 "no-flake" | "not-wired" | "unpinned" => vec![format!(
825 "rk self-depend add --target {target} prints the fragments; --apply seeds the files a target lacks"
826 )],
827 "ambiguous-pin" => vec![format!(
828 "leave exactly one release-kit input line in {target}/flake.nix, then rerun"
829 )],
830 "refused-dirty" => vec![format!(
831 "git -C {target} status -- flake.nix flake.lock names the edits; commit or stash them, then rerun"
832 )],
833 "skipped-disabled" => vec![format!(
834 "unset {} to let the sync run again",
835 guard::SWITCH_VAR
836 )],
837 "skipped-stamped" => vec![format!(
838 "rk self-depend sync --caller operator --apply --target {target} runs the attempt now, whatever the stamp says"
839 )],
840 "skipped-locked" => vec!["let the other run finish; nothing here is owed".to_owned()],
841 "lock-unavailable" => vec!["make the state root writable: rk doctor reports it".to_owned()],
842 "unreachable" | "unparsable" => vec![
843 "retry when the release page answers; --tag <TAG> makes no request at all".to_owned(),
844 ],
845 "cleanup-failed" => vec![
846 "remove the named marker by hand before the next entry; an active marker beside its backups would overwrite later edits".to_owned(),
847 ],
848 "recovery-failed" | "restore-failed" => vec![
849 "a file is not back: free the path the detail names, then rerun; the backups wait under the state root".to_owned(),
850 ],
851 "update-failed" | "build-failed" => vec![
852 "both files are as they were; the failing step's last line is above".to_owned(),
853 format!(
854 "rk self-depend sync --caller operator --apply --target {target} retries after the fix"
855 ),
856 ],
857 _ => Vec::new(),
858 };
859 if !observed.leftovers.is_empty() {
860 next.push(format!(
861 "rk self-depend clean --target {target}: the target still carries a predecessor bump mechanism"
862 ));
863 }
864 next
865}
866
867fn exit_for(caller: Caller, run: &SyncRun) -> Result<(), RkError> {
870 if caller == Caller::Envrc {
871 return Ok(());
872 }
873 let detail = run.detail.clone().unwrap_or_default();
874 match run.outcome {
875 "ambiguous-pin" | "refused-dirty" => Err(RkError::refusal(
876 Diagnostic::new(Reason::StateDrift, detail)
877 .expected("exactly one committed pin line in flake.nix")
878 .target_state("nothing was written"),
879 )),
880 "unreachable" | "unparsable" => {
881 let mut diagnostic = Diagnostic::new(Reason::ForgeTemporary, detail)
882 .action("rerun when the release page answers, or pass --tag")
883 .target_state("nothing was written");
884 diagnostic.retry = Some(true);
885 Err(RkError::subprocess(diagnostic))
886 }
887 "update-failed" | "build-failed" => {
888 let step = run
889 .steps
890 .as_ref()
891 .and_then(|steps| steps.iter().find(|s| s.status == "failed"))
892 .map_or("transaction", |s| s.step);
893 Err(RkError::subprocess(
894 Diagnostic::new(Reason::SubprocessFailed, detail)
895 .step(step)
896 .target_state(format!(
897 "restored {}",
898 run.restored.as_deref().unwrap_or_default().join(" and ")
899 )),
900 ))
901 }
902 "lock-unavailable" | "restore-failed" | "recovery-failed" | "cleanup-failed" => {
903 Err(RkError::Io(std::io::Error::other(detail)))
904 }
905 _ => Ok(()),
906 }
907}
908
909fn clean(args: &CleanArgs) -> Result<(), RkError> {
911 let out = Output::new(args.json);
912 let observed = self_depend::observe(&args.target)?;
913 let target = &observed.target;
914 let mut leftovers = observed.leftovers.clone();
915 for path in &args.also {
916 leftovers.push(also_leftover(target, path)?);
917 }
918 let mode = if args.apply { "apply" } else { "preview" };
919 let mut removed = Vec::new();
920 let mut rewritten = Vec::new();
921 let mut manual = Vec::new();
922 if args.apply {
923 for leftover in &leftovers {
924 match leftover.action {
925 Action::RemoveFile => {
926 std::fs::remove_file(target.join(&leftover.file))?;
927 removed.push(leftover.file.clone());
928 }
929 Action::ReplaceLine => {}
930 Action::Manual => manual.push(Manual {
931 id: leftover.id,
932 file: leftover.file.clone(),
933 line: leftover.line,
934 text: leftover.text.clone(),
935 reason: leftover.reason,
936 }),
937 }
938 }
939 if leftovers.iter().any(|l| l.action == Action::ReplaceLine) {
940 let envrc = target.join(".envrc");
941 let text = std::fs::read_to_string(&envrc)?;
942 if let Some(swapped) = leftovers::swap_envrc(&text, &fragments::envrc_line()) {
943 crate::atomic::write(envrc.as_std_path(), swapped.as_bytes())?;
944 rewritten.push(".envrc".to_owned());
945 }
946 }
947 }
948 if args.apply {
949 for file in &removed {
950 out.result_line(format!("removed {file}"));
951 }
952 for file in &rewritten {
953 out.result_line(format!(
954 "rewrote {file}: the sync line replaces the invocation"
955 ));
956 }
957 for entry in &manual {
958 out.result_line(format!(
959 "manual {}{} {} ({}: {})",
960 entry.file,
961 entry.line.map(|n| format!(":{n}")).unwrap_or_default(),
962 entry.text.as_deref().unwrap_or_default(),
963 entry.id,
964 entry.reason
965 ));
966 }
967 if removed.is_empty() && rewritten.is_empty() && manual.is_empty() {
968 out.result_line("nothing to remove: the target carries no predecessor mechanism");
969 }
970 } else {
971 out.result_line(
972 "DRY RUN: rk self-depend clean removes and rewrites these on --apply, and names the rest",
973 );
974 for leftover in &leftovers {
975 out.result_line(leftover_line(leftover));
976 }
977 if leftovers.is_empty() {
978 out.result_line("nothing to remove: the target carries no predecessor mechanism");
979 }
980 }
981 let next = clean_next(&observed, args.apply, &leftovers, &manual);
982 out.next(&next);
983 out.emit(&CleanReport {
984 schema: "rk.self-depend-clean/1",
985 mode,
986 target: target.as_str(),
987 leftovers: &leftovers,
988 removed: &removed,
989 rewritten: &rewritten,
990 manual: &manual,
991 next: &next,
992 })
993}
994
995fn also_leftover(target: &Utf8Path, path: &Utf8Path) -> Result<Leftover, RkError> {
998 let absolute = if path.is_absolute() {
999 path.to_owned()
1000 } else {
1001 target.join(path)
1002 };
1003 let refuse = |why: &str| {
1004 RkError::refusal(
1005 Diagnostic::new(
1006 Reason::DestructiveRefusal,
1007 format!("--also {path} is {why}; nothing was removed"),
1008 )
1009 .expected("a regular file inside the target, named for removal"),
1010 )
1011 };
1012 let Ok(meta) = std::fs::symlink_metadata(&absolute) else {
1013 return Err(refuse("not a file that exists"));
1014 };
1015 if meta.file_type().is_symlink() {
1016 return Err(refuse("a symlink, which a file removal never follows"));
1017 }
1018 if meta.is_dir() {
1019 return Err(refuse("a directory, and the cleanup removes files alone"));
1020 }
1021 let canonical = absolute.canonicalize_utf8()?;
1022 let Ok(relative) = canonical.strip_prefix(target) else {
1023 return Err(refuse("outside the target"));
1024 };
1025 Ok(Leftover {
1026 id: "also",
1027 file: relative.to_string(),
1028 line: None,
1029 text: None,
1030 action: Action::RemoveFile,
1031 reason: "named by the operator as a predecessor file the catalog does not know",
1032 })
1033}
1034
1035fn clean_next(
1037 observed: &Observed,
1038 apply: bool,
1039 leftovers: &[Leftover],
1040 manual: &[Manual],
1041) -> Vec<String> {
1042 let target = &observed.target;
1043 let mut next = Vec::new();
1044 if !apply && !leftovers.is_empty() {
1045 next.push(format!(
1046 "rk self-depend clean --target {target} --apply removes the files and rewrites .envrc"
1047 ));
1048 }
1049 let by_hand: Vec<String> = if apply {
1050 manual
1051 .iter()
1052 .map(|entry| entry.file.clone())
1053 .collect::<std::collections::BTreeSet<_>>()
1054 .into_iter()
1055 .collect()
1056 } else {
1057 leftovers
1058 .iter()
1059 .filter(|l| l.action == Action::Manual)
1060 .map(|l| l.file.clone())
1061 .collect::<std::collections::BTreeSet<_>>()
1062 .into_iter()
1063 .collect()
1064 };
1065 if !by_hand.is_empty() {
1066 next.push(format!(
1067 "edit by hand what a line scan must not touch: {}",
1068 by_hand.join(", ")
1069 ));
1070 }
1071 next.push(format!(
1072 "rk self-depend status --target {target} reports ready once the leftovers list is empty"
1073 ));
1074 if matches!(observed.scan, pin::Scan::None) {
1075 next.push(format!(
1076 "rk self-depend add --target {target} wires the native mechanism once the predecessor is gone"
1077 ));
1078 }
1079 next
1080}
1081
1082fn resolve_tag(argument: Option<&str>) -> Result<(String, &'static str), RkError> {
1085 let Some(raw) = argument else {
1086 return Ok((format!("v{}", env!("CARGO_PKG_VERSION")), "binary"));
1087 };
1088 self_depend::normalize_tag(raw)
1089 .map(|tag| (tag, "argument"))
1090 .ok_or_else(|| {
1091 RkError::Usage(format!(
1092 "--tag {raw} is not a release tag; pass v0.2.16, 0.2.16, or the release URL"
1093 ))
1094 })
1095}
1096
1097fn add_next(observed: &Observed, apply: bool, written: &[String]) -> Vec<String> {
1099 let target = &observed.target;
1100 let mut next = Vec::new();
1101 if !observed.leftovers.is_empty() {
1102 next.push(format!(
1103 "rk self-depend clean --target {target} first: the target carries a predecessor bump mechanism, and one project runs one"
1104 ));
1105 }
1106 if !apply {
1107 next.push(format!(
1108 "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"
1109 ));
1110 next.push(
1111 "run rk init --nix before the apply where the landed packaging capability is also wanted: a seeded flake.nix withholds it later".to_owned(),
1112 );
1113 }
1114 if !written.is_empty() {
1115 next.push(format!(
1116 "commit {} first — nix reads only tracked files, and the sync refuses uncommitted edits to the pair",
1117 written.join(" and ")
1118 ));
1119 }
1120 next.push(format!(
1121 "rk self-depend sync --caller operator --apply --target {target} writes the lock and proves the build; commit flake.lock, then direnv allow"
1122 ));
1123 next
1124}
1125
1126fn manager_line(entry: &Entry) -> String {
1128 use std::fmt::Write as _;
1129 let mut line = format!("manager {} {}", entry.manager.as_str(), word(entry.present));
1130 let Some(file) = &entry.file else {
1131 return line;
1132 };
1133 let _ = write!(line, " ({file})");
1134 match (entry.pin, entry.pin_lines) {
1135 ("absent", _) => line.push_str(", not named"),
1136 ("unpinned", _) => line.push_str(", named with no version"),
1137 ("ambiguous", Some(count)) => {
1138 let _ = write!(line, ", ambiguous: {count} lines name it");
1139 }
1140 _ => {
1141 let _ = write!(
1142 line,
1143 ", pinned {}",
1144 entry.version.as_deref().unwrap_or_default()
1145 );
1146 if let Some(freshness) = entry.freshness {
1147 let _ = write!(
1148 line,
1149 " ({} this binary)",
1150 match freshness {
1151 crate::self_depend::manager::Freshness::Behind => "behind",
1152 crate::self_depend::manager::Freshness::Current => "same as",
1153 crate::self_depend::manager::Freshness::Ahead => "ahead of",
1154 }
1155 );
1156 }
1157 }
1158 }
1159 if let Some(lock) = entry.lock {
1160 let _ = write!(line, ", lock {}", word(lock));
1161 }
1162 if let Some(rev) = &entry.locked_rev {
1163 let _ = write!(line, ", locked at {rev}");
1164 }
1165 line
1166}
1167
1168const fn word(presence: Presence) -> &'static str {
1170 match presence {
1171 Presence::Present => "present",
1172 Presence::Absent => "absent",
1173 }
1174}
1175
1176const fn probe_word(probe: &probes::ProbeResult) -> &'static str {
1178 match probe.status {
1179 ProbeStatus::Ok => "ok",
1180 ProbeStatus::Failed => "failed",
1181 }
1182}
1183
1184fn status_next(observed: &Observed) -> Vec<String> {
1186 let target = &observed.target;
1187 match observed.state() {
1188 "pending-recovery" => vec![format!(
1189 "rk self-depend sync --caller operator --target {target} recovers the interrupted run"
1190 )],
1191 "no-manager" | "not-wired" | "unpinned" => vec![format!(
1192 "rk self-depend add --target {target} prints the fragments; --apply seeds the files a target lacks"
1193 )],
1194 "ambiguous-pin" => vec![format!(
1195 "leave exactly one line naming release-kit, in one manager file under {target}, then rerun"
1196 )],
1197 "superseded" => vec![format!(
1198 "rk self-depend clean --target {target} previews the removal of the predecessor mechanism; --apply removes it"
1199 )],
1200 _ => vec![format!(
1201 "rk self-depend sync --caller operator --target {target} reports whether the pin is current"
1202 )],
1203 }
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::{AddReport, CleanReport, Host, Manual, StatusReport, Step, SyncReport};
1209
1210 #[test]
1212 fn the_self_depend_sync_schema_snapshot_holds() {
1213 let steps = vec![
1214 Step {
1215 step: "rewrite-pin",
1216 status: "ok",
1217 detail: None,
1218 },
1219 Step {
1220 step: "build",
1221 status: "failed",
1222 detail: Some("error: builder failed".to_owned()),
1223 },
1224 ];
1225 let restored = vec!["flake.nix".to_owned(), "flake.lock".to_owned()];
1226 let recovered = vec!["flake.nix".to_owned()];
1227 let next = vec!["both files are as they were".to_owned()];
1228 let report = SyncReport {
1229 schema: "rk.self-depend-sync/1",
1230 mode: "apply",
1231 caller: "operator",
1232 target: "/srv/widget",
1233 outcome: "build-failed",
1234 from: Some("v0.2.15"),
1235 to: Some("v0.2.16"),
1236 detail: Some("build failed: error: builder failed"),
1237 steps: Some(&steps),
1238 restored: Some(&restored),
1239 recovered: Some(&recovered),
1240 stamp: Some("2026-09-04"),
1241 next: &next,
1242 };
1243 assert_eq!(
1244 serde_json::to_string(&report).expect("a report serializes"),
1245 r#"{"schema":"rk.self-depend-sync/1","mode":"apply","caller":"operator","target":"/srv/widget","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"]}"#
1246 );
1247 let bare = SyncReport {
1248 schema: "rk.self-depend-sync/1",
1249 mode: "preview",
1250 caller: "envrc",
1251 target: "/srv/widget",
1252 outcome: "no-flake",
1253 from: None,
1254 to: None,
1255 detail: None,
1256 steps: None,
1257 restored: None,
1258 recovered: None,
1259 stamp: None,
1260 next: &[],
1261 };
1262 assert_eq!(
1263 serde_json::to_string(&bare).expect("a report serializes"),
1264 r#"{"schema":"rk.self-depend-sync/1","mode":"preview","caller":"envrc","target":"/srv/widget","outcome":"no-flake","next":[]}"#,
1265 "an unknown value is omitted, never null"
1266 );
1267 }
1268
1269 #[test]
1271 fn the_self_depend_clean_schema_snapshot_holds() {
1272 let leftovers = vec![Leftover {
1273 id: "bump-script",
1274 file: "scripts/rk-bump.sh".to_owned(),
1275 line: None,
1276 text: None,
1277 action: Action::RemoveFile,
1278 reason: "the file exists only for the predecessor bump mechanism",
1279 }];
1280 let removed = vec!["scripts/rk-bump.sh".to_owned()];
1281 let rewritten = vec![".envrc".to_owned()];
1282 let manual = vec![Manual {
1283 id: "just-recipe",
1284 file: "justfile".to_owned(),
1285 line: Some(42),
1286 text: Some("rk-bump:".to_owned()),
1287 reason: "a recipe body carries structure a line scan cannot judge",
1288 }];
1289 let next = vec!["rk self-depend status".to_owned()];
1290 let report = CleanReport {
1291 schema: "rk.self-depend-clean/1",
1292 mode: "apply",
1293 target: "/srv/widget",
1294 leftovers: &leftovers,
1295 removed: &removed,
1296 rewritten: &rewritten,
1297 manual: &manual,
1298 next: &next,
1299 };
1300 assert_eq!(
1301 serde_json::to_string(&report).expect("a report serializes"),
1302 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"]}"#
1303 );
1304 let bare = Manual {
1305 id: "also",
1306 file: "old.sh".to_owned(),
1307 line: None,
1308 text: None,
1309 reason: "named by the operator",
1310 };
1311 assert_eq!(
1312 serde_json::to_string(&bare).expect("an entry serializes"),
1313 r#"{"id":"also","file":"old.sh","reason":"named by the operator"}"#,
1314 "an absent line and text are omitted, never null"
1315 );
1316 }
1317 use crate::self_depend::Presence;
1318 use crate::self_depend::fragments::{Anchor, Fragment};
1319 use crate::self_depend::leftovers::{Action, Leftover};
1320 use crate::self_depend::manager::{Entry, Freshness, Manager, PinRead};
1321
1322 #[test]
1325 fn the_self_depend_add_schema_snapshot_holds() {
1326 let fragments = vec![Fragment {
1327 id: "flake-input",
1328 file: "flake.nix",
1329 role: "the pinned release-kit input",
1330 placement: "insert-into-attrset",
1331 anchor: Anchor {
1332 kind: "attrset",
1333 path: "inputs",
1334 needle: Some("inputs = {"),
1335 },
1336 text: "release-kit = {};".to_owned(),
1337 present: Some(false),
1338 }];
1339 let written = vec![".envrc".to_owned()];
1340 let next = vec!["direnv allow".to_owned()];
1341 let report = AddReport {
1342 schema: "rk.self-depend-add/1",
1343 mode: "apply",
1344 target: "/srv/widget",
1345 tag: "v0.2.16",
1346 tag_source: "binary",
1347 flake: Presence::Present,
1348 envrc: Presence::Absent,
1349 written: &written,
1350 refusal: Some("the target already carries flake.nix"),
1351 fragments: &fragments,
1352 next: &next,
1353 };
1354 assert_eq!(
1355 serde_json::to_string(&report).expect("a report serializes"),
1356 r#"{"schema":"rk.self-depend-add/1","mode":"apply","target":"/srv/widget","tag":"v0.2.16","tag_source":"binary","flake":"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"]}"#
1357 );
1358 let bare = Fragment {
1359 id: "envrc-sync",
1360 file: ".envrc",
1361 role: "the daily sync on directory entry",
1362 placement: "append-line",
1363 anchor: Anchor {
1364 kind: "file",
1365 path: ".envrc",
1366 needle: None,
1367 },
1368 text: "line".to_owned(),
1369 present: None,
1370 };
1371 assert_eq!(
1372 serde_json::to_string(&bare).expect("a fragment serializes"),
1373 r#"{"id":"envrc-sync","file":".envrc","role":"the daily sync on directory entry","placement":"append-line","anchor":{"kind":"file","path":".envrc"},"text":"line"}"#,
1374 "an unjudged presence and a missing needle are omitted, never null"
1375 );
1376 }
1377
1378 #[test]
1381 fn the_status_schema_is_versioned_and_snapshot_tested() {
1382 let leftovers = vec![
1383 Leftover {
1384 id: "just-recipe",
1385 file: "justfile".to_owned(),
1386 line: Some(42),
1387 text: Some("rk-bump:".to_owned()),
1388 action: Action::Manual,
1389 reason: "a recipe body carries structure a line scan cannot judge",
1390 },
1391 Leftover {
1392 id: "bump-script",
1393 file: "scripts/rk-bump.sh".to_owned(),
1394 line: None,
1395 text: None,
1396 action: Action::RemoveFile,
1397 reason: "the file exists only for the predecessor bump mechanism",
1398 },
1399 ];
1400 let managers = vec![
1401 Entry {
1402 manager: Manager::Flake,
1403 present: Presence::Present,
1404 file: Some("flake.nix".to_owned()),
1405 pin: "pinned",
1406 version: Some("v0.2.16".to_owned()),
1407 pin_lines: Some(1),
1408 freshness: Some(Freshness::Behind),
1409 lock: Some(Presence::Present),
1410 locked_ref: Some("refs/tags/v0.2.16".to_owned()),
1411 locked_rev: Some("9f3c".to_owned()),
1412 read: PinRead::One {
1413 line: 4,
1414 version: "v0.2.16".to_owned(),
1415 },
1416 },
1417 Entry::absent(Manager::Mise),
1418 ];
1419 let next = vec!["rk self-depend sync --caller operator --target /srv/widget reports whether the pin is current".to_owned()];
1420 let report = StatusReport {
1421 schema: "rk.self-depend-status/2",
1422 target: "/srv/widget",
1423 state: "ready",
1424 wired: Some(Manager::Flake),
1425 managers: &managers,
1426 envrc: Presence::Present,
1427 envrc_sync: true,
1428 stamp: Some("2026-09-04"),
1429 pending: false,
1430 host: Host {
1431 nix: "ok",
1432 direnv: "failed",
1433 },
1434 leftovers: &leftovers,
1435 next: &next,
1436 };
1437 assert_eq!(
1438 serde_json::to_string(&report).expect("a report serializes"),
1439 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"]}"#
1440 );
1441 let bare = StatusReport {
1442 schema: "rk.self-depend-status/2",
1443 target: "/srv/widget",
1444 state: "no-manager",
1445 wired: None,
1446 managers: &[],
1447 envrc: Presence::Absent,
1448 envrc_sync: false,
1449 stamp: None,
1450 pending: false,
1451 host: Host {
1452 nix: "failed",
1453 direnv: "failed",
1454 },
1455 leftovers: &[],
1456 next: &[],
1457 };
1458 assert_eq!(
1459 serde_json::to_string(&bare).expect("a report serializes"),
1460 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":[]}"#,
1461 "an unknown value must be omitted, not serialized as null"
1462 );
1463 }
1464}