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    use super::{PIN_PREFIX, Pin, Scan, rewrite, scan};
135
136    fn one(text: &str) -> Pin {
137        match scan(text) {
138            Scan::One(pin) => pin,
139            other => panic!("expected one pin, found {other:?}"),
140        }
141    }
142
143    #[test]
144    fn the_pin_matcher_is_anchored_at_both_ends() {
145        let pinned = format!("  url = \"{PIN_PREFIX}v0.2.16\";\n");
146        assert_eq!(one(&pinned).tag, "v0.2.16");
147        let commented = format!("  # url = \"{PIN_PREFIX}v0.2.16\";\n");
148        assert_eq!(scan(&commented), Scan::None, "a comment line is not a pin");
149        let follows = "  inputs.nixpkgs.follows = \"nixpkgs\";\n";
150        assert_eq!(scan(follows), Scan::None, "a follows line is not a pin");
151        let subdir = format!("  url = \"{PIN_PREFIX}v1/subdir\";\n");
152        assert_eq!(
153            scan(&subdir),
154            Scan::None,
155            "a subdirectory reference is not a pin"
156        );
157        let longer_owner = format!("  url = \"github:other-{}v0.2.16\";\n", &PIN_PREFIX[7..]);
158        assert_eq!(
159            scan(&longer_owner),
160            Scan::None,
161            "a longer owner is not a pin"
162        );
163        let prose = format!("  description = \"see {PIN_PREFIX}v0.2.16\";\n");
164        assert_eq!(scan(&prose), Scan::None, "a URL inside prose is not a pin");
165        let trailing = format!("  url = \"{PIN_PREFIX}v0.2.16\"; # the version\n");
166        assert_eq!(
167            one(&trailing).tag,
168            "v0.2.16",
169            "a trailing comment is allowed"
170        );
171        let no_semicolon = format!("  url = \"{PIN_PREFIX}v0.2.16\"\n");
172        assert_eq!(
173            scan(&no_semicolon),
174            Scan::None,
175            "the back anchor is the semicolon"
176        );
177    }
178
179    #[test]
180    fn an_unpinned_url_is_reported_not_rewritten() {
181        let text = format!(
182            "inputs = {{\n  release-kit.url = \"x\";\n  url = \"{}\";\n}}\n",
183            PIN_PREFIX.trim_end_matches('/')
184        );
185        assert_eq!(scan(&text), Scan::Unpinned(3));
186    }
187
188    #[test]
189    fn the_rewrite_changes_only_the_tag_substring() {
190        let text = format!("{{\r\n\turl =\t\"{PIN_PREFIX}v0.2.15\";   # keep\r\n}}\r\n");
191        let pin = one(&text);
192        let rewritten = rewrite(&text, &pin, "v0.2.16");
193        assert_eq!(rewritten, text.replace("v0.2.15", "v0.2.16"));
194        assert_eq!(one(&rewritten).tag, "v0.2.16");
195        assert_eq!(pin.line, 2);
196    }
197
198    #[test]
199    fn two_pin_lines_count_as_two() {
200        let text = format!("url = \"{PIN_PREFIX}v1.0.0\";\nurl = \"{PIN_PREFIX}v2.0.0\";\n");
201        assert_eq!(scan(&text), Scan::Many(2));
202        let mixed = format!(
203            "url = \"{PIN_PREFIX}v1.0.0\";\nurl = \"{}\";\n",
204            PIN_PREFIX.trim_end_matches('/')
205        );
206        assert_eq!(
207            scan(&mixed),
208            Scan::Many(2),
209            "an unpinned line beside a pin is ambiguity"
210        );
211    }
212}