Skip to main content

gammaloop_api/
templates.rs

1use color_eyre::Result;
2use include_dir::Dir;
3use std::fs;
4use std::path::Path;
5
6const LINNEST_PACKAGE_DIR: &str = "crates/linnest/typst";
7const KURVST_PACKAGE_DIR: &str = "crates/kurvst/typst";
8
9pub struct Assets;
10
11include!(concat!(env!("OUT_DIR"), "/embedded_assets.rs"));
12
13impl Assets {
14    /// Get all app template paths written under drawings/templates.
15    pub fn template_paths() -> impl Iterator<Item = String> {
16        GAMMALOOP_TEMPLATE_ASSETS
17            .files()
18            .map(|file| file.path().to_string_lossy().into_owned())
19    }
20
21    /// Extract all drawing templates to drawings/templates relative to target_dir.
22    pub fn extract_templates<P: AsRef<Path>>(target_dir: P) -> Result<()> {
23        let target = target_dir.as_ref().join("drawings/templates");
24
25        fs::create_dir_all(&target)?;
26
27        extract_package(&GAMMALOOP_TEMPLATE_ASSETS, &target)?;
28        extract_package(&LINNEST_PACKAGE_ASSETS, &target.join(LINNEST_PACKAGE_DIR))?;
29        extract_package(&KURVST_PACKAGE_ASSETS, &target.join(KURVST_PACKAGE_DIR))?;
30
31        Ok(())
32    }
33
34    /// Extract the Justfile to the target directory
35    pub fn extract_justfile<P: AsRef<Path>>(target_dir: P) -> Result<()> {
36        let justfile_path = target_dir.as_ref().join("justfile");
37
38        fs::write(
39            &justfile_path,
40            include_bytes!("../../../assets/embedded/drawing/justfile"),
41        )?;
42
43        Ok(())
44    }
45}
46
47fn extract_package(assets: &Dir<'_>, target: &Path) -> Result<()> {
48    fs::create_dir_all(target)?;
49    for file in assets.files() {
50        let target_path = target.join(file.path());
51        if let Some(parent) = target_path.parent() {
52            fs::create_dir_all(parent)?;
53        }
54        fs::write(&target_path, file.contents())?;
55    }
56
57    Ok(())
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn extract_templates_uses_canonical_package_layout() -> Result<()> {
66        let tempdir = tempfile::tempdir()?;
67
68        Assets::extract_templates(tempdir.path())?;
69
70        let templates = tempdir.path().join("drawings/templates");
71        assert!(templates.join("figure.typ").is_file());
72        assert!(templates.join("grid.typ").is_file());
73        assert!(templates.join("layout.typ").is_file());
74        assert!(templates.join("layout-core.typ").is_file());
75        assert!(templates.join("physics-edge-style.typ").is_file());
76        assert!(templates.join("impl/physics-edge-style.typ").is_file());
77
78        assert!(templates
79            .join("crates/linnest/typst/linnest.wasm")
80            .is_file());
81        assert!(templates
82            .join("crates/linnest/typst/src/curve.typ")
83            .is_file());
84        assert!(templates
85            .join("crates/linnest/typst/src/impl/graph.typ")
86            .is_file());
87        assert!(templates
88            .join("crates/linnest/typst/src/render/layout.typ")
89            .is_file());
90        assert!(!templates
91            .join("crates/linnest/typst/src/physics-edge-style.typ")
92            .exists());
93        assert!(!templates
94            .join("crates/linnest/typst/src/impl/physics-edge-style.typ")
95            .exists());
96        assert!(templates.join("crates/kurvst/typst/kurvst.wasm").is_file());
97        assert!(templates.join("crates/kurvst/typst/src/lib.typ").is_file());
98        assert!(templates.join("crates/kurvst/typst/src/impl.typ").is_file());
99
100        assert!(!templates.join("linnest.wasm").exists());
101        assert!(!templates.join("kurvst.wasm").exists());
102        assert!(!templates.join("curve.typ").exists());
103        for package in [LINNEST_PACKAGE_DIR, KURVST_PACKAGE_DIR] {
104            for excluded in ["docs", "examples", "benches", "README.md", "LICENSE"] {
105                assert!(!templates.join(package).join(excluded).exists());
106            }
107        }
108
109        Assets::extract_justfile(tempdir.path())?;
110        assert!(tempdir.path().join("justfile").is_file());
111
112        Ok(())
113    }
114
115    #[cfg(unix)]
116    #[test]
117    #[ignore = "requires Typst, just, and LINNET_TEST_EXECUTABLE pointing to a built CLI"]
118    fn exported_model_drawings_render_and_cache_without_python() -> Result<()> {
119        use std::collections::BTreeMap;
120        use std::os::unix::fs::symlink;
121        use std::path::PathBuf;
122        use std::process::Command;
123
124        let temp = tempfile::tempdir()?;
125        let bin = temp.path().join("bin");
126        fs::create_dir(&bin)?;
127        let search_path = std::env::var_os("PATH").unwrap_or_default();
128        for (name, configured) in [
129            (
130                "linnet",
131                Some(
132                    std::env::var_os("LINNET_TEST_EXECUTABLE").expect("set LINNET_TEST_EXECUTABLE"),
133                ),
134            ),
135            ("typst", std::env::var_os("TYPST_TEST_EXECUTABLE")),
136            ("just", None),
137            ("sh", None),
138        ] {
139            let executable = configured.map(PathBuf::from).unwrap_or_else(|| {
140                std::env::split_paths(&search_path)
141                    .map(|directory| directory.join(name))
142                    .find(|path| path.is_file())
143                    .unwrap_or_else(|| panic!("{name} must be available on PATH"))
144            });
145            symlink(fs::canonicalize(executable)?, bin.join(name))?;
146        }
147        let absent = Command::new(bin.join("sh"))
148            .env("PATH", &bin)
149            .args(["-c", "! command -v python && ! command -v python3"])
150            .status()?;
151        assert!(absent.success());
152
153        Assets::extract_templates(temp.path())?;
154        Assets::extract_justfile(temp.path())?;
155        let templates = temp.path().join("drawings/templates");
156        let model = gammalooprs::utils::load_generic_model("scalars");
157        model.generate_edge_style_template(templates.join("edge-style.typ"))?;
158        let data = temp.path().join("processes/amplitudes/example.dot");
159        fs::create_dir_all(data.parent().unwrap())?;
160        fs::write(
161            &data,
162            r#"digraph amplitude {
163            a [pos="0,0!"]; b [pos="4,0!"];
164            incoming [style=invis]; outgoing [style=invis];
165            incoming -> a [particle="scalar_0", pos="z:4"];
166            a -> b [particle="scalar_0"];
167            b -> outgoing [particle="scalar_0", pos="z:-2"];
168        }"#,
169        )?;
170
171        for scenario in ["initial", "cached", "changed-source", "explicit-inputs"] {
172            if scenario == "changed-source" {
173                let implementation = templates.join("crates/linnest/typst/src/impl/draw.typ");
174                let source = fs::read_to_string(&implementation)?;
175                fs::write(
176                    implementation,
177                    format!("{source}\n// Cache invalidation probe.\n"),
178                )?;
179            }
180            let mut command = Command::new(bin.join("just"));
181            command.current_dir(temp.path()).env("PATH", &bin).args([
182                "steps=0",
183                "columns=1",
184                "draw",
185            ]);
186            if scenario == "explicit-inputs" {
187                command.args(["--input", "steps=2", "--input", "columns=2"]);
188            }
189            let output = command.output()?;
190            let expected = if scenario == "cached" {
191                "0 built, 1 reused"
192            } else {
193                "1 built, 0 reused"
194            };
195            assert!(
196                output.status.success(),
197                "{}",
198                String::from_utf8_lossy(&output.stderr)
199            );
200            assert!(
201                String::from_utf8_lossy(&output.stdout).contains(expected),
202                "{}",
203                String::from_utf8_lossy(&output.stdout)
204            );
205            for path in [
206                "drawings.pdf",
207                "drawings/figs/processes/amplitudes/example.pdf",
208            ] {
209                assert!(fs::read(temp.path().join(path))?.starts_with(b"%PDF-"));
210            }
211            let metadata: serde_json::Value = serde_json::from_slice(&fs::read(
212                temp.path().join("drawings/.cache/run-metadata.json"),
213            )?)?;
214            let inputs: Vec<(String, String)> = serde_json::from_value(metadata["input"].clone())?;
215            let inputs: BTreeMap<_, _> = inputs.into_iter().collect();
216            assert_eq!(
217                inputs["steps"],
218                if scenario == "explicit-inputs" {
219                    "2"
220                } else {
221                    "0"
222                }
223            );
224            assert_eq!(
225                inputs["columns"],
226                if scenario == "explicit-inputs" {
227                    "2"
228                } else {
229                    "1"
230                }
231            );
232        }
233
234        let assertions = temp.path().join("model-style.typ");
235        fs::write(
236            &assertions,
237            r#"#import "drawings/templates/edge-style.typ" as physics
238#import "drawings/templates/crates/linnest/typst/src/lib.typ": graph
239#let g = graph.parse(read("processes/amplitudes/example.dot")).first()
240#let particle-label = physics.map.at("scalar_0").label
241#let styles = physics.style(label-size: 12pt, label-fill: blue)
242#let expected-label = text(size: 12pt, fill: blue)[#particle-label]
243#assert(particle-label != none)
244#assert(graph.edges(g).len() == 3)
245// Drawing callbacks receive flattened fields, while graph.edges stores DOT
246// attributes in statements. Use the public mapper's normalized view.
247#let _ = graph.map(g, edge: edge => {
248  assert((styles.edge-label)(edge.fields) == expected-label)
249  none
250})
251[Model-generated particle labels work directly in Typst.]
252"#,
253        )?;
254        let output = Command::new(bin.join("typst"))
255            .env("PATH", &bin)
256            .arg("compile")
257            .arg("--root")
258            .arg(temp.path())
259            .arg(&assertions)
260            .arg(temp.path().join("model-style.pdf"))
261            .output()?;
262        assert!(
263            output.status.success(),
264            "{}",
265            String::from_utf8_lossy(&output.stderr)
266        );
267        Ok(())
268    }
269}