1use serde::Deserialize;
2use serde_json::{Value, json};
3use sha2::{Digest, Sha256};
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8pub const VARIABLE: &str = "PFX_RECIPES";
9pub const SUBTREE: &str = "render/assets";
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub enum Origin {
12 Flag,
13 Variable,
14 Caller,
15}
16
17#[derive(Clone, Debug, PartialEq)]
18pub struct Root {
19 pub dir: PathBuf,
20 pub origin: Origin,
21}
22
23pub fn root(
24 flag: Option<&Path>,
25 variable: Option<PathBuf>,
26 toplevel: Option<PathBuf>,
27) -> Option<Root> {
28 flag.map(|dir| Root {
29 dir: dir.to_path_buf(),
30 origin: Origin::Flag,
31 })
32 .or_else(|| {
33 variable
34 .filter(|dir| !dir.as_os_str().is_empty())
35 .map(|dir| Root {
36 dir,
37 origin: Origin::Variable,
38 })
39 })
40 .or_else(|| {
41 toplevel.map(|top| Root {
42 dir: top.join(SUBTREE),
43 origin: Origin::Caller,
44 })
45 })
46}
47
48pub fn slug(name: &str) -> Result<(), String> {
49 let lower = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit();
50 if name.chars().next().is_some_and(lower) && name.chars().all(|c| lower(c) || c == '-') {
51 Ok(())
52 } else {
53 Err(format!(
54 "'{name}' is not a product slug: lowercase letters, digits and '-', starting with a letter or digit"
55 ))
56 }
57}
58
59fn git(dir: &Path, args: &[&str]) -> Option<String> {
60 let out = Command::new("git")
61 .arg("-C")
62 .arg(dir)
63 .args(args)
64 .output()
65 .ok()?;
66 out.status
67 .success()
68 .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
69}
70
71pub fn toplevel(dir: &Path) -> Option<PathBuf> {
72 git(dir, &["rev-parse", "--show-toplevel"])
73 .filter(|top| !top.is_empty())
74 .map(PathBuf::from)
75}
76
77fn sha(bytes: &[u8]) -> String {
78 Sha256::digest(bytes)
79 .iter()
80 .map(|b| format!("{b:02x}"))
81 .collect()
82}
83
84#[derive(Clone, Debug)]
85pub struct Recipes {
86 root: Option<Root>,
87 order: Vec<String>,
88}
89
90#[derive(Clone, Debug)]
91pub struct Recipe {
92 pub text: String,
93 pub record: Value,
94}
95
96impl Recipe {
97 pub fn pinned(&self) -> bool {
98 self.record["pinned"].as_bool().unwrap_or(false)
99 }
100
101 pub fn sha256(&self) -> String {
102 sha(self.text.as_bytes())
103 }
104}
105
106#[derive(Deserialize)]
107#[serde(deny_unknown_fields)]
108struct Listed {}
109
110#[derive(Deserialize)]
111struct Book {
112 #[serde(default)]
113 products: BTreeMap<String, toml::Spanned<Listed>>,
114}
115
116fn absolute(path: PathBuf) -> PathBuf {
117 std::path::absolute(&path).unwrap_or(path)
118}
119
120fn names(dir: &Path) -> Vec<String> {
121 let mut out: Vec<String> = std::fs::read_dir(dir)
122 .map(|entries| {
123 entries
124 .flatten()
125 .map(|entry| entry.path())
126 .filter(|path| path.is_file() && path.extension().is_some_and(|e| e == "toml"))
127 .filter_map(|path| path.file_stem().map(|s| s.to_string_lossy().into_owned()))
128 .collect()
129 })
130 .unwrap_or_default();
131 out.sort();
132 out
133}
134
135pub fn file(path: &Path) -> Result<Recipe, String> {
136 let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
137 Ok(local_recipe(&absolute(path.to_path_buf()), text, true))
138}
139
140fn local_recipe(path: &Path, text: String, may_pin: bool) -> Recipe {
141 let digest = sha(text.as_bytes());
142 let shown = path.display().to_string();
143 let dir = path.parent().unwrap_or(Path::new("."));
144 let name = path
145 .file_name()
146 .map(|n| n.to_string_lossy().into_owned())
147 .unwrap_or_default();
148 let tracked = git(
149 dir,
150 &["ls-files", "--full-name", "--error-unmatch", "--", &name],
151 );
152 let record = match tracked {
153 Some(full) => {
154 let clean = git(dir, &["status", "--porcelain", "--", &name])
155 .is_some_and(|status| status.is_empty());
156 let repo = git(dir, &["remote", "get-url", "origin"])
157 .or_else(|| toplevel(dir).map(|top| top.display().to_string()));
158 json!({
159 "path": full,
160 "file": shown,
161 "repo": repo,
162 "commit": git(dir, &["rev-parse", "HEAD"]),
163 "sha256": digest,
164 "pinned": may_pin && clean,
165 })
166 }
167 None => json!({
168 "path": shown,
169 "file": shown,
170 "repo": Value::Null,
171 "commit": Value::Null,
172 "sha256": digest,
173 "pinned": false,
174 }),
175 };
176 Recipe { text, record }
177}
178
179impl Recipes {
180 pub fn new(root: Option<Root>) -> Recipes {
181 Recipes {
182 root,
183 order: Vec::new(),
184 }
185 }
186
187 pub fn from_flags(
188 values: &[String],
189 variable: Option<PathBuf>,
190 toplevel: impl FnOnce() -> Option<PathBuf>,
191 ) -> Result<Recipes, String> {
192 let dir = match values {
193 [] => None,
194 [one] if one.contains('=') => {
195 return Err(format!(
196 "--recipes takes one folder, the caller's own; {one} names a product's folder elsewhere, and pfx reads no other repository"
197 ));
198 }
199 [one] => Some(absolute(PathBuf::from(one))),
200 _ => return Err("--recipes <dir> was given twice".into()),
201 };
202 let root = match (dir, variable.filter(|v| !v.as_os_str().is_empty())) {
203 (Some(dir), _) => root(Some(&dir), None, None),
204 (None, Some(var)) => root(None, Some(absolute(var)), None),
205 (None, None) => root(None, None, toplevel()),
206 };
207 Ok(Recipes::new(root))
208 }
209
210 pub fn root(&self) -> Option<&Root> {
211 self.root.as_ref()
212 }
213
214 pub fn path(&self, rel: &str) -> Option<PathBuf> {
215 self.root.as_ref().map(|root| root.dir.join(rel))
216 }
217
218 pub fn find(&self, rel: &str) -> Result<Option<Recipe>, String> {
219 if let Some(path) = self.path(rel) {
220 match std::fs::read_to_string(&path) {
221 Ok(text) => return Ok(Some(local_recipe(&path, text, true))),
222 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
223 Err(e) => return Err(format!("{}: {e}", path.display())),
224 }
225 }
226 Ok(None)
227 }
228
229 pub fn read(&self, rel: &str) -> Result<Recipe, String> {
230 self.find(rel)?.ok_or_else(|| self.missing("recipe", rel))
231 }
232
233 pub fn missing(&self, what: &str, rel: &str) -> String {
234 match self.path(rel) {
235 Some(path) => format!("no {what} at {}", path.display()),
236 None => format!(
237 "no recipes folder for {rel}: pass --recipes <dir>, set {VARIABLE}, or run inside the product's git tree, which keeps it at {SUBTREE}/{rel}"
238 ),
239 }
240 }
241
242 pub fn presets(&self) -> Vec<String> {
243 self.root
244 .as_ref()
245 .map(|root| names(&root.dir.join("presets")))
246 .unwrap_or_default()
247 }
248
249 pub fn books(&self) -> Vec<String> {
250 self.root
251 .as_ref()
252 .map(|root| names(&root.dir.join("shots")))
253 .unwrap_or_default()
254 }
255
256 pub fn icon_sets(&self, product: &str) -> Vec<String> {
257 self.path(&format!("icons/{product}"))
258 .map(|dir| names(&dir))
259 .unwrap_or_default()
260 }
261
262 pub fn icon_products(&self) -> Vec<String> {
263 let mut out: Vec<String> = Vec::new();
264 if let Some(root) = &self.root
265 && let Ok(entries) = std::fs::read_dir(root.dir.join("icons"))
266 {
267 out = entries
268 .flatten()
269 .filter(|entry| entry.path().is_dir())
270 .map(|entry| entry.file_name().to_string_lossy().into_owned())
271 .collect();
272 out.sort();
273 }
274 out
275 }
276
277 pub fn universe(&self, own: &str) -> Vec<String> {
278 let mut out = self.order.clone();
279 if !out.iter().any(|p| p == own) {
280 out.push(own.to_string());
281 }
282 out
283 }
284
285 pub fn list_products(&mut self, text: &str, what: &str) -> Result<(), String> {
286 let book: Book = toml::from_str(text).map_err(|e| {
287 format!(
288 "{what}: {e}; [products.<p>] only orders the products of the caller's own folder"
289 )
290 })?;
291 let mut listed: Vec<(usize, String)> = book
292 .products
293 .into_iter()
294 .map(|(product, entry)| (entry.span().start, product))
295 .collect();
296 listed.sort_by_key(|(at, _)| *at);
297 for (_, product) in listed {
298 slug(&product).map_err(|e| format!("{what} [products.{product}]: {e}"))?;
299 self.order.push(product);
300 }
301 Ok(())
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 fn fixtures() -> PathBuf {
310 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/recipes")
311 }
312
313 fn scratch(name: &str) -> PathBuf {
314 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
315 .join("tmp")
316 .join(format!("{name}-{}", std::process::id()));
317 std::fs::create_dir_all(&dir).unwrap();
318 dir
319 }
320
321 fn named() -> Recipes {
322 Recipes::from_flags(&[fixtures().display().to_string()], None, || None).unwrap()
323 }
324
325 #[test]
326 fn the_root_is_the_flag_then_the_variable_then_the_callers_tree() {
327 let flag = PathBuf::from("/flag");
328 let var = Some(PathBuf::from("/var"));
329 let top = Some(PathBuf::from("/top"));
330 assert_eq!(
331 root(Some(&flag), var.clone(), top.clone()),
332 Some(Root {
333 dir: flag.clone(),
334 origin: Origin::Flag
335 })
336 );
337 assert_eq!(
338 root(None, var.clone(), top.clone()),
339 Some(Root {
340 dir: PathBuf::from("/var"),
341 origin: Origin::Variable
342 })
343 );
344 assert_eq!(
345 root(None, Some(PathBuf::new()), top.clone()),
346 Some(Root {
347 dir: PathBuf::from("/top/render/assets"),
348 origin: Origin::Caller
349 })
350 );
351 assert_eq!(root(None, None, None), None);
352 let from = |values: &[&str], var: Option<&str>| {
353 let values: Vec<String> = values.iter().map(|v| v.to_string()).collect();
354 Recipes::from_flags(&values, var.map(PathBuf::from), || {
355 Some(PathBuf::from("/top"))
356 })
357 .unwrap()
358 .root
359 .unwrap()
360 };
361 assert_eq!(from(&["/flag"], Some("/var")).origin, Origin::Flag);
362 assert_eq!(from(&[], Some("/var")).origin, Origin::Variable);
363 assert_eq!(from(&[], None).dir, PathBuf::from("/top/render/assets"));
364 assert!(Recipes::from_flags(&["/a".into(), "/b".into()], None, || None).is_err());
365 let elsewhere = Recipes::from_flags(&["other=/o".into()], None, || None).unwrap_err();
366 assert!(elsewhere.contains("no other repository"), "{elsewhere}");
367 }
368
369 #[test]
370 fn product_slugs_take_no_list_but_only_safe_names() {
371 for good in ["sample", "estate", "app-0", "a1"] {
372 assert!(slug(good).is_ok(), "{good}");
373 }
374 for bad in ["", "Sample", "-x", "a b", "a/b", "..", "a_b"] {
375 assert!(slug(bad).is_err(), "{bad}");
376 }
377 }
378
379 #[test]
380 fn a_named_root_reads_only_its_own_files_and_a_missing_one_names_the_path() {
381 let recipes = named();
382 let preset = recipes.read("presets/sample.toml").unwrap();
383 assert!(preset.text.contains("[material]"));
384 assert_eq!(preset.record["sha256"], json!(preset.sha256()));
385 let missing = recipes.read("presets/other.toml").unwrap_err();
386 assert_eq!(
387 missing,
388 format!(
389 "no recipe at {}",
390 fixtures().join("presets/other.toml").display()
391 )
392 );
393 assert_eq!(recipes.presets(), ["sample"]);
394 assert_eq!(recipes.books(), ["sample"]);
395 assert_eq!(recipes.icon_sets("sample"), ["shapes"]);
396 assert_eq!(recipes.icon_products(), ["sample"]);
397 assert_eq!(recipes.universe("sample"), ["sample"]);
398 }
399
400 #[test]
401 fn the_variable_is_strict_like_the_flag() {
402 let recipes = Recipes::from_flags(&[], Some(fixtures()), || None).unwrap();
403 assert_eq!(recipes.root().unwrap().origin, Origin::Variable);
404 assert!(recipes.read("shots/sample.toml").is_ok());
405 assert!(recipes.read("presets/other.toml").is_err());
406 }
407
408 #[test]
409 fn the_callers_tree_reads_only_its_own_folder() {
410 let top = scratch("caller");
411 std::fs::create_dir_all(top.join("render/assets/presets")).unwrap();
412 std::fs::write(
413 top.join("render/assets/presets/sample.toml"),
414 "colors = \"master\"\n",
415 )
416 .unwrap();
417 let recipes = Recipes::from_flags(&[], None, || Some(top.clone())).unwrap();
418 assert_eq!(recipes.root().unwrap().origin, Origin::Caller);
419 let own = recipes.read("presets/sample.toml").unwrap();
420 assert_eq!(own.text, "colors = \"master\"\n");
421 assert_eq!(recipes.presets(), ["sample"]);
422 assert!(recipes.books().is_empty());
423 assert!(recipes.icon_products().is_empty());
424 assert_eq!(recipes.universe("estate"), ["estate"]);
425 assert!(
426 recipes.read("presets/other.toml").unwrap_err().contains(
427 &top.join("render/assets/presets/other.toml")
428 .display()
429 .to_string()
430 )
431 );
432 let nowhere = Recipes::from_flags(&[], None, || None).unwrap();
433 assert!(nowhere.presets().is_empty());
434 assert!(
435 nowhere
436 .read("presets/sample.toml")
437 .unwrap_err()
438 .contains(VARIABLE)
439 );
440 std::fs::remove_dir_all(&top).ok();
441 }
442
443 #[test]
444 fn a_shot_orders_its_products_and_reads_them_all_from_the_callers_folder() {
445 let book = r#"
446[products.other]
447
448[products.sample]
449
450[products.third]
451
452[shots.all]
453line = "x"
454"#;
455 let mut recipes = named();
456 recipes.list_products(book, "shots/sample.toml").unwrap();
457 assert_eq!(recipes.universe("sample"), ["other", "sample", "third"]);
458 assert_eq!(
459 recipes.universe("estate"),
460 ["other", "sample", "third", "estate"]
461 );
462 assert!(recipes.read("presets/other.toml").is_err());
463 for bad in [
464 "[products.other]\nrepo = \"r\"\nrev = \"v1.0.0\"\n",
465 "[products.other]\npath = \"render/assets\"\n",
466 "[products.Other]\n",
467 ] {
468 let mut fresh = named();
469 assert!(fresh.list_products(bad, "shots").is_err(), "{bad}");
470 }
471 }
472
473 #[test]
474 fn a_recipe_outside_git_is_recorded_but_unpinned() {
475 let dir = scratch("loose");
476 let path = dir.join("look.toml");
477 std::fs::write(&path, "colors = \"master\"\n").unwrap();
478 let recipe = file(&path).unwrap();
479 assert_eq!(recipe.record["sha256"], json!(recipe.sha256()));
480 assert!(!recipe.pinned());
481 assert_eq!(recipe.record["commit"], Value::Null);
482 std::fs::remove_dir_all(&dir).ok();
483 }
484}