release-kit 0.9.9

A canonical release workflow: a technology-agnostic method, per-technology bindings, and the rk CLI that lands and serves them.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! `rk self-depend`: release-kit as a consumer project's development
//! dependency, kept fresh.
//!
//! A consumer obtains `rk` through whatever tool manager it already
//! runs, and `manager` owns that axis: the closed list, the detection,
//! and the per-manager pin reader. The flake manager carries two facts:
//! the tag in `flake.nix` is the version, and the `release-kit` node in
//! `flake.lock` is the content. This module owns the offline observation
//! across every manager and the per-checkout state key; `pin` owns the
//! flake line grammar, `fragments` the authored texts `add` serves,
//! `leftovers` the predecessor catalog `clean` removes, `discover` the
//! one network call, `txn` the fenced two-file transaction, `guard` the
//! gates around it, `venue` where a release is published, and `matrix`
//! which manager and venue pairs render.

pub mod discover;
pub mod fragments;
pub mod guard;
pub mod leftovers;
pub mod manager;
pub mod matrix;
pub mod pin;
pub mod txn;
pub mod venue;

use std::path::PathBuf;

use camino::{Utf8Path, Utf8PathBuf};
use serde::Serialize;

use crate::diagnostic::{Diagnostic, Reason};
use crate::digest::Digest;
use crate::error::RkError;

/// Whether a file exists at its expected path.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Presence {
    /// The path holds a file, a symlink included.
    Present,
    /// Nothing is at the path.
    Absent,
}

impl Presence {
    /// Judge a path by `symlink_metadata`, so a dangling symlink still
    /// counts as present: the verb would refuse to write over it.
    #[must_use]
    pub fn of(path: &Utf8Path) -> Self {
        if std::fs::symlink_metadata(path).is_ok() {
            Self::Present
        } else {
            Self::Absent
        }
    }

    /// Whether the file is there.
    #[must_use]
    pub const fn is_present(self) -> bool {
        matches!(self, Self::Present)
    }
}

/// Everything the offline pass reads from a target and this host's state
/// root. It spawns nothing and fetches nothing.
#[derive(Debug, Clone)]
pub struct Observed {
    /// The target, canonical.
    pub target: Utf8PathBuf,
    /// Whether `flake.nix` exists.
    pub flake: Presence,
    /// Whether `flake.lock` exists.
    pub lock: Presence,
    /// What the pin matcher found in `flake.nix`.
    pub scan: pin::Scan,
    /// The `flake.nix` text, where the file read.
    pub flake_text: Option<String>,
    /// The locked commit of the `release-kit` node, where the lock names one.
    pub locked_rev: Option<String>,
    /// The locked ref of the `release-kit` node, where the lock names one.
    pub locked_ref: Option<String>,
    /// One entry per manager, in the closed order, absent ones included.
    pub managers: Vec<manager::Entry>,
    /// The one manager whose file names release-kit, where exactly one does.
    pub wired: Option<manager::Manager>,
    /// Whether `.envrc` exists. Direnv is not a manager: it loads a
    /// shell and pins nothing, so the file is reported outside the list.
    pub envrc: Presence,
    /// Whether `.envrc` carries the sync line.
    pub envrc_sync: bool,
    /// Whether a transaction marker for this checkout survives.
    pub pending: bool,
    /// The day of the last sync attempt for this checkout, where stamped.
    pub stamp: Option<String>,
    /// What a predecessor bump mechanism left in the target.
    pub leftovers: Vec<leftovers::Leftover>,
    /// The host-install answer the target configuration records, where it
    /// records one.
    pub acquisition: Option<crate::config::Acquisition>,
}

impl Observed {
    /// The per-checkout state key.
    #[must_use]
    pub fn key(&self) -> String {
        state_key(&self.target)
    }

    /// The pinned tag, where the scan found exactly one pin.
    #[must_use]
    pub const fn pin_tag(&self) -> Option<&str> {
        match &self.scan {
            pin::Scan::One(pin) => Some(pin.tag.as_str()),
            _ => None,
        }
    }

    /// The rollup state, first match wins. It describes and never
    /// judges: every state exits 0, and the verbs that act read the
    /// entries rather than this word.
    #[must_use]
    pub fn state(&self) -> &'static str {
        if self.pending {
            return "pending-recovery";
        }
        let named: Vec<&manager::Entry> = self
            .managers
            .iter()
            .filter(|entry| entry.read.names())
            .collect();
        // A recorded host install is a decision, not a gap: no manager names
        // release-kit because the project chose that none does.
        //
        // SATISFIES packaging:a-host-install-reads-as-a-decision
        if named.is_empty() && self.acquisition.is_some() {
            return "host";
        }
        if self
            .managers
            .iter()
            .all(|entry| !entry.present.is_present())
        {
            return "no-manager";
        }
        let entry = match named.as_slice() {
            [] => return "not-wired",
            [one] => *one,
            _ => return "ambiguous-pin",
        };
        match entry.read {
            manager::PinRead::Many { .. } => return "ambiguous-pin",
            manager::PinRead::Unpinned { .. } => return "unpinned",
            manager::PinRead::Absent | manager::PinRead::One { .. } => {}
        }
        if self.leftovers.is_empty() {
            "ready"
        } else {
            "superseded"
        }
    }

    /// The flake manager's entry: always present in the list.
    #[must_use]
    pub fn flake_entry(&self) -> Option<&manager::Entry> {
        self.entry(manager::Manager::Flake)
    }

    /// One manager's entry: every manager in the enum has one.
    #[must_use]
    pub fn entry(&self, manager: manager::Manager) -> Option<&manager::Entry> {
        self.managers.iter().find(|entry| entry.manager == manager)
    }
}

/// Read a target's pin wiring across every manager, offline.
///
/// # Errors
///
/// Returns [`RkError::Missing`] for a target that is not a directory and
/// [`RkError::Io`] where a present file does not read.
pub fn observe(target: &Utf8Path) -> Result<Observed, RkError> {
    let target = canonical_target(target)?;
    let files = manager::manager_files(&target, Some(manager::DEP_NAME))?;
    let mut managers = manager::entries(&files);
    let flake_text = files
        .iter()
        .find(|file| file.manager == manager::Manager::Flake)
        .map(|file| file.text.clone());
    // Judged by symlink metadata, so a dangling symlink still counts as
    // present: the verbs would refuse to write over it.
    let flake = Presence::of(&target.join("flake.nix"));
    let scan = flake_text.as_deref().map_or(pin::Scan::None, pin::scan);
    let lock_path = target.join("flake.lock");
    let lock = Presence::of(&lock_path);
    let (locked_rev, locked_ref_name) = if lock.is_present() {
        locked_node(&std::fs::read(&lock_path)?)
    } else {
        (None, None)
    };
    if let Some(entry) = managers
        .iter_mut()
        .find(|entry| entry.manager == manager::Manager::Flake)
    {
        entry.lock = Some(lock);
        entry.locked_ref.clone_from(&locked_ref_name);
        entry.locked_rev.clone_from(&locked_rev);
    }
    let named: Vec<manager::Manager> = managers
        .iter()
        .filter(|entry| entry.read.names())
        .map(|entry| entry.manager)
        .collect();
    let wired = match named.as_slice() {
        [one] => Some(*one),
        _ => None,
    };
    let envrc_path = target.join(".envrc");
    let envrc = Presence::of(&envrc_path);
    let envrc_sync = envrc.is_present() && has_sync_line(&std::fs::read_to_string(&envrc_path)?);
    let key = state_key(&target);
    let pending = marker_path(&key).is_some_and(|marker| txn::marker_is_pending(&marker));
    let stamp = read_stamp(&key);
    let leftovers = leftovers::scan(&target)?;
    // A configuration this binary cannot read refuses here as it does for
    // every verb that reads one, rather than reading as no answer.
    let acquisition =
        crate::config::load(target.as_std_path())?.and_then(|config| config.acquisition);
    Ok(Observed {
        target,
        flake,
        lock,
        scan,
        flake_text,
        locked_rev,
        locked_ref: locked_ref_name,
        managers,
        wired,
        envrc,
        envrc_sync,
        pending,
        stamp,
        leftovers,
        acquisition,
    })
}

/// Whether an `.envrc` text carries the sync line: a line whose
/// trimmed start is the verb, whatever flags follow.
#[must_use]
pub fn has_sync_line(text: &str) -> bool {
    text.lines()
        .any(|line| line.trim_start().starts_with("rk self-depend sync"))
}

/// The `release-kit` node's locked commit and ref, from a `flake.lock`.
fn locked_node(bytes: &[u8]) -> (Option<String>, Option<String>) {
    let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
        return (None, None);
    };
    let locked = &value["nodes"]["release-kit"]["locked"];
    let read = |field: &str| locked[field].as_str().map(str::to_owned);
    (read("rev"), read("ref"))
}

/// The target as a canonical directory, or the missing-target refusal.
fn canonical_target(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
    if !target.is_dir() {
        return Err(RkError::missing(
            Diagnostic::new(
                Reason::TargetNotFound,
                format!("target {target} is not a directory"),
            )
            .expected("an existing project directory to read"),
        ));
    }
    Ok(target.canonicalize_utf8()?)
}

/// The per-checkout key every state file is named by:
/// `<basename>-<digest16>` over the canonical path, so two clones never
/// share a lock, a stamp, or a backup.
#[must_use]
pub fn state_key(target: &Utf8Path) -> String {
    let base = target
        .file_name()
        .filter(|name| !name.is_empty())
        .unwrap_or("root");
    let digest = Digest::of(target.as_str().as_bytes()).to_string();
    format!("{base}-{}", &digest[..16])
}

/// The directory every devshell state file lives under:
/// `<state root>/devshell`.
#[must_use]
pub fn state_dir() -> Option<PathBuf> {
    crate::applog::state_root().map(|root| root.join("devshell"))
}

/// The single-writer lock for one checkout.
#[must_use]
pub fn lock_path(key: &str) -> Option<PathBuf> {
    state_dir().map(|dir| dir.join(format!("{key}.lock")))
}

/// The daily stamp for one checkout.
#[must_use]
pub fn stamp_path(key: &str) -> Option<PathBuf> {
    state_dir().map(|dir| dir.join(format!("{key}.stamp")))
}

/// The directory a transaction backs the two files up into.
#[must_use]
pub fn backup_dir(key: &str) -> Option<PathBuf> {
    state_dir().map(|dir| dir.join(key).join("backup"))
}

/// The marker an open transaction leaves until it commits or restores.
#[must_use]
pub fn marker_path(key: &str) -> Option<PathBuf> {
    state_dir().map(|dir| dir.join(key).join("pending.json"))
}

/// The day the last sync attempt was stamped, where one was.
#[must_use]
pub fn read_stamp(key: &str) -> Option<String> {
    let text = std::fs::read_to_string(stamp_path(key)?).ok()?;
    let day = text.trim();
    (day.len() == 10).then(|| day.to_owned())
}

/// Fold the three tag shapes — `v0.2.16`, `0.2.16`, and the release URL
/// — to one tag with exactly one leading `v`.
#[must_use]
pub fn normalize_tag(raw: &str) -> Option<String> {
    let trimmed = raw.trim().trim_end_matches('/');
    let tail = trimmed.rsplit('/').next().unwrap_or(trimmed);
    let bare = tail.strip_prefix('v').unwrap_or(tail);
    let shaped = bare.chars().next().is_some_and(|c| c.is_ascii_digit())
        && bare
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'));
    shaped.then(|| format!("v{bare}"))
}

#[cfg(test)]
mod tests {
    use camino::Utf8Path;

    use super::{has_sync_line, locked_node, normalize_tag, state_key};

    #[test]
    fn the_tag_normalizer_folds_three_shapes_to_one() {
        for raw in [
            "v0.2.16",
            "0.2.16",
            "https://github.com/owner/release-kit/releases/tag/v0.2.16",
            "https://github.com/owner/release-kit/releases/tag/v0.2.16/",
            " v0.2.16\n",
        ] {
            assert_eq!(normalize_tag(raw).as_deref(), Some("v0.2.16"), "{raw:?}");
        }
        assert_eq!(normalize_tag("v0.3.0-rc.1").as_deref(), Some("v0.3.0-rc.1"));
        assert_eq!(normalize_tag(""), None);
        assert_eq!(normalize_tag("latest"), None);
        assert_eq!(normalize_tag("vv0.2.16"), None, "a doubled v is not a tag");
        assert_eq!(
            normalize_tag("https://github.com/owner/release-kit/releases/latest"),
            None
        );
    }

    #[test]
    fn the_state_key_is_stable_per_checkout() {
        let a = state_key(Utf8Path::new("/srv/one/widget"));
        let b = state_key(Utf8Path::new("/srv/two/widget"));
        assert_eq!(a, state_key(Utf8Path::new("/srv/one/widget")));
        assert_ne!(a, b, "two clones of one project key apart");
        assert!(a.starts_with("widget-"), "{a}");
        assert_eq!(a.len(), "widget-".len() + 16);
        assert!(state_key(Utf8Path::new("/")).starts_with("root-"));
    }

    #[test]
    fn the_sync_line_is_found_by_its_verb() {
        assert!(has_sync_line(
            "use flake\nrk self-depend sync --apply || true\n"
        ));
        assert!(has_sync_line("  rk self-depend sync\n"));
        assert!(!has_sync_line("# rk self-depend sync\nuse flake\n"));
        assert!(!has_sync_line(""));
    }

    #[test]
    fn the_locked_node_reads_the_release_kit_input() {
        let lock = br#"{"nodes":{"release-kit":{"locked":{"rev":"9f3c","ref":"refs/tags/v0.2.16"}},"root":{}}}"#;
        assert_eq!(
            locked_node(lock),
            (
                Some("9f3c".to_owned()),
                Some("refs/tags/v0.2.16".to_owned())
            )
        );
        assert_eq!(locked_node(b"not json"), (None, None));
        assert_eq!(locked_node(br#"{"nodes":{}}"#), (None, None));
    }
}