Skip to main content

release_kit/devshell/
fragments.rs

1//! The texts `rk devshell add` serves: three flake fragments, the
2//! `.envrc` line, and the seed pair for a target that has neither file.
3//!
4//! Every text is an authored file under `blocks/`, per
5//! `distribution:a-human-faced-artifact-is-authored-text`; each carries
6//! an `RK_DEVSHELL_PIN` token rendered with a plain replace, never a
7//! format, so the `${system}` interpolation survives untouched. The
8//! fragments describe where they go in a form a coding agent can apply
9//! with no parser: a closed placement vocabulary, a literal anchor
10//! substring, and a three-state `present` from the lexical observation.
11
12use serde::Serialize;
13
14use super::pin::PIN_PREFIX;
15use super::{Observed, pin};
16use crate::embedded::BLOCKS;
17
18/// The token every devshell block carries where the pinned URL goes.
19const PIN_TOKEN: &str = "RK_DEVSHELL_PIN";
20
21/// One fragment: what to add, where, and whether it is already there.
22#[derive(Debug, Clone, Serialize)]
23pub struct Fragment {
24    /// The stable name: `flake-input`, `outputs-argument`,
25    /// `devshell-package`, or `envrc-sync`.
26    pub id: &'static str,
27    /// The file it goes into, relative to the target.
28    pub file: &'static str,
29    /// What it is for, one phrase.
30    pub role: &'static str,
31    /// How it goes in: `insert-into-attrset`, `add-to-function-head`,
32    /// `append-to-list`, or `append-line`.
33    pub placement: &'static str,
34    /// Where it goes in the file.
35    pub anchor: Anchor,
36    /// The text to add, rendered.
37    pub text: String,
38    /// Whether the file already carries it; omitted where the file could
39    /// not be judged.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub present: Option<bool>,
42}
43
44/// Where a fragment goes.
45#[derive(Debug, Clone, Serialize)]
46pub struct Anchor {
47    /// `attrset`, `function-head`, `list`, or `file`.
48    pub kind: &'static str,
49    /// The attribute path or the file, as a reader names it.
50    pub path: &'static str,
51    /// A literal substring that locates the anchor, where the observation
52    /// found one; never a pattern.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub needle: Option<&'static str>,
55}
56
57/// The four fragments, in application order, judged against the target.
58#[must_use]
59pub fn fragments(tag: &str, observed: &Observed) -> Vec<Fragment> {
60    let flake = observed.flake_text.as_deref();
61    let envrc_present = observed.envrc.is_present();
62    vec![
63        Fragment {
64            id: "flake-input",
65            file: "flake.nix",
66            role: "the pinned release-kit input",
67            placement: "insert-into-attrset",
68            anchor: Anchor {
69                kind: "attrset",
70                path: "inputs",
71                needle: flake.and_then(|text| first_found(text, &["inputs = {", "inputs ="])),
72            },
73            text: fragment("devshell-input.nix.in", tag),
74            present: Some(flake.is_some() && !matches!(observed.scan, pin::Scan::None)),
75        },
76        Fragment {
77            id: "outputs-argument",
78            file: "flake.nix",
79            role: "the release-kit argument of the outputs function",
80            placement: "add-to-function-head",
81            anchor: Anchor {
82                kind: "function-head",
83                path: "outputs",
84                needle: flake.and_then(|text| first_found(text, &["outputs =", "outputs"])),
85            },
86            text: fragment("devshell-outputs-arg.nix.in", tag),
87            present: flake.map_or(Some(false), outputs_argument_present),
88        },
89        Fragment {
90            id: "devshell-package",
91            file: "flake.nix",
92            role: "the rk package in the default devshell",
93            placement: "append-to-list",
94            anchor: Anchor {
95                kind: "list",
96                path: "devShells.<system>.default.packages",
97                needle: flake.and_then(|text| first_found(text, &["packages = [", "devShells"])),
98            },
99            text: fragment("devshell-package.nix.in", tag),
100            present: flake.map_or(Some(false), devshell_package_present),
101        },
102        Fragment {
103            id: "envrc-sync",
104            file: ".envrc",
105            role: "the daily sync on directory entry",
106            placement: "append-line",
107            anchor: Anchor {
108                kind: "file",
109                path: ".envrc",
110                needle: None,
111            },
112            text: envrc_line(),
113            present: Some(envrc_present && observed.envrc_sync),
114        },
115    ]
116}
117
118/// The whole seed flake, pinned at `tag`.
119#[must_use]
120pub fn seed_flake(tag: &str) -> String {
121    render(block("devshell-seed-flake.nix.in"), tag)
122}
123
124/// The whole seed `.envrc`.
125#[must_use]
126pub fn seed_envrc() -> String {
127    block("devshell-seed-envrc.in").to_owned()
128}
129
130/// The one `.envrc` line, without its newline.
131#[must_use]
132pub fn envrc_line() -> String {
133    block("devshell-envrc-line.in")
134        .trim_end_matches('\n')
135        .to_owned()
136}
137
138/// The pinned flake-input URL for a tag: the grammar's prefix and the tag.
139#[must_use]
140pub fn pinned_url(tag: &str) -> String {
141    format!("{PIN_PREFIX}{tag}")
142}
143
144/// Render one block's token with a plain replace; a seed file keeps its
145/// final newline.
146fn render(text: &str, tag: &str) -> String {
147    text.replace(PIN_TOKEN, &pinned_url(tag))
148}
149
150/// One rendered fragment: no final newline, since a reader places it.
151fn fragment(name: &str, tag: &str) -> String {
152    render(block(name), tag).trim_end_matches('\n').to_owned()
153}
154
155/// One authored block, by name; the payload is compiled in, so a missing
156/// name is a build defect the tests catch, never a runtime path.
157fn block(name: &str) -> &'static str {
158    BLOCKS
159        .get_file(name)
160        .and_then(|file| file.contents_utf8())
161        .unwrap_or_default()
162}
163
164/// The first needle the text holds, as the literal a reader can search.
165fn first_found(text: &str, needles: &[&'static str]) -> Option<&'static str> {
166    needles.iter().copied().find(|needle| text.contains(needle))
167}
168
169/// Whether the outputs function head names `release-kit`: `Some(true)`
170/// where it does, `Some(false)` where the head is an explicit set that
171/// lacks it, and `None` where the head binds its inputs another way — an
172/// ellipsis or an `@` pattern — or no head was found at all.
173fn outputs_argument_present(text: &str) -> Option<bool> {
174    let start = text.find("outputs")?;
175    let rest = &text[start + "outputs".len()..];
176    let head = &rest[..rest.find(':')?];
177    if head.contains("release-kit") {
178        return Some(true);
179    }
180    if head.contains("...") || head.contains('@') || !head.contains('{') {
181        return None;
182    }
183    Some(false)
184}
185
186/// Whether the flake already takes the package: `Some(true)` where the
187/// package reference appears, `Some(false)` where a devshell exists
188/// without it, and `None` where no devshell was found to judge.
189fn devshell_package_present(text: &str) -> Option<bool> {
190    let package = block("devshell-package.nix.in").trim_end_matches('\n');
191    let prefix = package.split("${").next().unwrap_or(package);
192    if text.contains(prefix) {
193        return Some(true);
194    }
195    text.contains("devShells").then_some(false)
196}
197
198#[cfg(test)]
199mod tests {
200    #![allow(clippy::expect_used, clippy::panic)]
201
202    use super::{
203        PIN_TOKEN, block, devshell_package_present, envrc_line, fragment, outputs_argument_present,
204        render, seed_envrc, seed_flake,
205    };
206    use crate::devshell::pin::{PIN_PREFIX, Scan, scan};
207
208    /// The authored input fragment and the source grammar agree: the
209    /// rendered block is exactly what the matcher reads back.
210    #[test]
211    fn the_pin_matcher_matches_the_authored_input_fragment() {
212        let text = fragment("devshell-input.nix.in", "v0.2.16");
213        match scan(&text) {
214            Scan::One(pin) => assert_eq!(pin.tag, "v0.2.16"),
215            other => panic!("the fragment must scan as one pin: {other:?}"),
216        }
217        match scan(&seed_flake("v0.2.16")) {
218            Scan::One(pin) => assert_eq!(pin.tag, "v0.2.16"),
219            other => panic!("the seed must scan as one pin: {other:?}"),
220        }
221    }
222
223    #[test]
224    fn every_fragment_renders_its_tag_and_keeps_the_system_interpolation() {
225        for name in [
226            "devshell-input.nix.in",
227            "devshell-outputs-arg.nix.in",
228            "devshell-package.nix.in",
229            "devshell-envrc-line.in",
230            "devshell-seed-flake.nix.in",
231            "devshell-seed-envrc.in",
232        ] {
233            let rendered = render(block(name), "v9.9.9");
234            assert!(!rendered.contains(PIN_TOKEN), "{name}: the token renders");
235            assert!(!rendered.is_empty(), "{name}: the block is authored");
236        }
237        let package = fragment("devshell-package.nix.in", "v9.9.9");
238        assert_eq!(package, "release-kit.packages.${system}.default");
239        let seed = seed_flake("v9.9.9");
240        assert!(seed.contains("${system}"), "the interpolation survives");
241        assert!(seed.contains(&format!("{PIN_PREFIX}v9.9.9")));
242        assert!(seed.ends_with("}\n"), "a seed file keeps its final newline");
243        assert!(seed_envrc().ends_with('\n'));
244        assert!(
245            !envrc_line().ends_with('\n'),
246            "a fragment carries no newline"
247        );
248        assert!(seed_envrc().ends_with(&format!("{}\n", envrc_line())));
249    }
250
251    #[test]
252    fn the_outputs_head_is_judged_lexically() {
253        assert_eq!(
254            outputs_argument_present("outputs = { self, nixpkgs, release-kit }: {}"),
255            Some(true)
256        );
257        assert_eq!(
258            outputs_argument_present("outputs =\n    { self, nixpkgs }:\n    {}"),
259            Some(false)
260        );
261        assert_eq!(
262            outputs_argument_present("outputs = { self, ... }: {}"),
263            None,
264            "an ellipsis binds the input another way"
265        );
266        assert_eq!(outputs_argument_present("outputs = inputs: {}"), None);
267        assert_eq!(outputs_argument_present("{ inputs = {}; }"), None);
268    }
269
270    #[test]
271    fn the_devshell_package_is_judged_lexically() {
272        assert_eq!(
273            devshell_package_present(
274                "devShells = { default = mkShell { packages = [ release-kit.packages.${system}.default ]; }; }"
275            ),
276            Some(true)
277        );
278        assert_eq!(
279            devshell_package_present("devShells = { default = mkShell { packages = [ just ]; }; }"),
280            Some(false)
281        );
282        assert_eq!(devshell_package_present("packages = {}"), None);
283    }
284}