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