1use 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
22pub 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
38const PIN_TOKEN: &str = "RK_DEVSHELL_PIN";
40const VERSION_TOKEN: &str = "RK_DEVSHELL_VERSION";
42const REPO_TOKEN: &str = "RK_DEVSHELL_REPO";
44const TOOL_LINE_TOKEN: &str = "RK_DEVSHELL_TOOL_LINE";
46
47#[derive(Debug, Clone, Serialize)]
49pub struct Fragment {
50 pub id: &'static str,
54 pub file: String,
56 pub role: &'static str,
58 pub placement: &'static str,
62 pub anchor: Anchor,
64 pub text: String,
66 #[serde(skip_serializing_if = "Option::is_none")]
69 pub present: Option<bool>,
70}
71
72#[derive(Debug, Clone, Serialize)]
74pub struct Anchor {
75 pub kind: &'static str,
77 pub path: &'static str,
79 #[serde(skip_serializing_if = "Option::is_none")]
82 pub needle: Option<&'static str>,
83}
84
85#[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
167fn 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
213const 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#[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#[must_use]
250pub fn seed_flake(tag: &str) -> String {
251 render(block("self-depend-seed-flake.nix.in"), tag)
252}
253
254#[must_use]
258pub fn seed_envrc() -> String {
259 block("self-depend-seed-envrc.in").to_owned()
260}
261
262#[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#[must_use]
272pub fn pinned_url(tag: &str) -> String {
273 format!("{PIN_PREFIX}{tag}")
274}
275
276fn 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
285fn fragment(name: &str, tag: &str) -> String {
287 render(block(name), tag).trim_end_matches('\n').to_owned()
288}
289
290fn block(name: &str) -> &'static str {
293 BLOCKS
294 .get_file(name)
295 .and_then(|file| file.contents_utf8())
296 .unwrap_or_default()
297}
298
299fn first_found(text: &str, needles: &[&'static str]) -> Option<&'static str> {
301 needles.iter().copied().find(|needle| text.contains(needle))
302}
303
304fn 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
321fn 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 #[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 #[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}