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