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