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