Skip to main content

release_kit/
devshell.rs

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