Skip to main content

release_kit/self_depend/
fragments.rs

1//! The texts `rk self-depend add` serves: one fragment set per manager
2//! and venue pair, the `.envrc` line, and the seed file for a manager the
3//! target has no file for.
4//!
5//! Every text is an authored file under `blocks/`, per
6//! `distribution:a-human-faced-artifact-is-authored-text`; each carries
7//! `RK_DEVSHELL_*` tokens rendered with a plain replace, never a format,
8//! so the `${system}` interpolation survives untouched. The fragments
9//! describe where they go in a form a coding agent can apply with no
10//! parser: a closed placement vocabulary, a literal anchor substring, and
11//! a three-state `present` from the lexical observation.
12
13use serde::Serialize;
14
15use super::manager::Manager;
16use super::matrix::Pair;
17use super::pin::PIN_PREFIX;
18use super::venue::Venue;
19use super::{Observed, pin};
20use crate::embedded::BLOCKS;
21
22/// Every self-depend block, by name.
23pub const BLOCK_NAMES: [&str; 12] = [
24    "self-depend-input.nix.in",
25    "self-depend-outputs-arg.nix.in",
26    "self-depend-package.nix.in",
27    "self-depend-seed-flake.nix.in",
28    "self-depend-envrc-line.in",
29    "self-depend-seed-envrc.in",
30    "self-depend-mise-entry.toml.in",
31    "self-depend-mise-ubi.toml.in",
32    "self-depend-mise-seed.toml.in",
33    "self-depend-devbox-entry.json.in",
34    "self-depend-devbox-seed.json.in",
35    "self-depend-asdf-line.in",
36];
37
38/// The token every block carries where the pinned flake URL goes.
39const PIN_TOKEN: &str = "RK_DEVSHELL_PIN";
40/// The token where the bare version goes.
41const VERSION_TOKEN: &str = "RK_DEVSHELL_VERSION";
42/// The token where the forge path goes.
43const REPO_TOKEN: &str = "RK_DEVSHELL_REPO";
44/// The token where one rendered mise line goes, in the mise seed.
45const TOOL_LINE_TOKEN: &str = "RK_DEVSHELL_TOOL_LINE";
46
47/// One fragment: what to add, where, and whether it is already there.
48#[derive(Debug, Clone, Serialize)]
49pub struct Fragment {
50    /// The stable name: `flake-input`, `outputs-argument`,
51    /// `devshell-package`, `mise-tool`, `devbox-package`, `asdf-line`,
52    /// or `envrc-sync`.
53    pub id: &'static str,
54    /// The file it goes into, relative to the target.
55    pub file: String,
56    /// What it is for, one phrase.
57    pub role: &'static str,
58    /// How it goes in: `insert-into-attrset`, `add-to-function-head`,
59    /// `append-to-list`, `insert-into-table`, `append-to-array`, or
60    /// `append-line`.
61    pub placement: &'static str,
62    /// Where it goes in the file.
63    pub anchor: Anchor,
64    /// The text to add, rendered.
65    pub text: String,
66    /// Whether the file already carries it; omitted where the file could
67    /// not be judged.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub present: Option<bool>,
70}
71
72/// Where a fragment goes.
73#[derive(Debug, Clone, Serialize)]
74pub struct Anchor {
75    /// `attrset`, `function-head`, `list`, `table`, `array`, or `file`.
76    pub kind: &'static str,
77    /// The attribute path, table, array, or file, as a reader names it.
78    pub path: &'static str,
79    /// A literal substring that locates the anchor, where the observation
80    /// found one; never a pattern.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub needle: Option<&'static str>,
83}
84
85/// The fragments one pair serves, in application order, judged against
86/// the target. Every pair ends with the `.envrc` line: the sync runs on
87/// directory entry whatever manager moves the pin.
88#[must_use]
89pub fn fragments(pair: Pair, tag: &str, observed: &Observed) -> Vec<Fragment> {
90    let entry = observed.entry(pair.manager);
91    let file = entry
92        .and_then(|entry| entry.file.clone())
93        .unwrap_or_else(|| pair.manager.default_file().to_owned());
94    let text = entry.and_then(|entry| entry.text.as_deref());
95    let named = entry.is_some_and(|entry| entry.read.names());
96    let mut out = match pair {
97        Pair {
98            manager: Manager::Flake,
99            venue: Venue::Flake,
100        } => flake_fragments(tag, observed),
101        Pair {
102            manager: Manager::Mise,
103            venue: Venue::Crates | Venue::GithubRelease,
104        } => vec![Fragment {
105            id: "mise-tool",
106            file,
107            role: "the pinned rk tool entry",
108            placement: "insert-into-table",
109            anchor: Anchor {
110                kind: "table",
111                path: "tools",
112                needle: text.and_then(|t| first_found(t, &["[tools]"])),
113            },
114            text: fragment(mise_block(pair.venue), tag),
115            present: Some(named),
116        }],
117        Pair {
118            manager: Manager::Devbox,
119            venue: Venue::Flake,
120        } => vec![Fragment {
121            id: "devbox-package",
122            file,
123            role: "the rk flake package entry",
124            placement: "append-to-array",
125            anchor: Anchor {
126                kind: "array",
127                path: "packages",
128                needle: text.and_then(|t| first_found(t, &["\"packages\""])),
129            },
130            text: fragment("self-depend-devbox-entry.json.in", tag),
131            present: Some(named),
132        }],
133        Pair {
134            manager: Manager::Asdf,
135            ..
136        } => vec![Fragment {
137            id: "asdf-line",
138            file,
139            role: "the line the plugin would take, once a plugin exists",
140            placement: "append-line",
141            anchor: Anchor {
142                kind: "file",
143                path: ".tool-versions",
144                needle: None,
145            },
146            text: fragment("self-depend-asdf-line.in", tag),
147            present: Some(named),
148        }],
149        Pair { .. } => Vec::new(),
150    };
151    out.push(Fragment {
152        id: "envrc-sync",
153        file: ".envrc".to_owned(),
154        role: "the daily sync on directory entry",
155        placement: "append-line",
156        anchor: Anchor {
157            kind: "file",
158            path: ".envrc",
159            needle: None,
160        },
161        text: envrc_line(),
162        present: Some(observed.envrc.is_present() && observed.envrc_sync),
163    });
164    out
165}
166
167/// The three flake fragments, judged against `flake.nix`.
168fn flake_fragments(tag: &str, observed: &Observed) -> Vec<Fragment> {
169    let flake = observed.flake_text.as_deref();
170    vec![
171        Fragment {
172            id: "flake-input",
173            file: "flake.nix".to_owned(),
174            role: "the pinned release-kit input",
175            placement: "insert-into-attrset",
176            anchor: Anchor {
177                kind: "attrset",
178                path: "inputs",
179                needle: flake.and_then(|text| first_found(text, &["inputs = {", "inputs ="])),
180            },
181            text: fragment("self-depend-input.nix.in", tag),
182            present: Some(flake.is_some() && !matches!(observed.scan, pin::Scan::None)),
183        },
184        Fragment {
185            id: "outputs-argument",
186            file: "flake.nix".to_owned(),
187            role: "the release-kit argument of the outputs function",
188            placement: "add-to-function-head",
189            anchor: Anchor {
190                kind: "function-head",
191                path: "outputs",
192                needle: flake.and_then(|text| first_found(text, &["outputs =", "outputs"])),
193            },
194            text: fragment("self-depend-outputs-arg.nix.in", tag),
195            present: flake.map_or(Some(false), outputs_argument_present),
196        },
197        Fragment {
198            id: "devshell-package",
199            file: "flake.nix".to_owned(),
200            role: "the rk package in the default devshell",
201            placement: "append-to-list",
202            anchor: Anchor {
203                kind: "list",
204                path: "devShells.<system>.default.packages",
205                needle: flake.and_then(|text| first_found(text, &["packages = [", "devShells"])),
206            },
207            text: fragment("self-depend-package.nix.in", tag),
208            present: flake.map_or(Some(false), devshell_package_present),
209        },
210    ]
211}
212
213/// The mise block for a venue: the cargo backend for the crate, the ubi
214/// backend for the release archive.
215const fn mise_block(venue: Venue) -> &'static str {
216    match venue {
217        Venue::GithubRelease => "self-depend-mise-ubi.toml.in",
218        Venue::Crates | Venue::Flake => "self-depend-mise-entry.toml.in",
219    }
220}
221
222/// The whole seed file for a pair whose manager file is absent, pinned
223/// at `tag`; `None` for a pair the matrix renders no fragment for.
224#[must_use]
225pub fn seed(pair: Pair, tag: &str) -> Option<String> {
226    match pair {
227        Pair {
228            manager: Manager::Flake,
229            venue: Venue::Flake,
230        } => Some(seed_flake(tag)),
231        Pair {
232            manager: Manager::Mise,
233            venue: Venue::Crates | Venue::GithubRelease,
234        } => {
235            let line = fragment(mise_block(pair.venue), tag);
236            Some(
237                render(block("self-depend-mise-seed.toml.in"), tag).replace(TOOL_LINE_TOKEN, &line),
238            )
239        }
240        Pair {
241            manager: Manager::Devbox,
242            venue: Venue::Flake,
243        } => Some(render(block("self-depend-devbox-seed.json.in"), tag)),
244        Pair { .. } => None,
245    }
246}
247
248/// The whole seed flake, pinned at `tag`.
249#[must_use]
250pub fn seed_flake(tag: &str) -> String {
251    render(block("self-depend-seed-flake.nix.in"), tag)
252}
253
254/// The whole seed `.envrc`, for the flake pair alone: `use flake` is
255/// what puts `rk` on the path there, and no other manager loads through
256/// direnv by default.
257#[must_use]
258pub fn seed_envrc() -> String {
259    block("self-depend-seed-envrc.in").to_owned()
260}
261
262/// The one `.envrc` line, without its newline.
263#[must_use]
264pub fn envrc_line() -> String {
265    block("self-depend-envrc-line.in")
266        .trim_end_matches('\n')
267        .to_owned()
268}
269
270/// The pinned flake-input URL for a tag: the grammar's prefix and the tag.
271#[must_use]
272pub fn pinned_url(tag: &str) -> String {
273    format!("{PIN_PREFIX}{tag}")
274}
275
276/// Render one block's tokens with a plain replace; a seed file keeps its
277/// final newline.
278fn render(text: &str, tag: &str) -> String {
279    let bare = tag.strip_prefix('v').unwrap_or(tag);
280    text.replace(PIN_TOKEN, &pinned_url(tag))
281        .replace(VERSION_TOKEN, bare)
282        .replace(REPO_TOKEN, Venue::owner_repo())
283}
284
285/// One rendered fragment: no final newline, since a reader places it.
286fn fragment(name: &str, tag: &str) -> String {
287    render(block(name), tag).trim_end_matches('\n').to_owned()
288}
289
290/// One authored block, by name; the blocks are compiled in, so a missing
291/// name is a build defect the tests catch, never a runtime path.
292fn block(name: &str) -> &'static str {
293    BLOCKS
294        .get_file(name)
295        .and_then(|file| file.contents_utf8())
296        .unwrap_or_default()
297}
298
299/// The first needle the text holds, as the literal a reader can search.
300fn first_found(text: &str, needles: &[&'static str]) -> Option<&'static str> {
301    needles.iter().copied().find(|needle| text.contains(needle))
302}
303
304/// Whether the outputs function head names `release-kit`: `Some(true)`
305/// where it does, `Some(false)` where the head is an explicit set that
306/// lacks it, and `None` where the head binds its inputs another way — an
307/// ellipsis or an `@` pattern — or no head was found at all.
308fn outputs_argument_present(text: &str) -> Option<bool> {
309    let start = text.find("outputs")?;
310    let rest = &text[start + "outputs".len()..];
311    let head = &rest[..rest.find(':')?];
312    if head.contains("release-kit") {
313        return Some(true);
314    }
315    if head.contains("...") || head.contains('@') || !head.contains('{') {
316        return None;
317    }
318    Some(false)
319}
320
321/// Whether the flake already takes the package: `Some(true)` where the
322/// package reference appears, `Some(false)` where a devshell exists
323/// without it, and `None` where no devshell was found to judge.
324fn devshell_package_present(text: &str) -> Option<bool> {
325    let package = block("self-depend-package.nix.in").trim_end_matches('\n');
326    let prefix = package.split("${").next().unwrap_or(package);
327    if text.contains(prefix) {
328        return Some(true);
329    }
330    text.contains("devShells").then_some(false)
331}
332
333#[cfg(test)]
334mod tests {
335    use super::{
336        BLOCK_NAMES, PIN_TOKEN, Pair, REPO_TOKEN, TOOL_LINE_TOKEN, VERSION_TOKEN, block,
337        devshell_package_present, envrc_line, fragment, outputs_argument_present, render, seed,
338        seed_envrc, seed_flake,
339    };
340    use crate::self_depend::manager::Manager;
341    use crate::self_depend::pin::{PIN_PREFIX, Scan, scan};
342    use crate::self_depend::venue::Venue;
343
344    /// The authored input fragment and the source grammar agree: the
345    /// rendered block is exactly what the matcher reads back.
346    #[test]
347    fn the_pin_matcher_matches_the_authored_input_fragment() {
348        let text = fragment("self-depend-input.nix.in", "v0.2.16");
349        match scan(&text) {
350            Scan::One(pin) => assert_eq!(pin.tag, "v0.2.16"),
351            other => panic!("the fragment must scan as one pin: {other:?}"),
352        }
353        match scan(&seed_flake("v0.2.16")) {
354            Scan::One(pin) => assert_eq!(pin.tag, "v0.2.16"),
355            other => panic!("the seed must scan as one pin: {other:?}"),
356        }
357    }
358
359    #[test]
360    fn every_fragment_renders_its_tag_and_keeps_the_system_interpolation() {
361        for name in BLOCK_NAMES {
362            let authored = block(name);
363            assert!(!authored.is_empty(), "{name}: the block is authored");
364            assert!(authored.ends_with('\n'), "{name}: one final newline");
365            let rendered = render(authored, "v9.9.9");
366            for token in [PIN_TOKEN, VERSION_TOKEN, REPO_TOKEN] {
367                assert!(!rendered.contains(token), "{name}: {token} renders");
368            }
369        }
370        let package = fragment("self-depend-package.nix.in", "v9.9.9");
371        assert_eq!(package, "release-kit.packages.${system}.default");
372        let seed = seed_flake("v9.9.9");
373        assert!(seed.contains("${system}"), "the interpolation survives");
374        assert!(seed.contains(&format!("{PIN_PREFIX}v9.9.9")));
375        assert!(seed.ends_with("}\n"), "a seed file keeps its final newline");
376        assert!(seed_envrc().ends_with('\n'));
377        assert!(
378            !envrc_line().ends_with('\n'),
379            "a fragment carries no newline"
380        );
381        assert!(seed_envrc().ends_with(&format!("{}\n", envrc_line())));
382    }
383
384    /// Every fragment and seed a rendering pair serves comes from a file
385    /// under `blocks/`, and none from a source literal.
386    #[test]
387    fn no_fragment_is_a_source_literal() {
388        assert_eq!(
389            fragment("self-depend-mise-entry.toml.in", "v0.2.16"),
390            "\"cargo:release-kit\" = \"0.2.16\""
391        );
392        assert_eq!(
393            fragment("self-depend-mise-ubi.toml.in", "0.2.16"),
394            "\"ubi:gubasso/release-kit\" = { version = \"0.2.16\", exe = \"rk\" }"
395        );
396        assert_eq!(
397            fragment("self-depend-devbox-entry.json.in", "v0.2.16"),
398            "\"github:gubasso/release-kit/v0.2.16#default\""
399        );
400        assert_eq!(
401            fragment("self-depend-asdf-line.in", "v0.2.16"),
402            "release-kit 0.2.16"
403        );
404        let mise = Pair {
405            manager: Manager::Mise,
406            venue: Venue::Crates,
407        };
408        assert_eq!(
409            seed(mise, "v0.2.16").as_deref(),
410            Some("[tools]\n\"cargo:release-kit\" = \"0.2.16\"\n")
411        );
412        assert!(
413            block("self-depend-mise-seed.toml.in").contains(TOOL_LINE_TOKEN),
414            "the mise seed takes its line from the entry block"
415        );
416        let devbox = Pair {
417            manager: Manager::Devbox,
418            venue: Venue::Flake,
419        };
420        assert_eq!(
421            seed(devbox, "v0.2.16").as_deref(),
422            Some(
423                "{\n  \"packages\": [\n    \"github:gubasso/release-kit/v0.2.16#default\"\n  ]\n}\n"
424            )
425        );
426        let flake = Pair {
427            manager: Manager::Flake,
428            venue: Venue::Flake,
429        };
430        assert_eq!(
431            seed(flake, "v0.2.16").as_deref(),
432            Some(seed_flake("v0.2.16").as_str())
433        );
434        for manual in [
435            Pair {
436                manager: Manager::Asdf,
437                venue: Venue::Crates,
438            },
439            Pair {
440                manager: Manager::Flake,
441                venue: Venue::Crates,
442            },
443            Pair {
444                manager: Manager::Mise,
445                venue: Venue::Flake,
446            },
447        ] {
448            assert_eq!(seed(manual, "v0.2.16"), None, "{manual:?} seeds nothing");
449        }
450    }
451
452    #[test]
453    fn the_outputs_head_is_judged_lexically() {
454        assert_eq!(
455            outputs_argument_present("outputs = { self, nixpkgs, release-kit }: {}"),
456            Some(true)
457        );
458        assert_eq!(
459            outputs_argument_present("outputs =\n    { self, nixpkgs }:\n    {}"),
460            Some(false)
461        );
462        assert_eq!(
463            outputs_argument_present("outputs = { self, ... }: {}"),
464            None,
465            "an ellipsis binds the input another way"
466        );
467        assert_eq!(outputs_argument_present("outputs = inputs: {}"), None);
468        assert_eq!(outputs_argument_present("{ inputs = {}; }"), None);
469    }
470
471    #[test]
472    fn the_devshell_package_is_judged_lexically() {
473        assert_eq!(
474            devshell_package_present(
475                "devShells = { default = mkShell { packages = [ release-kit.packages.${system}.default ]; }; }"
476            ),
477            Some(true)
478        );
479        assert_eq!(
480            devshell_package_present("devShells = { default = mkShell { packages = [ just ]; }; }"),
481            Some(false)
482        );
483        assert_eq!(devshell_package_present("packages = {}"), None);
484    }
485}