Skip to main content

pfx_assets/
recipes.rs

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}