spec_driven_docs/self_depend/
pin.rs1use semver::Version;
16use serde::Serialize;
17
18use crate::self_depend::manager::Manager;
19use crate::self_depend::registry::CRATE_NAME;
20use crate::self_depend::venue::Venue;
21use crate::self_depend::{BINARY_NAME, slug};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25pub struct Pin {
26 pub version: Version,
28 pub spelled: String,
30 pub venue: Option<Venue>,
32 pub line: usize,
34 pub lines: usize,
36 #[serde(skip)]
38 pub span: (usize, usize),
39}
40
41#[must_use]
43pub fn read(manager: Manager, text: &str) -> Option<Pin> {
44 let mut found: Vec<Pin> = Vec::new();
45 let mut offset = 0;
46 for (index, line) in text.split_inclusive('\n').enumerate() {
47 if let Some((start, end, venue)) = locate(manager, line) {
48 let spelled = line[start..end].to_string();
49 let bare = spelled.strip_prefix('v').unwrap_or(&spelled);
50 if let Ok(version) = bare.parse::<Version>() {
51 found.push(Pin {
52 version,
53 spelled,
54 venue,
55 line: index + 1,
56 lines: 0,
57 span: (offset + start, offset + end),
58 });
59 }
60 }
61 offset += line.len();
62 }
63 let lines = found.len();
64 let mut first = found.into_iter().next()?;
65 first.lines = lines;
66 Some(first)
67}
68
69fn locate(manager: Manager, line: &str) -> Option<(usize, usize, Option<Venue>)> {
71 match manager {
72 Manager::Flake => {
73 let needle = format!("github:{}/", slug());
74 let start = line.find(&needle)? + needle.len();
75 let end = start
76 + line[start..]
77 .find(['"', '#', '?', '\''])
78 .unwrap_or(line.len() - start);
79 (end > start).then_some((start, end, Some(Venue::Flake)))
80 }
81 Manager::Devbox => {
82 let needle = format!("github:{}/", slug());
83 let start = line.find(&needle)? + needle.len();
84 let end = start + line[start..].find(['#', '"']).unwrap_or(line.len() - start);
85 (end > start).then_some((start, end, Some(Venue::Flake)))
86 }
87 Manager::Mise => {
88 let trimmed = line.trim_start();
89 let registry = format!("\"cargo:{CRATE_NAME}\"");
90 let archive = format!("\"ubi:{}\"", slug());
91 if trimmed.starts_with(®istry) {
92 let (start, end) = quoted_after(line, "=")?;
93 return Some((start, end, Some(Venue::Crates)));
94 }
95 if trimmed.starts_with(&archive) {
96 let (start, end) = quoted_after(line, "version")?;
97 return Some((start, end, Some(Venue::GithubRelease)));
98 }
99 None
100 }
101 Manager::Asdf => {
102 let mut parts = line.split_whitespace();
103 let tool = parts.next()?;
104 if tool != CRATE_NAME && tool != BINARY_NAME {
105 return None;
106 }
107 let version = parts.next()?;
108 let start = line.find(version)?;
109 Some((start, start + version.len(), None))
110 }
111 }
112}
113
114fn quoted_after(line: &str, marker: &str) -> Option<(usize, usize)> {
116 let at = line.find(marker)? + marker.len();
117 let open = at + line[at..].find('"')? + 1;
118 let close = open + line[open..].find('"')?;
119 Some((open, close))
120}
121
122pub fn rewrite(manager: Manager, text: &str, to: &Version) -> Result<(String, Pin), String> {
129 let held = read(manager, text).ok_or_else(|| "the file no longer records a pin".to_string())?;
130 let spelled = if held.spelled.starts_with('v') {
131 format!("v{to}")
132 } else {
133 to.to_string()
134 };
135 let mut moved = String::with_capacity(text.len());
136 moved.push_str(&text[..held.span.0]);
137 moved.push_str(&spelled);
138 moved.push_str(&text[held.span.1..]);
139 Ok((moved, held))
140}
141
142#[must_use]
144pub fn locked_rev(lock: &str) -> Option<String> {
145 let value: serde_json::Value = serde_json::from_str(lock).ok()?;
146 let (owner, repo) = crate::self_depend::coordinates();
147 let nodes = value.get("nodes")?.as_object()?;
148 nodes.values().find_map(|node| {
149 let original = node.get("original")?;
150 let same = |key: &str, want: &str| {
151 original
152 .get(key)
153 .and_then(serde_json::Value::as_str)
154 .is_some_and(|held| held.eq_ignore_ascii_case(want))
155 };
156 (same("owner", owner) && same("repo", repo))
157 .then(|| node.get("locked")?.get("rev")?.as_str().map(str::to_string))
158 .flatten()
159 })
160}
161
162#[must_use]
168pub fn input_name(flake: &str) -> Option<String> {
169 let needle = format!("github:{}/", slug());
170 let lines: Vec<&str> = flake.lines().collect();
171 let at = lines.iter().position(|line| line.contains(&needle))?;
172 if let Some(name) = attribute_before(lines[at], ".url") {
173 return Some(name);
174 }
175 lines[..at]
176 .iter()
177 .rev()
178 .find_map(|line| attribute_before(line, " ="))
179 .or_else(|| {
180 lines[..at]
181 .iter()
182 .rev()
183 .find_map(|line| attribute_before(line, "="))
184 })
185}
186
187fn attribute_before(line: &str, suffix: &str) -> Option<String> {
190 let trimmed = line.trim_start();
191 let end = trimmed.find(suffix)?;
192 let name = &trimmed[..end];
193 let is_ident = !name.is_empty()
194 && name
195 .bytes()
196 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
197 if !is_ident || name == "url" || name == "inputs" {
198 return None;
199 }
200 if suffix != ".url"
202 && !trimmed[end..]
203 .trim_start_matches(suffix)
204 .trim_start()
205 .starts_with('{')
206 {
207 return None;
208 }
209 Some(name.to_string())
210}
211
212#[cfg(test)]
213mod tests {
214 #![allow(clippy::unwrap_used, reason = "a test panics as its failure signal")]
215
216 use super::*;
217
218 fn flake() -> String {
219 format!(
220 "{{\n inputs = {{\n nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n sdd = {{\n url = \"github:{}/v0.10.1\";\n inputs.nixpkgs.follows = \"nixpkgs\";\n }};\n }};\n}}\n",
221 slug()
222 )
223 }
224
225 #[test]
227 fn a_flake_pin_reads_and_rewrites_in_place() {
228 let text = flake();
229 let pin = read(Manager::Flake, &text).unwrap();
230 assert_eq!(pin.version.to_string(), "0.10.1");
231 assert_eq!(pin.spelled, "v0.10.1");
232 assert_eq!(pin.venue, Some(Venue::Flake));
233 assert_eq!(pin.line, 5);
234 assert_eq!(pin.lines, 1);
235 let (moved, _) = rewrite(Manager::Flake, &text, &"0.10.2".parse().unwrap()).unwrap();
236 assert!(moved.contains(&format!("github:{}/v0.10.2\"", slug())));
237 assert_eq!(moved.len(), text.len());
238 assert_eq!(input_name(&text).as_deref(), Some("sdd"));
239 }
240
241 #[test]
242 fn a_dotted_flake_input_names_itself_on_the_url_line() {
243 let text = format!(
244 "{{ inputs = {{\n tool.url = \"github:{}/v0.9.0\";\n }}; }}\n",
245 slug()
246 );
247 assert_eq!(input_name(&text).as_deref(), Some("tool"));
248 }
249
250 #[test]
251 fn a_mise_pin_reads_both_forms() {
252 let registry = format!("[tools]\n\"cargo:{CRATE_NAME}\" = \"0.10.1\"\n");
253 let pin = read(Manager::Mise, ®istry).unwrap();
254 assert_eq!(pin.venue, Some(Venue::Crates));
255 assert_eq!(pin.spelled, "0.10.1");
256 let archive = format!(
257 "[tools]\n\"ubi:{}\" = {{ version = \"0.10.1\", exe = \"{BINARY_NAME}\" }}\n",
258 slug()
259 );
260 let pin = read(Manager::Mise, &archive).unwrap();
261 assert_eq!(pin.venue, Some(Venue::GithubRelease));
262 let (moved, _) = rewrite(Manager::Mise, &archive, &"0.11.0".parse().unwrap()).unwrap();
263 assert!(moved.contains("version = \"0.11.0\""));
264 }
265
266 #[test]
267 fn an_asdf_pin_reads_the_second_token() {
268 let text = format!("nodejs 20.0.0\n{CRATE_NAME} 0.10.1\n");
269 let pin = read(Manager::Asdf, &text).unwrap();
270 assert_eq!(pin.version.to_string(), "0.10.1");
271 assert_eq!(pin.venue, None);
272 assert!(read(Manager::Asdf, "nodejs 20.0.0\n").is_none());
273 }
274
275 #[test]
276 fn a_devbox_pin_reads_up_to_the_output() {
277 let text = format!(
278 "{{ \"packages\": [\"github:{}/v0.10.1#default\"] }}\n",
279 slug()
280 );
281 let pin = read(Manager::Devbox, &text).unwrap();
282 assert_eq!(pin.spelled, "v0.10.1");
283 let (moved, _) = rewrite(Manager::Devbox, &text, &"0.10.2".parse().unwrap()).unwrap();
284 assert!(moved.contains("v0.10.2#default"));
285 }
286
287 #[test]
288 fn the_lock_revision_is_found_by_the_repository_it_names() {
289 let (owner, repo) = crate::self_depend::coordinates();
290 let lock = format!(
291 "{{\"nodes\":{{\"root\":{{\"inputs\":{{\"sdd\":\"sdd\"}}}},\"sdd\":{{\"locked\":{{\"rev\":\"abc123\"}},\"original\":{{\"owner\":\"{owner}\",\"repo\":\"{repo}\",\"type\":\"github\"}}}}}},\"version\":7}}"
292 );
293 assert_eq!(locked_rev(&lock).as_deref(), Some("abc123"));
294 assert_eq!(locked_rev("{\"nodes\":{}}"), None);
295 }
296}