Skip to main content

spec_driven_docs/self_depend/
status.rs

1//! The offline status report: what a target carries, and nothing judged.
2//!
3//! A report is not a verdict. Every state it can describe exits 0, and the
4//! `next` lines say what an operator does about each.
5
6use camino::{Utf8Path, Utf8PathBuf};
7use semver::Version;
8use serde::Serialize;
9
10use crate::domain::paths::{CI_VAR, SELF_DEPEND_OFF_VAR, UserEnv, variable};
11use crate::self_depend::leftovers::{self, Leftover};
12use crate::self_depend::manager::{self, Detected, Manager};
13use crate::self_depend::pin;
14use crate::self_depend::stamp;
15use crate::self_depend::venue::Venue;
16use crate::self_depend::{ENVRC, SYNC_LINE};
17
18/// The machine schema `sdd self-depend status --json` declares.
19pub const SCHEMA: &str = "sdd.self-depend-status/1";
20
21/// Whether a file is on disk.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum Presence {
25    /// The file exists.
26    Present,
27    /// The file does not exist.
28    Absent,
29}
30
31impl Presence {
32    const fn of(present: bool) -> Self {
33        if present { Self::Present } else { Self::Absent }
34    }
35}
36
37/// How a recorded pin compares with the release this binary is.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "kebab-case")]
40pub enum Freshness {
41    /// The pin names this binary's release.
42    Current,
43    /// The pin names an older release.
44    Behind,
45    /// The pin names a newer release than this binary.
46    Ahead,
47}
48
49/// One manager's row.
50#[derive(Debug, Clone, Serialize)]
51pub struct ManagerEntry {
52    /// Which manager.
53    pub manager: Manager,
54    /// Whether the target carries its file.
55    pub present: Presence,
56    /// The file, where present.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub file: Option<Utf8PathBuf>,
59    /// Whether the file names this tool.
60    pub pinned: bool,
61    /// The version pinned, as the file spells it.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub version: Option<String>,
64    /// The venue the entry's form selects.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub venue: Option<Venue>,
67    /// How many lines pin this tool.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub pin_lines: Option<usize>,
70    /// The pin against this binary's release.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub freshness: Option<Freshness>,
73    /// Whether the manager's lock is on disk, where the manager keeps one.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub lock: Option<Presence>,
76    /// The revision the lock holds for this tool.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub locked_rev: Option<String>,
79}
80
81/// What the report concludes about the wiring.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
83#[serde(rename_all = "kebab-case")]
84pub enum State {
85    /// One manager pins this tool, the sync line is landed, nothing is left over.
86    Ready,
87    /// No manager file names this tool.
88    Unwired,
89    /// A manager pins this tool and the shell loader carries no sync line.
90    LineAbsent,
91    /// More than one manager names this tool.
92    Ambiguous,
93    /// A predecessor mechanism left a file behind.
94    Leftovers,
95}
96
97/// What the host offers.
98#[derive(Debug, Clone, Serialize)]
99pub struct Host {
100    /// Whether `nix` is on `PATH`.
101    pub nix: bool,
102    /// Whether `direnv` is on `PATH`.
103    pub direnv: bool,
104}
105
106/// The whole report.
107#[derive(Debug, Clone, Serialize)]
108pub struct Report {
109    /// The machine schema of this object.
110    pub schema: &'static str,
111    /// The target read.
112    pub target: Utf8PathBuf,
113    /// What the wiring amounts to.
114    pub state: State,
115    /// The one manager that pins this tool, where exactly one does.
116    pub wired: Option<Manager>,
117    /// Every manager, in the enum's order, absent ones included.
118    pub managers: Vec<ManagerEntry>,
119    /// Whether the shell loader file exists.
120    pub envrc: Presence,
121    /// Whether the shell loader carries the sync line.
122    pub envrc_sync: bool,
123    /// The day of the last attempt for this checkout, where one is stamped.
124    pub stamp: Option<String>,
125    /// Whether the loop is switched off in this environment.
126    pub off: bool,
127    /// What the host offers.
128    pub host: Host,
129    /// What a predecessor mechanism left behind.
130    pub leftovers: Vec<Leftover>,
131    /// What an operator does next, one line each.
132    pub next: Vec<String>,
133}
134
135/// Whether the loop is switched off: in continuous integration, or by the
136/// operator's variable.
137#[must_use]
138pub fn switched_off() -> bool {
139    variable(CI_VAR).is_some() || variable(SELF_DEPEND_OFF_VAR).is_some()
140}
141
142/// Whether one program is on `PATH`.
143fn on_path(program: &str) -> bool {
144    std::env::var_os("PATH")
145        .is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(program).is_file()))
146}
147
148/// The report for one target, offline.
149///
150/// SATISFIES acquisition:status-reports-and-never-judges
151#[must_use]
152pub fn report(target: &Utf8Path, env: &UserEnv, this: &Version) -> Report {
153    let detected = manager::detect(target);
154    let managers = detected
155        .iter()
156        .map(|held| entry(target, held, this))
157        .collect();
158    let wired = manager::wired(&detected);
159    let envrc_text = std::fs::read_to_string(target.join(ENVRC)).ok();
160    let envrc_sync = envrc_text
161        .as_deref()
162        .is_some_and(|text| text.contains(SYNC_LINE));
163    let leftovers = leftovers::find(target, &[]);
164    let stamp = env
165        .state_root()
166        .map(|root| stamp::path(&root.path, target))
167        .and_then(|path| stamp::read(&path))
168        .map(|day| day.to_string());
169    let state = match (wired.len(), envrc_sync, leftovers.is_empty()) {
170        (0, _, _) => State::Unwired,
171        (1, true, true) => State::Ready,
172        (1, true, false) => State::Leftovers,
173        (1, false, _) => State::LineAbsent,
174        (_, _, _) => State::Ambiguous,
175    };
176    let next = next_lines(state, &wired, target);
177    Report {
178        schema: SCHEMA,
179        target: target.to_owned(),
180        state,
181        wired: (wired.len() == 1).then(|| wired[0].manager),
182        managers,
183        envrc: Presence::of(envrc_text.is_some()),
184        envrc_sync,
185        stamp,
186        off: switched_off(),
187        host: Host {
188            nix: on_path("nix"),
189            direnv: on_path("direnv"),
190        },
191        leftovers,
192        next,
193    }
194}
195
196fn entry(target: &Utf8Path, held: &Detected, this: &Version) -> ManagerEntry {
197    let lock = held.manager.lock_file().map(|lock| {
198        let path = target.join(lock);
199        (
200            Presence::of(path.is_file()),
201            std::fs::read_to_string(&path)
202                .ok()
203                .and_then(|text| pin::locked_rev(&text)),
204        )
205    });
206    ManagerEntry {
207        manager: held.manager,
208        present: Presence::of(held.file.is_some()),
209        file: held.file.clone(),
210        pinned: held.pin.is_some(),
211        version: held.pin.as_ref().map(|pin| pin.spelled.clone()),
212        venue: held.pin.as_ref().and_then(|pin| pin.venue),
213        pin_lines: held.pin.as_ref().map(|pin| pin.lines),
214        freshness: held.pin.as_ref().map(|pin| match pin.version.cmp(this) {
215            std::cmp::Ordering::Less => Freshness::Behind,
216            std::cmp::Ordering::Equal => Freshness::Current,
217            std::cmp::Ordering::Greater => Freshness::Ahead,
218        }),
219        lock: lock.as_ref().map(|(presence, _)| *presence),
220        locked_rev: lock.and_then(|(_, rev)| rev),
221    }
222}
223
224fn next_lines(state: State, wired: &[&Detected], target: &Utf8Path) -> Vec<String> {
225    match state {
226        State::Ready => vec![format!(
227            "sdd self-depend sync --caller operator --target {target} moves the pin by hand"
228        )],
229        State::Unwired => vec![format!(
230            "sdd self-depend add --target {target} serves the fragments for the manager this project runs"
231        )],
232        State::LineAbsent => vec![format!(
233            "add `{SYNC_LINE}` to {ENVRC}; sdd self-depend add --target {target} prints it with its placement"
234        )],
235        State::Ambiguous => {
236            let names: Vec<&str> = wired.iter().map(|held| held.manager.as_str()).collect();
237            vec![format!(
238                "one target runs one mechanism; keep one of {} and remove the others' pins",
239                names.join(", ")
240            )]
241        }
242        State::Leftovers => vec![format!(
243            "sdd self-depend clean --target {target} --apply removes what the predecessor left"
244        )],
245    }
246}