1use serde::Serialize;
13
14use super::pin::PIN_PREFIX;
15use super::{Observed, pin};
16use crate::embedded::BLOCKS;
17
18const PIN_TOKEN: &str = "RK_DEVSHELL_PIN";
20
21#[derive(Debug, Clone, Serialize)]
23pub struct Fragment {
24 pub id: &'static str,
27 pub file: &'static str,
29 pub role: &'static str,
31 pub placement: &'static str,
34 pub anchor: Anchor,
36 pub text: String,
38 #[serde(skip_serializing_if = "Option::is_none")]
41 pub present: Option<bool>,
42}
43
44#[derive(Debug, Clone, Serialize)]
46pub struct Anchor {
47 pub kind: &'static str,
49 pub path: &'static str,
51 #[serde(skip_serializing_if = "Option::is_none")]
54 pub needle: Option<&'static str>,
55}
56
57#[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#[must_use]
120pub fn seed_flake(tag: &str) -> String {
121 render(block("devshell-seed-flake.nix.in"), tag)
122}
123
124#[must_use]
126pub fn seed_envrc() -> String {
127 block("devshell-seed-envrc.in").to_owned()
128}
129
130#[must_use]
132pub fn envrc_line() -> String {
133 block("devshell-envrc-line.in")
134 .trim_end_matches('\n')
135 .to_owned()
136}
137
138#[must_use]
140pub fn pinned_url(tag: &str) -> String {
141 format!("{PIN_PREFIX}{tag}")
142}
143
144fn render(text: &str, tag: &str) -> String {
147 text.replace(PIN_TOKEN, &pinned_url(tag))
148}
149
150fn fragment(name: &str, tag: &str) -> String {
152 render(block(name), tag).trim_end_matches('\n').to_owned()
153}
154
155fn block(name: &str) -> &'static str {
158 BLOCKS
159 .get_file(name)
160 .and_then(|file| file.contents_utf8())
161 .unwrap_or_default()
162}
163
164fn first_found(text: &str, needles: &[&'static str]) -> Option<&'static str> {
166 needles.iter().copied().find(|needle| text.contains(needle))
167}
168
169fn 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
186fn 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 #[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}