Skip to main content

release_kit/commands/
self_depend.rs

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