Skip to main content

release_kit/commands/
self_depend.rs

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