Skip to main content

release_kit/
self_depend.rs

1//! `rk self-depend`: release-kit as a consumer project's development
2//! dependency, kept fresh.
3//!
4//! A consumer obtains `rk` through whatever tool manager it already
5//! runs, and `manager` owns that axis: the closed list, the detection,
6//! and the per-manager pin reader. The flake manager carries two facts:
7//! the tag in `flake.nix` is the version, and the `release-kit` node in
8//! `flake.lock` is the content. This module owns the offline observation
9//! across every manager and the per-checkout state key; `pin` owns the
10//! flake line grammar, `fragments` the authored texts `add` serves,
11//! `leftovers` the predecessor catalog `clean` removes, `discover` the
12//! one network call, `txn` the fenced two-file transaction, and `guard`
13//! the gates around it.
14
15pub mod discover;
16pub mod fragments;
17pub mod guard;
18pub mod leftovers;
19pub mod manager;
20pub mod pin;
21pub mod txn;
22
23use std::path::PathBuf;
24
25use camino::{Utf8Path, Utf8PathBuf};
26use serde::Serialize;
27
28use crate::diagnostic::{Diagnostic, Reason};
29use crate::digest::Digest;
30use crate::error::RkError;
31
32/// Whether a file exists at its expected path.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
34#[serde(rename_all = "kebab-case")]
35pub enum Presence {
36    /// The path holds a file, a symlink included.
37    Present,
38    /// Nothing is at the path.
39    Absent,
40}
41
42impl Presence {
43    /// Judge a path by `symlink_metadata`, so a dangling symlink still
44    /// counts as present: the verb would refuse to write over it.
45    #[must_use]
46    pub fn of(path: &Utf8Path) -> Self {
47        if std::fs::symlink_metadata(path).is_ok() {
48            Self::Present
49        } else {
50            Self::Absent
51        }
52    }
53
54    /// Whether the file is there.
55    #[must_use]
56    pub const fn is_present(self) -> bool {
57        matches!(self, Self::Present)
58    }
59}
60
61/// Everything the offline pass reads from a target and this host's state
62/// root. It spawns nothing and fetches nothing.
63#[derive(Debug, Clone)]
64pub struct Observed {
65    /// The target, canonical.
66    pub target: Utf8PathBuf,
67    /// Whether `flake.nix` exists.
68    pub flake: Presence,
69    /// Whether `flake.lock` exists.
70    pub lock: Presence,
71    /// What the pin matcher found in `flake.nix`.
72    pub scan: pin::Scan,
73    /// The `flake.nix` text, where the file read.
74    pub flake_text: Option<String>,
75    /// The locked commit of the `release-kit` node, where the lock names one.
76    pub locked_rev: Option<String>,
77    /// The locked ref of the `release-kit` node, where the lock names one.
78    pub locked_ref: Option<String>,
79    /// One entry per manager, in the closed order, absent ones included.
80    pub managers: Vec<manager::Entry>,
81    /// The one manager whose file names release-kit, where exactly one does.
82    pub wired: Option<manager::Manager>,
83    /// Whether `.envrc` exists. Direnv is not a manager: it loads a
84    /// shell and pins nothing, so the file is reported outside the list.
85    pub envrc: Presence,
86    /// Whether `.envrc` carries the sync line.
87    pub envrc_sync: bool,
88    /// Whether a transaction marker for this checkout survives.
89    pub pending: bool,
90    /// The day of the last sync attempt for this checkout, where stamped.
91    pub stamp: Option<String>,
92    /// What a predecessor bump mechanism left in the target.
93    pub leftovers: Vec<leftovers::Leftover>,
94}
95
96impl Observed {
97    /// The per-checkout state key.
98    #[must_use]
99    pub fn key(&self) -> String {
100        state_key(&self.target)
101    }
102
103    /// The pinned tag, where the scan found exactly one pin.
104    #[must_use]
105    pub fn pin_tag(&self) -> Option<&str> {
106        match &self.scan {
107            pin::Scan::One(pin) => Some(pin.tag.as_str()),
108            _ => None,
109        }
110    }
111
112    /// The rollup state, first match wins. It describes and never
113    /// judges: every state exits 0, and the verbs that act read the
114    /// entries rather than this word.
115    #[must_use]
116    pub fn state(&self) -> &'static str {
117        if self.pending {
118            return "pending-recovery";
119        }
120        if self
121            .managers
122            .iter()
123            .all(|entry| !entry.present.is_present())
124        {
125            return "no-manager";
126        }
127        let named: Vec<&manager::Entry> = self
128            .managers
129            .iter()
130            .filter(|entry| entry.read.names())
131            .collect();
132        let entry = match named.as_slice() {
133            [] => return "not-wired",
134            [one] => *one,
135            _ => return "ambiguous-pin",
136        };
137        match entry.read {
138            manager::PinRead::Many { .. } => return "ambiguous-pin",
139            manager::PinRead::Unpinned { .. } => return "unpinned",
140            manager::PinRead::Absent | manager::PinRead::One { .. } => {}
141        }
142        if self.leftovers.is_empty() {
143            "ready"
144        } else {
145            "superseded"
146        }
147    }
148
149    /// The flake manager's entry: always present in the list.
150    #[must_use]
151    pub fn flake_entry(&self) -> Option<&manager::Entry> {
152        self.managers
153            .iter()
154            .find(|entry| entry.manager == manager::Manager::Flake)
155    }
156}
157
158/// Read a target's pin wiring across every manager, offline.
159///
160/// # Errors
161///
162/// Returns [`RkError::Missing`] for a target that is not a directory and
163/// [`RkError::Io`] where a present file does not read.
164pub fn observe(target: &Utf8Path) -> Result<Observed, RkError> {
165    let target = canonical_target(target)?;
166    let files = manager::manager_files(&target, Some(manager::DEP_NAME))?;
167    let mut managers = manager::entries(&files);
168    let flake_text = files
169        .iter()
170        .find(|file| file.manager == manager::Manager::Flake)
171        .map(|file| file.text.clone());
172    // Judged by symlink metadata, so a dangling symlink still counts as
173    // present: the verbs would refuse to write over it.
174    let flake = Presence::of(&target.join("flake.nix"));
175    let scan = flake_text.as_deref().map_or(pin::Scan::None, pin::scan);
176    let lock_path = target.join("flake.lock");
177    let lock = Presence::of(&lock_path);
178    let (locked_rev, locked_ref_name) = if lock.is_present() {
179        locked_node(&std::fs::read(&lock_path)?)
180    } else {
181        (None, None)
182    };
183    if let Some(entry) = managers
184        .iter_mut()
185        .find(|entry| entry.manager == manager::Manager::Flake)
186    {
187        entry.lock = Some(lock);
188        entry.locked_ref.clone_from(&locked_ref_name);
189        entry.locked_rev.clone_from(&locked_rev);
190    }
191    let named: Vec<manager::Manager> = managers
192        .iter()
193        .filter(|entry| entry.read.names())
194        .map(|entry| entry.manager)
195        .collect();
196    let wired = match named.as_slice() {
197        [one] => Some(*one),
198        _ => None,
199    };
200    let envrc_path = target.join(".envrc");
201    let envrc = Presence::of(&envrc_path);
202    let envrc_sync = envrc.is_present() && has_sync_line(&std::fs::read_to_string(&envrc_path)?);
203    let key = state_key(&target);
204    let pending = marker_path(&key).is_some_and(|marker| txn::marker_is_pending(&marker));
205    let stamp = read_stamp(&key);
206    let leftovers = leftovers::scan(&target)?;
207    Ok(Observed {
208        target,
209        flake,
210        lock,
211        scan,
212        flake_text,
213        locked_rev,
214        locked_ref: locked_ref_name,
215        managers,
216        wired,
217        envrc,
218        envrc_sync,
219        pending,
220        stamp,
221        leftovers,
222    })
223}
224
225/// Whether an `.envrc` text carries the sync line: a line whose
226/// trimmed start is the verb, whatever flags follow.
227#[must_use]
228pub fn has_sync_line(text: &str) -> bool {
229    text.lines()
230        .any(|line| line.trim_start().starts_with("rk self-depend sync"))
231}
232
233/// The `release-kit` node's locked commit and ref, from a `flake.lock`.
234fn locked_node(bytes: &[u8]) -> (Option<String>, Option<String>) {
235    let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
236        return (None, None);
237    };
238    let locked = &value["nodes"]["release-kit"]["locked"];
239    let read = |field: &str| locked[field].as_str().map(str::to_owned);
240    (read("rev"), read("ref"))
241}
242
243/// The target as a canonical directory, or the missing-target refusal.
244fn canonical_target(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
245    if !target.is_dir() {
246        return Err(RkError::missing(
247            Diagnostic::new(
248                Reason::TargetNotFound,
249                format!("target {target} is not a directory"),
250            )
251            .expected("an existing project directory to read"),
252        ));
253    }
254    Ok(target.canonicalize_utf8()?)
255}
256
257/// The per-checkout key every state file is named by:
258/// `<basename>-<digest16>` over the canonical path, so two clones never
259/// share a lock, a stamp, or a backup.
260#[must_use]
261pub fn state_key(target: &Utf8Path) -> String {
262    let base = target
263        .file_name()
264        .filter(|name| !name.is_empty())
265        .unwrap_or("root");
266    let digest = Digest::of(target.as_str().as_bytes()).to_string();
267    format!("{base}-{}", &digest[..16])
268}
269
270/// The directory every devshell state file lives under:
271/// `<state root>/devshell`.
272#[must_use]
273pub fn state_dir() -> Option<PathBuf> {
274    crate::applog::state_root().map(|root| root.join("devshell"))
275}
276
277/// The single-writer lock for one checkout.
278#[must_use]
279pub fn lock_path(key: &str) -> Option<PathBuf> {
280    state_dir().map(|dir| dir.join(format!("{key}.lock")))
281}
282
283/// The daily stamp for one checkout.
284#[must_use]
285pub fn stamp_path(key: &str) -> Option<PathBuf> {
286    state_dir().map(|dir| dir.join(format!("{key}.stamp")))
287}
288
289/// The directory a transaction backs the two files up into.
290#[must_use]
291pub fn backup_dir(key: &str) -> Option<PathBuf> {
292    state_dir().map(|dir| dir.join(key).join("backup"))
293}
294
295/// The marker an open transaction leaves until it commits or restores.
296#[must_use]
297pub fn marker_path(key: &str) -> Option<PathBuf> {
298    state_dir().map(|dir| dir.join(key).join("pending.json"))
299}
300
301/// The day the last sync attempt was stamped, where one was.
302#[must_use]
303pub fn read_stamp(key: &str) -> Option<String> {
304    let text = std::fs::read_to_string(stamp_path(key)?).ok()?;
305    let day = text.trim();
306    (day.len() == 10).then(|| day.to_owned())
307}
308
309/// Fold the three tag shapes — `v0.2.16`, `0.2.16`, and the release URL
310/// — to one tag with exactly one leading `v`.
311#[must_use]
312pub fn normalize_tag(raw: &str) -> Option<String> {
313    let trimmed = raw.trim().trim_end_matches('/');
314    let tail = trimmed.rsplit('/').next().unwrap_or(trimmed);
315    let bare = tail.strip_prefix('v').unwrap_or(tail);
316    let shaped = bare.chars().next().is_some_and(|c| c.is_ascii_digit())
317        && bare
318            .chars()
319            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'));
320    shaped.then(|| format!("v{bare}"))
321}
322
323#[cfg(test)]
324mod tests {
325    use camino::Utf8Path;
326
327    use super::{has_sync_line, locked_node, normalize_tag, state_key};
328
329    #[test]
330    fn the_tag_normalizer_folds_three_shapes_to_one() {
331        for raw in [
332            "v0.2.16",
333            "0.2.16",
334            "https://github.com/owner/release-kit/releases/tag/v0.2.16",
335            "https://github.com/owner/release-kit/releases/tag/v0.2.16/",
336            " v0.2.16\n",
337        ] {
338            assert_eq!(normalize_tag(raw).as_deref(), Some("v0.2.16"), "{raw:?}");
339        }
340        assert_eq!(normalize_tag("v0.3.0-rc.1").as_deref(), Some("v0.3.0-rc.1"));
341        assert_eq!(normalize_tag(""), None);
342        assert_eq!(normalize_tag("latest"), None);
343        assert_eq!(normalize_tag("vv0.2.16"), None, "a doubled v is not a tag");
344        assert_eq!(
345            normalize_tag("https://github.com/owner/release-kit/releases/latest"),
346            None
347        );
348    }
349
350    #[test]
351    fn the_state_key_is_stable_per_checkout() {
352        let a = state_key(Utf8Path::new("/srv/one/widget"));
353        let b = state_key(Utf8Path::new("/srv/two/widget"));
354        assert_eq!(a, state_key(Utf8Path::new("/srv/one/widget")));
355        assert_ne!(a, b, "two clones of one project key apart");
356        assert!(a.starts_with("widget-"), "{a}");
357        assert_eq!(a.len(), "widget-".len() + 16);
358        assert!(state_key(Utf8Path::new("/")).starts_with("root-"));
359    }
360
361    #[test]
362    fn the_sync_line_is_found_by_its_verb() {
363        assert!(has_sync_line(
364            "use flake\nrk self-depend sync --apply || true\n"
365        ));
366        assert!(has_sync_line("  rk self-depend sync\n"));
367        assert!(!has_sync_line("# rk self-depend sync\nuse flake\n"));
368        assert!(!has_sync_line(""));
369    }
370
371    #[test]
372    fn the_locked_node_reads_the_release_kit_input() {
373        let lock = br#"{"nodes":{"release-kit":{"locked":{"rev":"9f3c","ref":"refs/tags/v0.2.16"}},"root":{}}}"#;
374        assert_eq!(
375            locked_node(lock),
376            (
377                Some("9f3c".to_owned()),
378                Some("refs/tags/v0.2.16".to_owned())
379            )
380        );
381        assert_eq!(locked_node(b"not json"), (None, None));
382        assert_eq!(locked_node(br#"{"nodes":{}}"#), (None, None));
383    }
384}