1use serde::Serialize;
13
14use crate::embedded::BLOCKS;
15
16pub 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#[derive(Debug, Clone, Default)]
35pub struct Tokens {
36 pub name: String,
38 pub input: String,
41 pub version: String,
43 pub tag: String,
45 pub owner_repo: Option<String>,
47 pub bin: Option<String>,
49 pub flake_ref: Option<String>,
51 pub tool_line: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize)]
57pub struct Fragment {
58 pub id: &'static str,
61 pub file: String,
63 pub role: &'static str,
65 pub placement: &'static str,
69 pub anchor: Anchor,
71 pub text: String,
73 #[serde(skip_serializing_if = "Option::is_none")]
76 pub present: Option<bool>,
77}
78
79#[derive(Debug, Clone, Serialize)]
81pub struct Anchor {
82 pub kind: &'static str,
84 pub path: String,
86 #[serde(skip_serializing_if = "Option::is_none")]
89 pub needle: Option<&'static str>,
90}
91
92#[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
105const NIX_KEYWORDS: [&str; 10] = [
107 "assert", "else", "if", "in", "inherit", "let", "or", "rec", "then", "with",
108];
109
110#[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#[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#[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#[must_use]
172pub fn seed(name: &str, tokens: &Tokens) -> String {
173 render(block(name), tokens)
174}
175
176#[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#[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#[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#[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 use super::{
223 BLOCK_NAMES, Tokens, block, flake_ref, fragment, nix_input_name, outputs_argument_present,
224 render, seed,
225 };
226
227 fn full() -> Tokens {
228 Tokens {
229 name: "sample-tool".into(),
230 input: "sample-tool".into(),
231 version: "1.4.0".into(),
232 tag: "v1.4.0".into(),
233 owner_repo: Some("acme/sample-tool".into()),
234 bin: Some("sam".into()),
235 flake_ref: Some("github:acme/sample-tool/v1.4.0".into()),
236 tool_line: Some("\"cargo:sample-tool\" = \"1.4.0\"".into()),
237 }
238 }
239
240 #[test]
242 fn every_depend_block_renders_all_its_tokens() {
243 let tokens = full();
244 for name in BLOCK_NAMES {
245 let authored = block(name);
246 assert!(!authored.is_empty(), "{name}: the block is authored");
247 assert!(authored.ends_with('\n'), "{name}: one final newline");
248 let rendered = render(authored, &tokens);
249 assert!(!rendered.contains("RK_DEP_"), "{name}: every token renders");
250 }
251 assert_eq!(
252 fragment("depend-flake-package.nix.in", &tokens),
253 "sample-tool.packages.${system}.default"
254 );
255 assert_eq!(
256 fragment("depend-mise-ubi.toml.in", &tokens),
257 "\"ubi:acme/sample-tool\" = { version = \"1.4.0\", exe = \"sam\" }"
258 );
259 assert_eq!(
260 fragment("depend-devbox-flake.json.in", &tokens),
261 "\"github:acme/sample-tool/v1.4.0#default\""
262 );
263 }
264
265 #[test]
266 fn a_seed_keeps_its_final_newline_and_a_fragment_drops_it() {
267 let tokens = full();
268 let flake = seed("depend-seed-flake.nix.in", &tokens);
269 assert!(flake.ends_with("}\n"));
270 assert!(flake.contains("${system}"), "the interpolation survives");
271 assert!(flake.contains("github:acme/sample-tool/v1.4.0"));
272 let mise = seed("depend-seed-mise.toml.in", &tokens);
273 assert_eq!(mise, "[tools]\n\"cargo:sample-tool\" = \"1.4.0\"\n");
274 assert!(!fragment("depend-asdf-line.in", &tokens).ends_with('\n'));
275 assert_eq!(
276 fragment("depend-asdf-line.in", &tokens),
277 "sample-tool 1.4.0"
278 );
279 }
280
281 #[test]
282 fn the_flake_ref_carries_no_owner_of_its_own() {
283 assert_eq!(
284 flake_ref(Some("github.com"), Some("acme/sample-tool"), "v1.4.0").as_deref(),
285 Some("github:acme/sample-tool/v1.4.0")
286 );
287 assert_eq!(
288 flake_ref(Some("gitlab.com"), Some("group/sample"), "1.0.0").as_deref(),
289 Some("gitlab:group/sample/1.0.0")
290 );
291 assert_eq!(flake_ref(Some("codeberg.org"), Some("a/b"), "v1"), None);
292 assert_eq!(flake_ref(Some("github.com"), None, "v1"), None);
293 let without_ref = Tokens {
294 flake_ref: None,
295 ..full()
296 };
297 assert!(
298 render(block("depend-flake-input.nix.in"), &without_ref).contains("RK_DEP_FLAKE_REF"),
299 "an unknown value is left as its token, never invented"
300 );
301 }
302
303 #[test]
304 fn a_package_name_becomes_a_nix_identifier() {
305 assert_eq!(nix_input_name("sample-tool"), "sample-tool");
306 assert_eq!(nix_input_name("@acme/tool"), "acme-tool");
307 assert_eq!(nix_input_name("my.tool"), "my-tool");
308 assert_eq!(nix_input_name("7zip"), "dep-7zip");
309 assert_eq!(nix_input_name("with"), "with-input");
310 assert_eq!(nix_input_name("@@"), "dep");
311 let scoped = Tokens {
312 name: "@acme/tool".into(),
313 input: nix_input_name("@acme/tool"),
314 ..full()
315 };
316 let rendered = fragment("depend-flake-input.nix.in", &scoped);
317 assert!(rendered.starts_with("acme-tool = {"), "{rendered}");
318 assert!(!rendered.contains('@'));
319 }
320
321 #[test]
322 fn an_input_binding_is_read_to_its_close() {
323 use super::input_binding;
324 let text = "inputs = {\n acme-tool = {\n url = \"github:other/thing/v1\";\n };\n nixpkgs.url = \"x\";\n};";
325 let body = input_binding(text, "acme-tool").expect("a binding");
326 assert!(body.contains("github:other/thing/v1"));
327 assert!(!body.contains("nixpkgs"));
328 assert!(input_binding(text, "nixpkgs").is_some_and(|v| v.contains("\"x\"")));
329 assert!(
330 input_binding(
331 "inputs.acme-tool.url = \"github:other/thing/v1\";",
332 "acme-tool"
333 )
334 .is_some_and(|v| v.contains("github:other/thing/v1")),
335 "the dotted form is a declaration too"
336 );
337 assert_eq!(
338 input_binding("packages = [ acme-tool ];", "acme-tool"),
339 None
340 );
341 }
342
343 #[test]
344 fn the_outputs_head_is_judged_lexically() {
345 assert_eq!(
346 outputs_argument_present(
347 "outputs = { self, nixpkgs, sample-tool }: {}",
348 "sample-tool"
349 ),
350 Some(true)
351 );
352 assert_eq!(
353 outputs_argument_present("outputs =\n { self, nixpkgs }:\n {}", "sample-tool"),
354 Some(false)
355 );
356 assert_eq!(
357 outputs_argument_present("outputs = { self, ... }: {}", "sample-tool"),
358 None
359 );
360 assert_eq!(
361 outputs_argument_present("{ inputs = {}; }", "sample-tool"),
362 None
363 );
364 }
365}