Skip to main content

spec_driven_docs/self_depend/
pin.rs

1//! The pin each manager records, read and rewritten in its own form.
2//!
3//! ```text
4//! flake input      github:<owner>/<repo>/<tag> in flake.nix, locked in flake.lock
5//! mise, registry   "cargo:<crate>" = "<version>"
6//! mise, release    "ubi:<owner>/<repo>" = { version = "<version>", exe = "<binary>" }
7//! asdf             <tool> <version>, one line in .tool-versions
8//! devbox           "github:<owner>/<repo>/<tag>#<output>", one entry in packages
9//! ```
10//!
11//! A registry entry names the crate as the registry knows it, and an archive
12//! entry names the binary inside the archive. The reader below accepts each
13//! form where its manager writes it and nowhere else.
14
15use semver::Version;
16use serde::Serialize;
17
18use crate::release::crates_io::CRATE_NAME;
19use crate::self_depend::manager::Manager;
20use crate::self_depend::venue::Venue;
21use crate::self_depend::{BINARY_NAME, slug};
22
23/// One recorded pin, located in its file.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25pub struct Pin {
26    /// The released triple the pin names.
27    pub version: Version,
28    /// The version as the file spells it, `v` prefix included where present.
29    pub spelled: String,
30    /// The venue the entry's form selects, where the form says.
31    pub venue: Option<Venue>,
32    /// The one-based line the pin sits on.
33    pub line: usize,
34    /// How many lines in the file pin this tool.
35    pub lines: usize,
36    /// The byte span of the spelled version in the file text.
37    #[serde(skip)]
38    pub span: (usize, usize),
39}
40
41/// The pin a manager file records for this tool, where it records one.
42#[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
69/// Where one line spells the version, and which venue the form selects.
70fn 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(&registry) {
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
114/// The span of the first quoted string after `marker` in `line`.
115fn 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
122/// The same file text with the pin moved to `to`, in the spelling the file
123/// already uses.
124///
125/// # Errors
126///
127/// A message where the file no longer records the pin it was read with.
128pub 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/// The revision `flake.lock` holds for this tool's input, where it holds one.
143#[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/// The name of the flake input whose URL names this tool.
163///
164/// The input is found by the URL it names, never by its name: a consumer
165/// calls the input whatever it likes. The name is read from the attribute
166/// that holds the URL, either `name.url = "..."` or `name = { url = ... }`.
167#[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
187/// The identifier that opens `line` before `suffix`, where the line reads
188/// `  <ident><suffix>...` and the identifier is an attribute name.
189fn 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    // `name = {` opens an attribute set; `name = "..."` does not hold a URL.
201    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    /// VERIFIES acquisition:a-fragment-names-the-venue-form
226    #[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, &registry).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}