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