Skip to main content

release_kit/self_depend/
manager.rs

1//! The manager axis: which tool manager a target obtains `rk` through.
2//!
3//! One enum owns the list, and one detection reads which manager files
4//! a target carries. `rk depend` reads the same files to land another
5//! project, so both verbs share this module and a fifth manager is one
6//! variant here plus its rows in each matrix. The per-manager pin reader
7//! is pure text in, values out: it names whether a file mentions
8//! release-kit, the version it pins where the manager records one, and
9//! nothing it cannot read offline.
10
11use camino::Utf8Path;
12use clap::ValueEnum;
13use serde::Serialize;
14
15use super::Presence;
16use super::discover::version_order;
17use super::pin;
18use crate::error::RkError;
19
20/// A tool manager a project declares its development tools through.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ValueEnum)]
22#[serde(rename_all = "kebab-case")]
23#[value(rename_all = "kebab-case")]
24pub enum Manager {
25    /// A Nix flake: an input pinned at a tag and its package in the devshell.
26    Flake,
27    /// mise: one `[tools]` entry in its configuration file.
28    Mise,
29    /// asdf: one line in `.tool-versions`.
30    Asdf,
31    /// devbox: one entry in the `packages` array of `devbox.json`.
32    Devbox,
33}
34
35/// The mise configuration paths, in mise's precedence order, the
36/// highest first: a file, or a `conf.d` directory whose `*.toml` entries
37/// all load. A local override file is left out: it is not committed.
38pub const MISE_FILES: [&str; 9] = [
39    "mise.toml",
40    ".mise.toml",
41    "mise/config.toml",
42    "mise/conf.d",
43    ".mise/config.toml",
44    ".mise/conf.d",
45    ".config/mise.toml",
46    ".config/mise/config.toml",
47    ".config/mise/conf.d",
48];
49
50impl Manager {
51    /// The wire form.
52    #[must_use]
53    pub const fn as_str(self) -> &'static str {
54        match self {
55            Self::Flake => "flake",
56            Self::Mise => "mise",
57            Self::Asdf => "asdf",
58            Self::Devbox => "devbox",
59        }
60    }
61
62    /// Every manager, in the order the reports list them.
63    pub const ALL: [Self; 4] = [Self::Flake, Self::Mise, Self::Asdf, Self::Devbox];
64
65    /// The default file a manager is seeded into when absent.
66    #[must_use]
67    pub const fn default_file(self) -> &'static str {
68        match self {
69            Self::Flake => "flake.nix",
70            Self::Mise => "mise.toml",
71            Self::Asdf => ".tool-versions",
72            Self::Devbox => "devbox.json",
73        }
74    }
75
76    /// The paths a manager declares itself at, in precedence order.
77    #[must_use]
78    pub const fn candidates(self) -> &'static [&'static str] {
79        match self {
80            Self::Flake => &["flake.nix"],
81            Self::Mise => &MISE_FILES,
82            Self::Asdf => &[".tool-versions"],
83            Self::Devbox => &["devbox.json"],
84        }
85    }
86}
87
88/// One manager and the file that declares it.
89#[derive(Debug, Clone, Serialize)]
90pub struct ManagerFile {
91    /// The manager.
92    pub manager: Manager,
93    /// The file, relative to the target.
94    pub file: String,
95    /// Its text.
96    #[serde(skip)]
97    pub text: String,
98}
99
100/// The manager files present, one per manager, in the closed order.
101///
102/// For mise, the first path in precedence wins, and inside a `conf.d`
103/// directory the file that already names `dep_name` wins over the first
104/// in name order.
105///
106/// # Errors
107///
108/// Returns [`RkError::Io`] where a present file does not read.
109pub fn manager_files(dir: &Utf8Path, dep_name: Option<&str>) -> Result<Vec<ManagerFile>, RkError> {
110    let mut out = Vec::new();
111    for manager in Manager::ALL {
112        for candidate in manager.candidates() {
113            let Some(file) = first_config(dir, candidate, dep_name)? else {
114                continue;
115            };
116            out.push(ManagerFile {
117                manager,
118                text: std::fs::read_to_string(dir.join(&file))?,
119                file,
120            });
121            break;
122        }
123    }
124    Ok(out)
125}
126
127/// The configuration file a candidate path resolves to: the file
128/// itself, or, under a `conf.d` directory, the first `*.toml` in name
129/// order that already names the dependency, else the first in name order.
130fn first_config(
131    dir: &Utf8Path,
132    candidate: &str,
133    dep_name: Option<&str>,
134) -> Result<Option<String>, RkError> {
135    let path = dir.join(candidate);
136    if path.is_file() {
137        return Ok(Some(candidate.to_owned()));
138    }
139    if !candidate.ends_with("conf.d") || !path.is_dir() {
140        return Ok(None);
141    }
142    let mut names: Vec<String> = std::fs::read_dir(&path)?
143        .filter_map(Result::ok)
144        .filter(|entry| entry.path().is_file())
145        .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "toml"))
146        .filter_map(|entry| entry.file_name().into_string().ok())
147        .collect();
148    names.sort();
149    if let Some(name) = dep_name {
150        for file in &names {
151            if first_mention(&std::fs::read_to_string(path.join(file))?, name).is_some() {
152                return Ok(Some(format!("{candidate}/{file}")));
153            }
154        }
155    }
156    Ok(names.first().map(|name| format!("{candidate}/{name}")))
157}
158
159/// The first line naming `name` as a word, 1-based.
160#[must_use]
161pub fn first_mention(text: &str, name: &str) -> Option<usize> {
162    text.lines()
163        .position(|line| mentions(line, name))
164        .map(|index| index + 1)
165}
166
167/// Whether a line names `name` as a whole word.
168fn mentions(line: &str, name: &str) -> bool {
169    let boundary = |c: Option<char>| {
170        c.is_none_or(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
171    };
172    line.match_indices(name).any(|(index, _)| {
173        boundary(line[..index].chars().next_back())
174            && boundary(line[index + name.len()..].chars().next())
175    })
176}
177
178/// The name every manager file is read for.
179pub const DEP_NAME: &str = "release-kit";
180
181/// What one manager's file says about the release-kit pin.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum PinRead {
184    /// No line names release-kit.
185    Absent,
186    /// One line names it with no version: a real state, reported and
187    /// never rewritten.
188    Unpinned {
189        /// The one-based line.
190        line: usize,
191    },
192    /// Exactly one line pins it.
193    One {
194        /// The one-based line.
195        line: usize,
196        /// The version as the manager records it: a `v` tag for the
197        /// flake and devbox, a bare version for mise and asdf.
198        version: String,
199    },
200    /// More than one line names it; the count is what a refusal names.
201    Many {
202        /// How many lines.
203        count: usize,
204    },
205}
206
207impl PinRead {
208    /// The closed `pin` vocabulary.
209    #[must_use]
210    pub const fn word(&self) -> &'static str {
211        match self {
212            Self::Absent => "absent",
213            Self::Unpinned { .. } => "unpinned",
214            Self::One { .. } => "pinned",
215            Self::Many { .. } => "ambiguous",
216        }
217    }
218
219    /// Whether any line names release-kit.
220    #[must_use]
221    pub const fn names(&self) -> bool {
222        !matches!(self, Self::Absent)
223    }
224
225    /// The version, where exactly one line pins it.
226    #[must_use]
227    pub fn version(&self) -> Option<&str> {
228        match self {
229            Self::One { version, .. } => Some(version),
230            _ => None,
231        }
232    }
233
234    /// How many lines name release-kit, where any does.
235    #[must_use]
236    pub const fn lines(&self) -> Option<usize> {
237        match self {
238            Self::Absent => None,
239            Self::Unpinned { .. } | Self::One { .. } => Some(1),
240            Self::Many { count } => Some(*count),
241        }
242    }
243
244    /// Fold a list of `(line, version)` mentions to one read.
245    fn fold(mentions: &[(usize, Option<String>)]) -> Self {
246        match mentions {
247            [] => Self::Absent,
248            [(line, None)] => Self::Unpinned { line: *line },
249            [(line, Some(version))] => Self::One {
250                line: *line,
251                version: version.clone(),
252            },
253            many => Self::Many { count: many.len() },
254        }
255    }
256}
257
258/// Read one manager's text for the release-kit pin.
259#[must_use]
260pub fn read_pin(manager: Manager, text: &str) -> PinRead {
261    match manager {
262        Manager::Flake => match pin::scan(text) {
263            pin::Scan::None => PinRead::Absent,
264            pin::Scan::Unpinned(line) => PinRead::Unpinned { line },
265            pin::Scan::One(pin) => PinRead::One {
266                line: pin.line,
267                version: pin.tag,
268            },
269            pin::Scan::Many(count) => PinRead::Many { count },
270        },
271        Manager::Mise => PinRead::fold(&mise_mentions(text)),
272        Manager::Asdf => PinRead::fold(&asdf_mentions(text)),
273        Manager::Devbox => PinRead::fold(&devbox_mentions(text)),
274    }
275}
276
277/// The double quote, as a code point: the source scan that keeps whole
278/// artifacts out of the sources reads a quote literal as a string start.
279const QUOTE: char = '\u{22}';
280
281/// Every double-quoted value in a text, in order.
282fn quoted_values(text: &str) -> Vec<&str> {
283    let mut out = Vec::new();
284    let mut rest = text;
285    while let Some(start) = rest.find(QUOTE) {
286        let body = &rest[start + 1..];
287        let Some(end) = body.find(QUOTE) else {
288            break;
289        };
290        out.push(&body[..end]);
291        rest = &body[end + 1..];
292    }
293    out
294}
295
296/// The first double-quoted value in a text.
297fn quoted(text: &str) -> Option<&str> {
298    quoted_values(text).into_iter().next()
299}
300
301/// A mise `[tools]` entry whose key names release-kit, in any backend:
302/// `"cargo:release-kit"`, `"ubi:gubasso/release-kit"`, or the bare name.
303/// The version is the quoted value, or the `version` field of a table.
304fn mise_mentions(text: &str) -> Vec<(usize, Option<String>)> {
305    let mut out = Vec::new();
306    let mut in_tools = false;
307    for (index, raw) in text.lines().enumerate() {
308        let line = raw.trim();
309        if line.starts_with('[') {
310            in_tools = line == "[tools]";
311            continue;
312        }
313        if !in_tools || line.starts_with('#') {
314            continue;
315        }
316        let Some((key, value)) = line.split_once('=') else {
317            continue;
318        };
319        let key = key.trim().trim_matches(QUOTE).trim_matches('\'');
320        let named = key == DEP_NAME
321            || key.ends_with(&format!(":{DEP_NAME}"))
322            || key.ends_with(&format!("/{DEP_NAME}"));
323        if !named {
324            continue;
325        }
326        let value = value.trim();
327        let version = if value.starts_with('{') {
328            value
329                .split_once("version")
330                .and_then(|(_, rest)| quoted(rest))
331                .map(str::to_owned)
332        } else {
333            quoted(value).map(str::to_owned)
334        };
335        let version = version.filter(|v| !v.is_empty() && v != "latest");
336        out.push((index + 1, version));
337    }
338    out
339}
340
341/// An asdf line whose first word is `release-kit`; the second word is
342/// the version.
343fn asdf_mentions(text: &str) -> Vec<(usize, Option<String>)> {
344    text.lines()
345        .enumerate()
346        .filter_map(|(index, line)| {
347            let mut words = line.split('#').next().unwrap_or("").split_whitespace();
348            (words.next() == Some(DEP_NAME)).then(|| {
349                let version = words
350                    .next()
351                    .filter(|word| *word != "latest")
352                    .map(str::to_owned);
353                (index + 1, version)
354            })
355        })
356        .collect()
357}
358
359/// A devbox package entry naming release-kit: a flake reference
360/// `github:gubasso/release-kit/<tag>#default` or a name `release-kit@<version>`
361/// in the `packages` array, or a `release-kit` key in the `packages` object
362/// whose value is the version.
363fn devbox_mentions(text: &str) -> Vec<(usize, Option<String>)> {
364    let mut out = Vec::new();
365    for (index, raw) in text.lines().enumerate() {
366        let values = quoted_values(raw.trim());
367        let Some(at) = values.iter().position(|value| mentions(value, DEP_NAME)) else {
368            continue;
369        };
370        let value = values[at];
371        let version = if let Some((_, rest)) = value.split_once(&format!("{DEP_NAME}/")) {
372            rest.split('#').next().filter(|v| !v.is_empty())
373        } else if let Some((_, rest)) = value.split_once(&format!("{DEP_NAME}@")) {
374            (!rest.is_empty()).then_some(rest)
375        } else if value == DEP_NAME {
376            values.get(at + 1).copied()
377        } else {
378            None
379        };
380        let version = version.filter(|v| *v != "latest").map(str::to_owned);
381        out.push((index + 1, version));
382    }
383    out
384}
385
386/// The offline freshness of a pinned version against this binary's own:
387/// a fact the report carries, never a judgment.
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
389#[serde(rename_all = "kebab-case")]
390pub enum Freshness {
391    /// The pin is older than this binary.
392    Behind,
393    /// The pin names this binary's version.
394    Current,
395    /// The pin is newer than this binary.
396    Ahead,
397}
398
399impl Freshness {
400    /// Compare a pinned version with this binary's.
401    #[must_use]
402    pub fn of(version: &str) -> Self {
403        match version_order(version, env!("CARGO_PKG_VERSION")) {
404            std::cmp::Ordering::Less => Self::Behind,
405            std::cmp::Ordering::Equal => Self::Current,
406            std::cmp::Ordering::Greater => Self::Ahead,
407        }
408    }
409}
410
411/// One manager's entry in the status report: what its file says.
412#[derive(Debug, Clone, Serialize)]
413pub struct Entry {
414    /// The manager.
415    pub manager: Manager,
416    /// Whether the manager's file exists.
417    pub present: Presence,
418    /// The file, relative to the target, where present.
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub file: Option<String>,
421    /// `pinned`, `unpinned`, `absent`, or `ambiguous`.
422    pub pin: &'static str,
423    /// The pinned version as the manager records it, where exactly one
424    /// line pins it.
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub version: Option<String>,
427    /// How many lines name release-kit, where any does.
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub pin_lines: Option<usize>,
430    /// The pin against this binary's version, where one is pinned.
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub freshness: Option<Freshness>,
433    /// Whether `flake.lock` exists; the flake manager alone.
434    #[serde(skip_serializing_if = "Option::is_none")]
435    pub lock: Option<Presence>,
436    /// The locked ref of the input, where the lock names one.
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub locked_ref: Option<String>,
439    /// The locked commit of the input, where the lock names one.
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub locked_rev: Option<String>,
442    /// What the file says, kept for the verbs that act on it.
443    #[serde(skip)]
444    pub read: PinRead,
445    /// The file's text, kept for the fragments judged against it.
446    #[serde(skip)]
447    pub text: Option<String>,
448}
449
450impl Entry {
451    /// An entry for a manager whose file is absent.
452    #[must_use]
453    pub const fn absent(manager: Manager) -> Self {
454        Self {
455            manager,
456            present: Presence::Absent,
457            file: None,
458            pin: "absent",
459            version: None,
460            pin_lines: None,
461            freshness: None,
462            lock: None,
463            locked_ref: None,
464            locked_rev: None,
465            read: PinRead::Absent,
466            text: None,
467        }
468    }
469
470    /// An entry read from a present file.
471    #[must_use]
472    pub fn read(file: &ManagerFile) -> Self {
473        let read = read_pin(file.manager, &file.text);
474        Self {
475            manager: file.manager,
476            present: Presence::Present,
477            file: Some(file.file.clone()),
478            pin: read.word(),
479            version: read.version().map(str::to_owned),
480            pin_lines: read.lines(),
481            freshness: read.version().map(Freshness::of),
482            lock: None,
483            locked_ref: None,
484            locked_rev: None,
485            read,
486            text: Some(file.text.clone()),
487        }
488    }
489}
490
491impl Manager {
492    /// Whether the manager records the `v` tag rather than the bare
493    /// version: the flake and devbox pin a flake reference at a tag, mise
494    /// and asdf record the version a registry names.
495    #[must_use]
496    pub const fn records_tag(self) -> bool {
497        matches!(self, Self::Flake | Self::Devbox)
498    }
499
500    /// The version as this manager records it, from a tag with or
501    /// without its `v`.
502    #[must_use]
503    pub fn recorded(self, tag: &str) -> String {
504        let bare = tag.strip_prefix('v').unwrap_or(tag);
505        if self.records_tag() {
506            format!("v{bare}")
507        } else {
508            bare.to_owned()
509        }
510    }
511}
512
513/// The same text with one line's pinned version replaced: the first
514/// occurrence of `from` after the release-kit mention on that line. Only
515/// those bytes change; every other byte of the file survives.
516#[must_use]
517pub fn rewrite_line(text: &str, line: usize, from: &str, to: &str) -> String {
518    let mut out = String::with_capacity(text.len() + to.len());
519    for (index, raw) in text.split_inclusive('\n').enumerate() {
520        if index + 1 != line {
521            out.push_str(raw);
522            continue;
523        }
524        let mention = raw.find(DEP_NAME).map_or(0, |at| at + DEP_NAME.len());
525        match raw[mention..].find(from) {
526            Some(at) => {
527                let start = mention + at;
528                out.push_str(&raw[..start]);
529                out.push_str(to);
530                out.push_str(&raw[start + from.len()..]);
531            }
532            None => out.push_str(raw),
533        }
534    }
535    out
536}
537
538/// One entry per manager, in `ALL` order, absent ones included: an
539/// absent manager is a fact, never a fault.
540#[must_use]
541pub fn entries(files: &[ManagerFile]) -> Vec<Entry> {
542    Manager::ALL
543        .into_iter()
544        .map(|manager| {
545            files
546                .iter()
547                .find(|file| file.manager == manager)
548                .map_or_else(|| Entry::absent(manager), Entry::read)
549        })
550        .collect()
551}
552
553#[cfg(test)]
554mod tests {
555    use super::{Freshness, MISE_FILES, Manager, PinRead, first_mention, manager_files, read_pin};
556
557    #[test]
558    fn the_first_mise_file_in_precedence_wins() {
559        let dir = tempfile::tempdir().expect("a scratch dir");
560        let root = camino::Utf8Path::from_path(dir.path()).expect("utf-8");
561        std::fs::create_dir_all(root.join(".config/mise")).expect("mkdir");
562        std::fs::write(root.join(".config/mise/config.toml"), "[tools]\n").expect("writes");
563        std::fs::write(root.join(".mise.toml"), "[tools]\nnode = '24'\n").expect("writes");
564        std::fs::write(root.join("devbox.json"), "{}\n").expect("writes");
565        let files = manager_files(root, None).expect("reads");
566        let names: Vec<(Manager, &str)> =
567            files.iter().map(|m| (m.manager, m.file.as_str())).collect();
568        assert_eq!(
569            names,
570            [
571                (Manager::Mise, ".mise.toml"),
572                (Manager::Devbox, "devbox.json")
573            ]
574        );
575        assert_eq!(MISE_FILES[0], "mise.toml");
576        std::fs::remove_file(root.join(".mise.toml")).expect("removes");
577        std::fs::remove_file(root.join(".config/mise/config.toml")).expect("removes");
578        std::fs::create_dir_all(root.join(".mise/conf.d")).expect("mkdir");
579        std::fs::write(root.join(".mise/conf.d/tools.toml"), "[tools]\n").expect("writes");
580        std::fs::write(root.join(".mise/conf.d/env.toml"), "[env]\n").expect("writes");
581        std::fs::write(root.join(".mise/conf.d/README"), "").expect("writes");
582        let files = manager_files(root, None).expect("reads");
583        assert_eq!(
584            files[0].file, ".mise/conf.d/env.toml",
585            "a conf.d directory is mise ownership, its first toml in name order"
586        );
587        std::fs::write(
588            root.join(".mise/conf.d/tools.toml"),
589            "[tools]\n\"cargo:sample-tool\" = \"1.0.0\"\n",
590        )
591        .expect("writes");
592        let files = manager_files(root, Some("sample-tool")).expect("reads");
593        assert_eq!(
594            files[0].file, ".mise/conf.d/tools.toml",
595            "the file that already names the dependency is the destination"
596        );
597    }
598
599    #[test]
600    fn a_mention_is_found_by_line() {
601        let text = "[tools]\nnode = '24'\n\"cargo:sample-tool\" = \"1.4.0\"\n";
602        assert_eq!(first_mention(text, "sample-tool"), Some(3));
603        assert_eq!(
604            first_mention(text, "sample"),
605            None,
606            "a prefix is not a name"
607        );
608        assert_eq!(first_mention("", "sample-tool"), None);
609    }
610
611    #[test]
612    fn every_manager_has_its_files_and_the_enum_is_closed() {
613        for manager in Manager::ALL {
614            assert_eq!(manager.candidates()[0], manager.default_file());
615        }
616        assert_eq!(Manager::ALL.len(), 4);
617    }
618
619    #[test]
620    fn the_mise_reader_names_the_tool_in_any_backend() {
621        let text = "[env]\nFOO = \"release-kit\"\n[tools]\nnode = \"24\"\n\"cargo:release-kit\" = \"0.3.18\"\n";
622        assert_eq!(
623            read_pin(Manager::Mise, text),
624            PinRead::One {
625                line: 5,
626                version: "0.3.18".to_owned()
627            }
628        );
629        let table =
630            "[tools]\n\"ubi:gubasso/release-kit\" = { version = \"0.3.17\", exe = \"rk\" }\n";
631        assert_eq!(read_pin(Manager::Mise, table).version(), Some("0.3.17"));
632        assert_eq!(
633            read_pin(Manager::Mise, "[tools]\nrelease-kit = \"latest\"\n"),
634            PinRead::Unpinned { line: 2 }
635        );
636        assert_eq!(
637            read_pin(
638                Manager::Mise,
639                "[tools]\n\"cargo:release-kit\" = \"0.3.18\"\n\"ubi:gubasso/release-kit\" = \"0.3.18\"\n"
640            ),
641            PinRead::Many { count: 2 }
642        );
643        assert_eq!(
644            read_pin(Manager::Mise, "[tools]\nnode = \"24\"\n"),
645            PinRead::Absent
646        );
647        assert_eq!(
648            read_pin(
649                Manager::Mise,
650                "[tools]\n\"cargo:release-kit-extra\" = \"1\"\n"
651            ),
652            PinRead::Absent,
653            "a longer name is not this one"
654        );
655    }
656
657    #[test]
658    fn the_asdf_reader_takes_the_second_word() {
659        assert_eq!(
660            read_pin(Manager::Asdf, "nodejs 24.0.0\nrelease-kit 0.3.18 # rk\n"),
661            PinRead::One {
662                line: 2,
663                version: "0.3.18".to_owned()
664            }
665        );
666        assert_eq!(
667            read_pin(Manager::Asdf, "release-kit\n"),
668            PinRead::Unpinned { line: 1 }
669        );
670        assert_eq!(
671            read_pin(Manager::Asdf, "# release-kit 1\n"),
672            PinRead::Absent
673        );
674    }
675
676    #[test]
677    fn the_devbox_reader_takes_the_flake_tag_or_the_version_suffix() {
678        let reference =
679            "{\n  \"packages\": [\n    \"github:gubasso/release-kit/v0.3.18#default\"\n  ]\n}\n";
680        assert_eq!(
681            read_pin(Manager::Devbox, reference),
682            PinRead::One {
683                line: 3,
684                version: "v0.3.18".to_owned()
685            }
686        );
687        assert_eq!(
688            read_pin(Manager::Devbox, "{\"packages\": [\"release-kit@0.3.18\"]}"),
689            PinRead::One {
690                line: 1,
691                version: "0.3.18".to_owned()
692            }
693        );
694        assert_eq!(
695            read_pin(
696                Manager::Devbox,
697                "{\"packages\": {\"release-kit\": \"0.3.18\"}}"
698            )
699            .version(),
700            Some("0.3.18")
701        );
702        assert_eq!(
703            read_pin(Manager::Devbox, "{\"packages\": [\"release-kit@latest\"]}"),
704            PinRead::Unpinned { line: 1 }
705        );
706        assert_eq!(
707            read_pin(Manager::Devbox, "{\"packages\": [\"nodejs@24\"]}"),
708            PinRead::Absent
709        );
710    }
711
712    #[test]
713    fn the_flake_reader_is_the_pin_matcher() {
714        let flake = "{\n  url = \"github:gubasso/release-kit/v0.2.16\";\n}\n";
715        assert_eq!(
716            read_pin(Manager::Flake, flake),
717            PinRead::One {
718                line: 2,
719                version: "v0.2.16".to_owned()
720            }
721        );
722    }
723
724    #[test]
725    fn a_one_fact_line_is_rewritten_in_place() {
726        let mise = "[tools]\nnode = \"24\"\n\"cargo:release-kit\" = \"0.2.15\" # 0.2.15 was fine\n";
727        assert_eq!(
728            super::rewrite_line(mise, 3, "0.2.15", "0.2.16"),
729            "[tools]\nnode = \"24\"\n\"cargo:release-kit\" = \"0.2.16\" # 0.2.15 was fine\n",
730            "only the first occurrence after the name moves"
731        );
732        let devbox =
733            "{\r\n  \"packages\": [\"github:gubasso/release-kit/v0.2.15#default\"]\r\n}\r\n";
734        assert_eq!(
735            super::rewrite_line(devbox, 2, "v0.2.15", "v0.2.16"),
736            devbox.replace("v0.2.15", "v0.2.16")
737        );
738        assert_eq!(
739            super::rewrite_line(mise, 9, "0.2.15", "0.2.16"),
740            mise,
741            "a line the file lacks changes nothing"
742        );
743        assert_eq!(Manager::Mise.recorded("v0.2.16"), "0.2.16");
744        assert_eq!(Manager::Devbox.recorded("0.2.16"), "v0.2.16");
745        assert!(Manager::Flake.records_tag());
746        assert!(!Manager::Asdf.records_tag());
747    }
748
749    #[test]
750    fn freshness_compares_with_the_binary() {
751        assert_eq!(Freshness::of("v0.0.1"), Freshness::Behind);
752        assert_eq!(Freshness::of(env!("CARGO_PKG_VERSION")), Freshness::Current);
753        assert_eq!(
754            Freshness::of(&format!("v{}", env!("CARGO_PKG_VERSION"))),
755            Freshness::Current
756        );
757        assert_eq!(Freshness::of("v999.0.0"), Freshness::Ahead);
758    }
759}