Skip to main content

release_kit/depend/
fragments.rs

1//! The texts `rk depend add` serves: one fragment set per manager, and
2//! the seed file for a manager the target has no file for.
3//!
4//! Every text is an authored file under `blocks/`, per
5//! `distribution:a-human-faced-artifact-is-authored-text`; each carries
6//! `RK_DEP_*` tokens rendered with a plain replace, never a format, so a
7//! `${system}` interpolation survives untouched. A fragment describes
8//! where it goes in a form a coding agent can apply with no parser: a
9//! closed placement vocabulary, a literal anchor substring, and a
10//! three-state `present` from the lexical observation.
11
12use serde::Serialize;
13
14use crate::embedded::BLOCKS;
15
16/// Every depend block, by name.
17pub const BLOCK_NAMES: [&str; 12] = [
18    "depend-flake-input.nix.in",
19    "depend-flake-outputs-arg.nix.in",
20    "depend-flake-package.nix.in",
21    "depend-seed-flake.nix.in",
22    "depend-mise-cargo.toml.in",
23    "depend-mise-ubi.toml.in",
24    "depend-mise-pipx.toml.in",
25    "depend-mise-npm.toml.in",
26    "depend-seed-mise.toml.in",
27    "depend-asdf-line.in",
28    "depend-devbox-flake.json.in",
29    "depend-seed-devbox.json.in",
30];
31
32/// The values a block's tokens render to. A token whose value is absent
33/// is left in place; the matrix selects no block that needs it.
34#[derive(Debug, Clone, Default)]
35pub struct Tokens {
36    /// `RK_DEP_NAME`: the package name.
37    pub name: String,
38    /// `RK_DEP_INPUT`: the flake input name, a Nix identifier derived
39    /// from the package name.
40    pub input: String,
41    /// `RK_DEP_VERSION`: the bare version.
42    pub version: String,
43    /// `RK_DEP_TAG`: the release tag.
44    pub tag: String,
45    /// `RK_DEP_OWNER_REPO`: the forge path.
46    pub owner_repo: Option<String>,
47    /// `RK_DEP_BIN`: the executable a prebuilt archive carries.
48    pub bin: Option<String>,
49    /// `RK_DEP_FLAKE_REF`: the flake reference at the tag.
50    pub flake_ref: Option<String>,
51    /// `RK_DEP_TOOL_LINE`: one rendered mise line, for the mise seed.
52    pub tool_line: Option<String>,
53}
54
55/// One fragment: what to add, where, and whether it is already there.
56#[derive(Debug, Clone, Serialize)]
57pub struct Fragment {
58    /// The stable name: `flake-input`, `outputs-argument`,
59    /// `devshell-package`, `mise-tool`, `asdf-line`, or `devbox-package`.
60    pub id: &'static str,
61    /// The file it goes into, relative to the target.
62    pub file: String,
63    /// What it is for, one phrase.
64    pub role: &'static str,
65    /// How it goes in: `insert-into-attrset`, `add-to-function-head`,
66    /// `append-to-list`, `insert-into-table`, `append-to-array`, or
67    /// `append-line`.
68    pub placement: &'static str,
69    /// Where it goes in the file.
70    pub anchor: Anchor,
71    /// The text to add, rendered.
72    pub text: String,
73    /// Whether the file already carries it; omitted where the file could
74    /// not be judged.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub present: Option<bool>,
77}
78
79/// Where a fragment goes.
80#[derive(Debug, Clone, Serialize)]
81pub struct Anchor {
82    /// `attrset`, `function-head`, `list`, `table`, `array`, or `file`.
83    pub kind: &'static str,
84    /// The attribute path, table, key, or file, as a reader names it.
85    pub path: String,
86    /// A literal substring that locates the anchor, where the observation
87    /// found one; never a pattern.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub needle: Option<&'static str>,
90}
91
92/// The flake reference for a forge host and path at a tag, where the
93/// host has a flake reference type: `github:` and `gitlab:`.
94#[must_use]
95pub fn flake_ref(host: Option<&str>, owner_repo: Option<&str>, tag: &str) -> Option<String> {
96    let owner_repo = owner_repo?;
97    let scheme = match host? {
98        "github.com" => "github",
99        "gitlab.com" => "gitlab",
100        _ => return None,
101    };
102    Some(format!("{scheme}:{owner_repo}/{tag}"))
103}
104
105/// The Nix keywords an identifier may not be.
106const NIX_KEYWORDS: [&str; 10] = [
107    "assert", "else", "if", "in", "inherit", "let", "or", "rec", "then", "with",
108];
109
110/// A flake input name for a package name: a Nix identifier, derived
111/// deterministically.
112///
113/// Every character outside `[A-Za-z0-9_-]` becomes `-`, runs collapse,
114/// a name that cannot start an identifier takes the `dep-` prefix, and
115/// a keyword takes the `-input` suffix.
116#[must_use]
117pub fn nix_input_name(name: &str) -> String {
118    let mut out = String::new();
119    for c in name.chars() {
120        if c.is_ascii_alphanumeric() || matches!(c, '_' | '-') {
121            out.push(c);
122        } else if !out.ends_with('-') {
123            out.push('-');
124        }
125    }
126    let trimmed = out.trim_matches('-');
127    let mut out = if trimmed.is_empty() {
128        "dep".to_owned()
129    } else {
130        trimmed.to_owned()
131    };
132    if !out.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
133        out = format!("dep-{out}");
134    }
135    if NIX_KEYWORDS.contains(&out.as_str()) {
136        out.push_str("-input");
137    }
138    out
139}
140
141/// Render every token whose value is known, with a plain replace.
142#[must_use]
143pub fn render(text: &str, tokens: &Tokens) -> String {
144    let mut out = text
145        .replace("RK_DEP_INPUT", &tokens.input)
146        .replace("RK_DEP_NAME", &tokens.name)
147        .replace("RK_DEP_VERSION", &tokens.version)
148        .replace("RK_DEP_TAG", &tokens.tag);
149    for (token, value) in [
150        ("RK_DEP_OWNER_REPO", &tokens.owner_repo),
151        ("RK_DEP_BIN", &tokens.bin),
152        ("RK_DEP_FLAKE_REF", &tokens.flake_ref),
153        ("RK_DEP_TOOL_LINE", &tokens.tool_line),
154    ] {
155        if let Some(value) = value {
156            out = out.replace(token, value);
157        }
158    }
159    out
160}
161
162/// One rendered fragment: no final newline, since a reader places it.
163#[must_use]
164pub fn fragment(name: &str, tokens: &Tokens) -> String {
165    render(block(name), tokens)
166        .trim_end_matches('\n')
167        .to_owned()
168}
169
170/// One rendered seed file, its final newline kept.
171#[must_use]
172pub fn seed(name: &str, tokens: &Tokens) -> String {
173    render(block(name), tokens)
174}
175
176/// One authored block, by name; the payload is compiled in, so a missing
177/// name is a build defect the tests catch, never a runtime path.
178#[must_use]
179pub fn block(name: &str) -> &'static str {
180    BLOCKS
181        .get_file(name)
182        .and_then(|file| file.contents_utf8())
183        .unwrap_or_default()
184}
185
186/// The declaration of the flake input named `input`, in any of its
187/// forms — `name = { … };`, `inputs.name.url = "…";`, a compact or a
188/// quoted binding — as the text of its value.
189#[must_use]
190pub fn input_binding<'a>(text: &'a str, input: &str) -> Option<&'a str> {
191    super::nix::input_declaration(text, input)
192}
193
194/// The first needle the text holds, as the literal a reader can search.
195#[must_use]
196pub fn first_found(text: &str, needles: &[&'static str]) -> Option<&'static str> {
197    needles.iter().copied().find(|needle| text.contains(needle))
198}
199
200/// Whether a flake's outputs function head names `input`.
201///
202/// `Some(true)` where it does, `Some(false)` where the head is an
203/// explicit set that lacks it, and `None` where the head binds its
204/// inputs another way — an ellipsis or an `@` pattern — or no head was
205/// found at all.
206#[must_use]
207pub fn outputs_argument_present(text: &str, input: &str) -> Option<bool> {
208    let start = text.find("outputs")?;
209    let rest = &text[start + "outputs".len()..];
210    let head = &rest[..rest.find(':')?];
211    if head.contains(input) {
212        return Some(true);
213    }
214    if head.contains("...") || head.contains('@') || !head.contains('{') {
215        return None;
216    }
217    Some(false)
218}
219
220#[cfg(test)]
221mod tests {
222    #![allow(clippy::expect_used)]
223
224    use super::{
225        BLOCK_NAMES, Tokens, block, flake_ref, fragment, nix_input_name, outputs_argument_present,
226        render, seed,
227    };
228
229    fn full() -> Tokens {
230        Tokens {
231            name: "sample-tool".into(),
232            input: "sample-tool".into(),
233            version: "1.4.0".into(),
234            tag: "v1.4.0".into(),
235            owner_repo: Some("acme/sample-tool".into()),
236            bin: Some("sam".into()),
237            flake_ref: Some("github:acme/sample-tool/v1.4.0".into()),
238            tool_line: Some("\"cargo:sample-tool\" = \"1.4.0\"".into()),
239        }
240    }
241
242    /// SATISFIES dependencies:a-fragment-names-no-project-of-its-own
243    #[test]
244    fn every_depend_block_renders_all_its_tokens() {
245        let tokens = full();
246        for name in BLOCK_NAMES {
247            let authored = block(name);
248            assert!(!authored.is_empty(), "{name}: the block is authored");
249            assert!(authored.ends_with('\n'), "{name}: one final newline");
250            let rendered = render(authored, &tokens);
251            assert!(!rendered.contains("RK_DEP_"), "{name}: every token renders");
252        }
253        assert_eq!(
254            fragment("depend-flake-package.nix.in", &tokens),
255            "sample-tool.packages.${system}.default"
256        );
257        assert_eq!(
258            fragment("depend-mise-ubi.toml.in", &tokens),
259            "\"ubi:acme/sample-tool\" = { version = \"1.4.0\", exe = \"sam\" }"
260        );
261        assert_eq!(
262            fragment("depend-devbox-flake.json.in", &tokens),
263            "\"github:acme/sample-tool/v1.4.0#default\""
264        );
265    }
266
267    #[test]
268    fn a_seed_keeps_its_final_newline_and_a_fragment_drops_it() {
269        let tokens = full();
270        let flake = seed("depend-seed-flake.nix.in", &tokens);
271        assert!(flake.ends_with("}\n"));
272        assert!(flake.contains("${system}"), "the interpolation survives");
273        assert!(flake.contains("github:acme/sample-tool/v1.4.0"));
274        let mise = seed("depend-seed-mise.toml.in", &tokens);
275        assert_eq!(mise, "[tools]\n\"cargo:sample-tool\" = \"1.4.0\"\n");
276        assert!(!fragment("depend-asdf-line.in", &tokens).ends_with('\n'));
277        assert_eq!(
278            fragment("depend-asdf-line.in", &tokens),
279            "sample-tool 1.4.0"
280        );
281    }
282
283    #[test]
284    fn the_flake_ref_carries_no_owner_of_its_own() {
285        assert_eq!(
286            flake_ref(Some("github.com"), Some("acme/sample-tool"), "v1.4.0").as_deref(),
287            Some("github:acme/sample-tool/v1.4.0")
288        );
289        assert_eq!(
290            flake_ref(Some("gitlab.com"), Some("group/sample"), "1.0.0").as_deref(),
291            Some("gitlab:group/sample/1.0.0")
292        );
293        assert_eq!(flake_ref(Some("codeberg.org"), Some("a/b"), "v1"), None);
294        assert_eq!(flake_ref(Some("github.com"), None, "v1"), None);
295        let without_ref = Tokens {
296            flake_ref: None,
297            ..full()
298        };
299        assert!(
300            render(block("depend-flake-input.nix.in"), &without_ref).contains("RK_DEP_FLAKE_REF"),
301            "an unknown value is left as its token, never invented"
302        );
303    }
304
305    #[test]
306    fn a_package_name_becomes_a_nix_identifier() {
307        assert_eq!(nix_input_name("sample-tool"), "sample-tool");
308        assert_eq!(nix_input_name("@acme/tool"), "acme-tool");
309        assert_eq!(nix_input_name("my.tool"), "my-tool");
310        assert_eq!(nix_input_name("7zip"), "dep-7zip");
311        assert_eq!(nix_input_name("with"), "with-input");
312        assert_eq!(nix_input_name("@@"), "dep");
313        let scoped = Tokens {
314            name: "@acme/tool".into(),
315            input: nix_input_name("@acme/tool"),
316            ..full()
317        };
318        let rendered = fragment("depend-flake-input.nix.in", &scoped);
319        assert!(rendered.starts_with("acme-tool = {"), "{rendered}");
320        assert!(!rendered.contains('@'));
321    }
322
323    #[test]
324    fn an_input_binding_is_read_to_its_close() {
325        use super::input_binding;
326        let text = "inputs = {\n  acme-tool = {\n    url = \"github:other/thing/v1\";\n  };\n  nixpkgs.url = \"x\";\n};";
327        let body = input_binding(text, "acme-tool").expect("a binding");
328        assert!(body.contains("github:other/thing/v1"));
329        assert!(!body.contains("nixpkgs"));
330        assert!(input_binding(text, "nixpkgs").is_some_and(|v| v.contains("\"x\"")));
331        assert!(
332            input_binding(
333                "inputs.acme-tool.url = \"github:other/thing/v1\";",
334                "acme-tool"
335            )
336            .is_some_and(|v| v.contains("github:other/thing/v1")),
337            "the dotted form is a declaration too"
338        );
339        assert_eq!(
340            input_binding("packages = [ acme-tool ];", "acme-tool"),
341            None
342        );
343    }
344
345    #[test]
346    fn the_outputs_head_is_judged_lexically() {
347        assert_eq!(
348            outputs_argument_present(
349                "outputs = { self, nixpkgs, sample-tool }: {}",
350                "sample-tool"
351            ),
352            Some(true)
353        );
354        assert_eq!(
355            outputs_argument_present("outputs =\n    { self, nixpkgs }:\n    {}", "sample-tool"),
356            Some(false)
357        );
358        assert_eq!(
359            outputs_argument_present("outputs = { self, ... }: {}", "sample-tool"),
360            None
361        );
362        assert_eq!(
363            outputs_argument_present("{ inputs = {}; }", "sample-tool"),
364            None
365        );
366    }
367}