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