Skip to main content

release_kit/devshell/
pin.rs

1//! The pin matcher: the one line in a consumer's `flake.nix` that names
2//! the release-kit tag.
3//!
4//! Pure text in, values out: no file access and no spawning, so every
5//! caller — the offline observation, the preview, the transaction — reads
6//! one grammar. The matcher is anchored at both ends. Without the front
7//! anchor a commented example or a URL inside prose counts as a pin, and
8//! the "exactly one, or refuse" rule then counts the wrong thing; without
9//! the back anchor a subdirectory reference matches. The crate carries no
10//! regex engine, so the matcher is hand-written.
11
12/// The flake-input URL prefix every consumer pin begins with; the tag
13/// follows it. A grammar in the same class as the branch grammar: a
14/// source constant, never a payload text.
15pub const PIN_PREFIX: &str = "github:gubasso/release-kit/";
16
17/// One matched pin line, with the byte range of the tag alone.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Pin {
20    /// The one-based line the pin sits on.
21    pub line: usize,
22    /// The tag between the prefix and the closing quote.
23    pub tag: String,
24    /// The byte offset of the tag's first byte in the scanned text.
25    pub start: usize,
26    /// The byte offset one past the tag's last byte.
27    pub end: usize,
28}
29
30/// What a scan of `flake.nix` found.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Scan {
33    /// No line names the release-kit input.
34    None,
35    /// One line names the input with no tag: a real state, reported and
36    /// never rewritten. Carries the one-based line.
37    Unpinned(usize),
38    /// Exactly one pinned line.
39    One(Pin),
40    /// More than one line matches; the count is what the refusal names.
41    Many(usize),
42}
43
44/// Scan a flake's text for the release-kit input line.
45#[must_use]
46pub fn scan(text: &str) -> Scan {
47    let mut pins = Vec::new();
48    let mut unpinned = None;
49    let mut offset = 0;
50    for (index, line) in text.split_inclusive('\n').enumerate() {
51        match match_line(line) {
52            Some(Match::Pinned { tag, start, end }) => pins.push(Pin {
53                line: index + 1,
54                tag,
55                start: offset + start,
56                end: offset + end,
57            }),
58            Some(Match::Unpinned) if unpinned.is_none() => unpinned = Some(index + 1),
59            Some(Match::Unpinned) | None => {}
60        }
61        offset += line.len();
62    }
63    match (pins.len(), unpinned) {
64        (0, None) => Scan::None,
65        (0, Some(line)) => Scan::Unpinned(line),
66        (1, None) => Scan::One(pins.remove(0)),
67        (n, None) => Scan::Many(n),
68        (n, Some(_)) => Scan::Many(n + 1),
69    }
70}
71
72/// The same text with one pin's tag replaced. Only the tag's bytes
73/// change: indentation, quoting, line endings, and a trailing comment
74/// survive byte for byte.
75#[must_use]
76pub fn rewrite(text: &str, pin: &Pin, tag: &str) -> String {
77    let mut out = String::with_capacity(text.len() + tag.len());
78    out.push_str(&text[..pin.start]);
79    out.push_str(tag);
80    out.push_str(&text[pin.end..]);
81    out
82}
83
84/// The double quote, as a code point: the source scan that keeps whole
85/// artifacts out of the sources reads a quote literal as a string start.
86const QUOTE: char = '\u{22}';
87
88/// One line's classification.
89enum Match {
90    Pinned {
91        tag: String,
92        start: usize,
93        end: usize,
94    },
95    Unpinned,
96}
97
98/// Classify one line. It matches only when every anchor holds after the
99/// leading whitespace: `url`, `=`, a quote, the prefix, a tag holding no
100/// `/`, the closing quote, `;`, and then nothing but an optional comment.
101fn match_line(line: &str) -> Option<Match> {
102    let rest = line.trim_start().strip_prefix("url")?;
103    let rest = rest.trim_start();
104    let rest = rest.strip_prefix('=')?;
105    let rest = rest.trim_start();
106    let rest = rest.strip_prefix(QUOTE)?;
107    let value_start = line.len() - rest.len();
108    let close = rest.find(QUOTE)?;
109    let value = &rest[..close];
110    let after = rest[close + 1..].trim_start();
111    let after = after.strip_prefix(';')?;
112    let after = after.trim_start();
113    if !(after.is_empty() || after.starts_with('#')) {
114        return None;
115    }
116    let bare = PIN_PREFIX.trim_end_matches('/');
117    if value == bare {
118        return Some(Match::Unpinned);
119    }
120    let tag = value.strip_prefix(PIN_PREFIX)?;
121    if tag.is_empty() || tag.contains('/') {
122        return None;
123    }
124    let start = value_start + PIN_PREFIX.len();
125    Some(Match::Pinned {
126        tag: tag.to_owned(),
127        start,
128        end: start + tag.len(),
129    })
130}
131
132#[cfg(test)]
133mod tests {
134    #![allow(clippy::expect_used, clippy::panic)]
135
136    use super::{PIN_PREFIX, Pin, Scan, rewrite, scan};
137
138    fn one(text: &str) -> Pin {
139        match scan(text) {
140            Scan::One(pin) => pin,
141            other => panic!("expected one pin, found {other:?}"),
142        }
143    }
144
145    #[test]
146    fn the_pin_matcher_is_anchored_at_both_ends() {
147        let pinned = format!("  url = \"{PIN_PREFIX}v0.2.16\";\n");
148        assert_eq!(one(&pinned).tag, "v0.2.16");
149        let commented = format!("  # url = \"{PIN_PREFIX}v0.2.16\";\n");
150        assert_eq!(scan(&commented), Scan::None, "a comment line is not a pin");
151        let follows = "  inputs.nixpkgs.follows = \"nixpkgs\";\n";
152        assert_eq!(scan(follows), Scan::None, "a follows line is not a pin");
153        let subdir = format!("  url = \"{PIN_PREFIX}v1/subdir\";\n");
154        assert_eq!(
155            scan(&subdir),
156            Scan::None,
157            "a subdirectory reference is not a pin"
158        );
159        let longer_owner = format!("  url = \"github:other-{}v0.2.16\";\n", &PIN_PREFIX[7..]);
160        assert_eq!(
161            scan(&longer_owner),
162            Scan::None,
163            "a longer owner is not a pin"
164        );
165        let prose = format!("  description = \"see {PIN_PREFIX}v0.2.16\";\n");
166        assert_eq!(scan(&prose), Scan::None, "a URL inside prose is not a pin");
167        let trailing = format!("  url = \"{PIN_PREFIX}v0.2.16\"; # the version\n");
168        assert_eq!(
169            one(&trailing).tag,
170            "v0.2.16",
171            "a trailing comment is allowed"
172        );
173        let no_semicolon = format!("  url = \"{PIN_PREFIX}v0.2.16\"\n");
174        assert_eq!(
175            scan(&no_semicolon),
176            Scan::None,
177            "the back anchor is the semicolon"
178        );
179    }
180
181    #[test]
182    fn an_unpinned_url_is_reported_not_rewritten() {
183        let text = format!(
184            "inputs = {{\n  release-kit.url = \"x\";\n  url = \"{}\";\n}}\n",
185            PIN_PREFIX.trim_end_matches('/')
186        );
187        assert_eq!(scan(&text), Scan::Unpinned(3));
188    }
189
190    #[test]
191    fn the_rewrite_changes_only_the_tag_substring() {
192        let text = format!("{{\r\n\turl =\t\"{PIN_PREFIX}v0.2.15\";   # keep\r\n}}\r\n");
193        let pin = one(&text);
194        let rewritten = rewrite(&text, &pin, "v0.2.16");
195        assert_eq!(rewritten, text.replace("v0.2.15", "v0.2.16"));
196        assert_eq!(one(&rewritten).tag, "v0.2.16");
197        assert_eq!(pin.line, 2);
198    }
199
200    #[test]
201    fn two_pin_lines_count_as_two() {
202        let text = format!("url = \"{PIN_PREFIX}v1.0.0\";\nurl = \"{PIN_PREFIX}v2.0.0\";\n");
203        assert_eq!(scan(&text), Scan::Many(2));
204        let mixed = format!(
205            "url = \"{PIN_PREFIX}v1.0.0\";\nurl = \"{}\";\n",
206            PIN_PREFIX.trim_end_matches('/')
207        );
208        assert_eq!(
209            scan(&mixed),
210            Scan::Many(2),
211            "an unpinned line beside a pin is ambiguity"
212        );
213    }
214}