release_kit/devshell/
pin.rs1pub const PIN_PREFIX: &str = "github:gubasso/release-kit/";
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Pin {
20 pub line: usize,
22 pub tag: String,
24 pub start: usize,
26 pub end: usize,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Scan {
33 None,
35 Unpinned(usize),
38 One(Pin),
40 Many(usize),
42}
43
44#[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#[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
84const QUOTE: char = '\u{22}';
87
88enum Match {
90 Pinned {
91 tag: String,
92 start: usize,
93 end: usize,
94 },
95 Unpinned,
96}
97
98fn 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}