Skip to main content

release_kit/commands/
devshell.rs

1//! `rk devshell status | add | clean | sync`: the consumer half of the
2//! release-kit flake.
3//!
4//! The producer half already ships: the flake at every tag, and the
5//! landed package expression. This handler serves a consumer that pins
6//! that flake as a devshell input. `status` is the offline reporter and
7//! fetches nothing; `add` serves the fragments and seeds the pair where a
8//! target has neither file, never editing a file the target owns;
9//! `clean` removes what a predecessor mechanism left and names the rest,
10//! so the wiring is a replacement and never an addition; `sync` moves
11//! the pin to the latest release inside a fenced transaction, both files
12//! or neither. Every report goes through the output boundary with a
13//! versioned schema.
14
15use 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/// The `rk.devshell-status/1` document.
34#[derive(Debug, Serialize)]
35struct StatusReport<'a> {
36    /// The shape version of this document.
37    schema: &'static str,
38    /// The target, canonical.
39    target: &'a str,
40    /// The rollup: `ready`, `superseded`, `no-flake`, `not-wired`,
41    /// `unpinned`, `ambiguous-pin`, or `pending-recovery`.
42    state: &'static str,
43    /// Whether `flake.nix` exists.
44    flake: Presence,
45    /// Whether `flake.lock` exists.
46    lock: Presence,
47    /// `pinned`, `unpinned`, `absent`, or `ambiguous`.
48    input: &'static str,
49    /// The pinned tag, where exactly one pin was found.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pin_tag: Option<&'a str>,
52    /// How many lines name the input, where any does.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pin_lines: Option<usize>,
55    /// The locked ref of the input, where the lock names one.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    locked_ref: Option<&'a str>,
58    /// The locked commit of the input, where the lock names one.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    locked_rev: Option<&'a str>,
61    /// Whether `.envrc` exists.
62    envrc: Presence,
63    /// Whether `.envrc` carries the sync line.
64    envrc_sync: bool,
65    /// The day of the last sync attempt, where stamped.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    stamp: Option<&'a str>,
68    /// Whether an interrupted transaction awaits recovery.
69    pending: bool,
70    /// The two host probes the sync depends on.
71    host: Host,
72    /// What a predecessor bump mechanism left, whatever the state.
73    leftovers: &'a [Leftover],
74    /// What plausibly follows.
75    next: &'a [String],
76}
77
78/// The `rk.devshell-add/1` document.
79#[derive(Debug, Serialize)]
80struct AddReport<'a> {
81    /// The shape version of this document.
82    schema: &'static str,
83    /// `preview` or `apply`.
84    mode: &'static str,
85    /// The target, canonical.
86    target: &'a str,
87    /// The tag the fragments pin.
88    tag: &'a str,
89    /// `binary` or `argument`: where the tag came from.
90    tag_source: &'static str,
91    /// Whether `flake.nix` existed before the run.
92    flake: Presence,
93    /// Whether `.envrc` existed before the run.
94    envrc: Presence,
95    /// The seed files this run wrote, relative to the target; empty in
96    /// preview.
97    written: &'a [String],
98    /// Why an owned file was refused, where one was.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    refusal: Option<&'a str>,
101    /// The four fragments, in application order.
102    fragments: &'a [Fragment],
103    /// What plausibly follows.
104    next: &'a [String],
105}
106
107/// The `rk.devshell-clean/1` document.
108#[derive(Debug, Serialize)]
109struct CleanReport<'a> {
110    /// The shape version of this document.
111    schema: &'static str,
112    /// `preview` or `apply`.
113    mode: &'static str,
114    /// The target, canonical.
115    target: &'a str,
116    /// Every leftover the scan found before the run, in catalog order,
117    /// plus one `also` row per `--also` path.
118    leftovers: &'a [Leftover],
119    /// The files this run removed, relative to the target; empty in preview.
120    removed: &'a [String],
121    /// The files this run rewrote; empty in preview.
122    rewritten: &'a [String],
123    /// What this run left in place by design, for a hand edit; empty in
124    /// preview.
125    manual: &'a [Manual],
126    /// What plausibly follows.
127    next: &'a [String],
128}
129
130/// One leftover the cleanup names and does not touch.
131#[derive(Debug, Clone, Serialize)]
132struct Manual {
133    /// The catalog entry.
134    id: &'static str,
135    /// The file, relative to the target.
136    file: String,
137    /// The one-based line, where the entry is a line.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    line: Option<usize>,
140    /// The matched line, trimmed.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    text: Option<String>,
143    /// Why a line scan must not touch it.
144    reason: &'static str,
145}
146
147/// The `rk.devshell-sync/1` document.
148#[derive(Debug, Serialize)]
149struct SyncReport<'a> {
150    /// The shape version of this document.
151    schema: &'static str,
152    /// `preview` or `apply`.
153    mode: &'static str,
154    /// `envrc` or `operator`.
155    caller: &'static str,
156    /// The target, canonical.
157    target: &'a str,
158    /// The closed outcome vocabulary: `bumped`, `current`, `ahead`,
159    /// `would-bump`, `pending-recovery`, `recovery-failed`, `skipped-ci`,
160    /// `skipped-disabled`, `skipped-stamped`, `skipped-locked`,
161    /// `lock-unavailable`, `not-wired`, `unpinned`, `no-flake`,
162    /// `ambiguous-pin`, `refused-dirty`, `unreachable`, `unparsable`,
163    /// `update-failed`, `build-failed`, `restore-failed`, or
164    /// `cleanup-failed`.
165    outcome: &'static str,
166    /// The pinned tag before the run, where the pin was read.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    from: Option<&'a str>,
169    /// The tag the run moved to or would move to, where one was resolved.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    to: Option<&'a str>,
172    /// One line of detail for an outcome that has one.
173    #[serde(skip_serializing_if = "Option::is_none")]
174    detail: Option<&'a str>,
175    /// The transaction's steps, once it opened.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    steps: Option<&'a [Step]>,
178    /// The files a failure rolled back.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    restored: Option<&'a [String]>,
181    /// The files an earlier interrupted run left half-moved, restored
182    /// before this run.
183    #[serde(skip_serializing_if = "Option::is_none")]
184    recovered: Option<&'a [String]>,
185    /// The day this checkout's last attempt was stamped.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    stamp: Option<&'a str>,
188    /// What plausibly follows.
189    next: &'a [String],
190}
191
192/// One transaction step.
193#[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    /// `rewrite-pin`, `flake-update`, `current-system`, or `build`.
200    step: &'static str,
201    /// `ok` or `failed`.
202    status: &'static str,
203    /// The child's last stderr line, for a failed step.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    detail: Option<String>,
206}
207
208/// Everything one sync run decided, before rendering.
209#[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/// The two Soft probes the sync spawns, as `ok` or `failed`.
222#[derive(Debug, Serialize)]
223struct Host {
224    /// Whether `nix` answers.
225    nix: &'static str,
226    /// Whether `direnv` answers.
227    direnv: &'static str,
228}
229
230/// Dispatch the devshell action.
231///
232/// # Errors
233///
234/// Returns [`RkError::Missing`] for a target that is not a directory,
235/// [`RkError::Io`] where a present file does not read, and
236/// [`RkError::Usage`] for an action this build does not carry yet.
237pub 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
246/// Report the wiring, offline.
247fn 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
300/// The one human line for a leftover.
301fn 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
319/// Serve the fragments; seed the files a target lacks under `--apply`.
320fn 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
411/// Move the pin forward, both files or neither, behind the gates.
412///
413/// The gate order is the design: the off switches first, so CI and a
414/// switched-off shell fetch nothing and spawn nothing; then the recovery
415/// of an interrupted run; then the daily stamp, which under the `.envrc`
416/// caller ends a stamped day before the lock, the fetch, and nix; then
417/// the lock; then the observation and the dirty check; then the decision.
418fn 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
441/// The stamp, the lock, the recovery, the dirty check, and the
442/// decision, in that order; `held` keeps the lock alive until the report
443/// is rendered. The recovery runs under the lock, so two entries never
444/// restore the same marker at once.
445fn 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    // A pending marker outranks the stamp: an interrupted run recovers on
456    // the next entry, not on the next day.
457    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        // A stamp that cannot be written is not the run's failure: the
465        // lock under the same root answers for the broken state root.
466        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        // A marker that cannot be read is a reported outcome, never an
490        // error: the unattended caller must still exit 0.
491        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
539/// The decision sequence: the wiring, the target tag, the comparison,
540/// and — under `--apply` on a behind pin — the transaction.
541fn 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        // A discovered tag moves the pin forward only; an explicit --tag is
604        // the operator's deliberate choice and pins in either direction.
605        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
621/// Rewrite, refresh, and build inside one transaction; a failure
622/// restores both files, and a restore that cannot is its own outcome.
623fn 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
674/// One step's work, boxed so the three steps sit in one ordered table.
675type Attempt<'a> = Box<dyn Fn() -> Result<(), StepFailure> + 'a>;
676
677/// The ordered steps inside an open transaction; the first failure ends
678/// the sequence and is returned.
679fn 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
720/// Whether an outcome is "nothing to do", which the `.envrc` caller
721/// reports in silence.
722const 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
737/// Render the human lines and emit the document.
738fn 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
793/// The one human line for an outcome.
794fn 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
808/// What plausibly follows a sync.
809fn 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
873/// The exit for a finished run: the `.envrc` caller exits 0 on every
874/// reported outcome, and the operator caller takes the matrix.
875fn 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
915/// Remove what the catalog can judge, name the rest.
916fn 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
1001/// One `--also` path as a leftover, or the refusal: it must be a regular
1002/// file inside the target, judged before any write.
1003fn 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
1041/// What plausibly follows a clean.
1042fn 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
1088/// The tag an `add` pins: the argument, normalized, or this binary's own
1089/// version, so the fragments stay offline and deterministic.
1090fn 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
1103/// What plausibly follows an add.
1104fn 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
1132/// The human line for the input.
1133fn 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
1149/// The closed `input` vocabulary.
1150const 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
1159/// How many lines name the input, where any does.
1160const 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
1168/// The human word for a presence.
1169const fn word(presence: Presence) -> &'static str {
1170    match presence {
1171        Presence::Present => "present",
1172        Presence::Absent => "absent",
1173    }
1174}
1175
1176/// The host word for a probe.
1177const fn probe_word(probe: &probes::ProbeResult) -> &'static str {
1178    match probe.status {
1179        ProbeStatus::Ok => "ok",
1180        ProbeStatus::Failed => "failed",
1181    }
1182}
1183
1184/// What plausibly follows a status.
1185fn 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    /// The complete `rk.devshell-sync/1` shape, held by snapshot.
1211    #[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    /// The complete `rk.devshell-clean/1` shape, held by snapshot.
1270    #[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    /// The complete `rk.devshell-add/1` shape, held by snapshot, the
1322    /// fragment carrying every field the agent contract names.
1323    #[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    /// The complete `rk.devshell-status/1` shape, held by snapshot.
1378    #[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}