spec_driven_docs/self_depend/
status.rs1use 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
18pub const SCHEMA: &str = "sdd.self-depend-status/1";
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum Presence {
25 Present,
27 Absent,
29}
30
31impl Presence {
32 const fn of(present: bool) -> Self {
33 if present { Self::Present } else { Self::Absent }
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "kebab-case")]
40pub enum Freshness {
41 Current,
43 Behind,
45 Ahead,
47}
48
49#[derive(Debug, Clone, Serialize)]
51pub struct ManagerEntry {
52 pub manager: Manager,
54 pub present: Presence,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub file: Option<Utf8PathBuf>,
59 pub pinned: bool,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub version: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub venue: Option<Venue>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub pin_lines: Option<usize>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub freshness: Option<Freshness>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub lock: Option<Presence>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub locked_rev: Option<String>,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
83#[serde(rename_all = "kebab-case")]
84pub enum State {
85 Ready,
87 Unwired,
89 LineAbsent,
91 Ambiguous,
93 Leftovers,
95}
96
97#[derive(Debug, Clone, Serialize)]
99pub struct Host {
100 pub nix: bool,
102 pub direnv: bool,
104}
105
106#[derive(Debug, Clone, Serialize)]
108pub struct Report {
109 pub schema: &'static str,
111 pub target: Utf8PathBuf,
113 pub state: State,
115 pub wired: Option<Manager>,
117 pub managers: Vec<ManagerEntry>,
119 pub envrc: Presence,
121 pub envrc_sync: bool,
123 pub stamp: Option<String>,
125 pub off: bool,
127 pub host: Host,
129 pub leftovers: Vec<Leftover>,
131 pub next: Vec<String>,
133}
134
135#[must_use]
138pub fn switched_off() -> bool {
139 variable(CI_VAR).is_some() || variable(SELF_DEPEND_OFF_VAR).is_some()
140}
141
142fn 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#[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}