Skip to main content

weaveffi_core/
codegen.rs

1use anyhow::{bail, Result};
2use camino::Utf8Path;
3use weaveffi_ir::ir::Api;
4
5use crate::cache;
6use crate::config::GeneratorConfig;
7use crate::templates::TemplateEngine;
8
9fn run_hook(label: &str, cmd: &str) -> Result<()> {
10    let status = if cfg!(target_os = "windows") {
11        std::process::Command::new("cmd")
12            .args(["/C", cmd])
13            .status()?
14    } else {
15        std::process::Command::new("sh")
16            .arg("-c")
17            .arg(cmd)
18            .status()?
19    };
20    if !status.success() {
21        bail!("{label} hook failed with {status}");
22    }
23    Ok(())
24}
25
26pub trait Generator {
27    fn name(&self) -> &'static str;
28    fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()>;
29
30    fn generate_with_config(
31        &self,
32        api: &Api,
33        out_dir: &Utf8Path,
34        _config: &GeneratorConfig,
35    ) -> Result<()> {
36        self.generate(api, out_dir)
37    }
38
39    fn generate_with_templates(
40        &self,
41        api: &Api,
42        out_dir: &Utf8Path,
43        config: &GeneratorConfig,
44        _templates: Option<&TemplateEngine>,
45    ) -> Result<()> {
46        self.generate_with_config(api, out_dir, config)
47    }
48
49    fn output_files(&self, _api: &Api, _out_dir: &Utf8Path) -> Vec<String> {
50        vec![]
51    }
52
53    fn output_files_with_config(
54        &self,
55        api: &Api,
56        out_dir: &Utf8Path,
57        _config: &GeneratorConfig,
58    ) -> Vec<String> {
59        self.output_files(api, out_dir)
60    }
61}
62
63#[derive(Default)]
64pub struct Orchestrator<'a> {
65    generators: Vec<&'a dyn Generator>,
66}
67
68impl<'a> Orchestrator<'a> {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    pub fn with_generator(mut self, gen: &'a dyn Generator) -> Self {
74        self.generators.push(gen);
75        self
76    }
77
78    pub fn run(
79        &self,
80        api: &Api,
81        out_dir: &Utf8Path,
82        config: &GeneratorConfig,
83        force: bool,
84        templates: Option<&TemplateEngine>,
85    ) -> Result<()> {
86        let hash = cache::hash_api(api);
87
88        if !force {
89            if let Some(cached) = cache::read_cache(out_dir) {
90                if cached == hash {
91                    println!("No changes detected, skipping code generation.");
92                    return Ok(());
93                }
94            }
95        }
96
97        if let Some(cmd) = &config.pre_generate {
98            run_hook("pre_generate", cmd)?;
99        }
100
101        for g in &self.generators {
102            g.generate_with_templates(api, out_dir, config, templates)?;
103        }
104
105        if let Some(cmd) = &config.post_generate {
106            run_hook("post_generate", cmd)?;
107        }
108
109        cache::write_cache(out_dir, &hash)?;
110        Ok(())
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use std::sync::atomic::{AtomicUsize, Ordering};
118    use std::sync::Arc;
119    use weaveffi_ir::ir::{Function, Module, Param, TypeRef};
120
121    struct CountingGenerator {
122        calls: Arc<AtomicUsize>,
123    }
124
125    impl Generator for CountingGenerator {
126        fn name(&self) -> &'static str {
127            "counting"
128        }
129
130        fn generate(&self, _api: &Api, out_dir: &Utf8Path) -> Result<()> {
131            self.calls.fetch_add(1, Ordering::SeqCst);
132            std::fs::write(out_dir.join("output.txt").as_std_path(), "generated")?;
133            Ok(())
134        }
135    }
136
137    fn test_api() -> Api {
138        Api {
139            version: "0.1.0".to_string(),
140            modules: vec![Module {
141                name: "math".to_string(),
142                functions: vec![Function {
143                    name: "add".to_string(),
144                    params: vec![
145                        Param {
146                            name: "a".to_string(),
147                            ty: TypeRef::I32,
148                            mutable: false,
149                        },
150                        Param {
151                            name: "b".to_string(),
152                            ty: TypeRef::I32,
153                            mutable: false,
154                        },
155                    ],
156                    returns: Some(TypeRef::I32),
157                    doc: None,
158                    r#async: false,
159                    cancellable: false,
160                    deprecated: None,
161                    since: None,
162                }],
163                structs: vec![],
164                enums: vec![],
165                callbacks: vec![],
166                listeners: vec![],
167                errors: None,
168                modules: vec![],
169            }],
170            generators: None,
171        }
172    }
173
174    #[test]
175    fn incremental_skips_when_unchanged() {
176        let dir = tempfile::tempdir().unwrap();
177        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
178        let api = test_api();
179        let config = GeneratorConfig::default();
180        let calls = Arc::new(AtomicUsize::new(0));
181        let gen = CountingGenerator {
182            calls: Arc::clone(&calls),
183        };
184
185        let orch = Orchestrator::new().with_generator(&gen);
186
187        orch.run(&api, out_dir, &config, false, None).unwrap();
188        assert_eq!(calls.load(Ordering::SeqCst), 1);
189        let content_after_first = std::fs::read_to_string(out_dir.join("output.txt")).unwrap();
190
191        orch.run(&api, out_dir, &config, false, None).unwrap();
192        assert_eq!(
193            calls.load(Ordering::SeqCst),
194            1,
195            "generator should not run again"
196        );
197        let content_after_second = std::fs::read_to_string(out_dir.join("output.txt")).unwrap();
198
199        assert_eq!(content_after_first, content_after_second);
200    }
201
202    #[test]
203    fn force_bypasses_cache() {
204        let dir = tempfile::tempdir().unwrap();
205        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
206        let api = test_api();
207        let config = GeneratorConfig::default();
208        let calls = Arc::new(AtomicUsize::new(0));
209        let gen = CountingGenerator {
210            calls: Arc::clone(&calls),
211        };
212
213        let orch = Orchestrator::new().with_generator(&gen);
214
215        orch.run(&api, out_dir, &config, false, None).unwrap();
216        assert_eq!(calls.load(Ordering::SeqCst), 1);
217
218        orch.run(&api, out_dir, &config, true, None).unwrap();
219        assert_eq!(calls.load(Ordering::SeqCst), 2, "force should bypass cache");
220    }
221
222    #[test]
223    fn generate_with_custom_templates_dir() {
224        use crate::templates::TemplateEngine;
225
226        let tpl_dir = tempfile::tempdir().unwrap();
227        let tpl_path = Utf8Path::from_path(tpl_dir.path()).unwrap();
228        std::fs::write(tpl_path.join("greeting.tera"), "Hello from {{ name }}!").unwrap();
229
230        let mut engine = TemplateEngine::new();
231        engine.load_dir(tpl_path).unwrap();
232
233        let mut ctx = tera::Context::new();
234        ctx.insert("name", "user-templates");
235        let rendered = engine.render("greeting.tera", &ctx).unwrap();
236        assert_eq!(rendered, "Hello from user-templates!");
237
238        let dir = tempfile::tempdir().unwrap();
239        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
240        let api = test_api();
241        let config = GeneratorConfig::default();
242        let calls = Arc::new(AtomicUsize::new(0));
243        let gen = CountingGenerator {
244            calls: Arc::clone(&calls),
245        };
246
247        let orch = Orchestrator::new().with_generator(&gen);
248        orch.run(&api, out_dir, &config, true, Some(&engine))
249            .unwrap();
250        assert_eq!(calls.load(Ordering::SeqCst), 1);
251    }
252
253    #[test]
254    fn pre_hook_runs_before_generate() {
255        let dir = tempfile::tempdir().unwrap();
256        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
257        let api = test_api();
258        let config = GeneratorConfig {
259            pre_generate: Some("echo ok".into()),
260            ..Default::default()
261        };
262        let calls = Arc::new(AtomicUsize::new(0));
263        let gen = CountingGenerator {
264            calls: Arc::clone(&calls),
265        };
266
267        let orch = Orchestrator::new().with_generator(&gen);
268        orch.run(&api, out_dir, &config, true, None).unwrap();
269        assert_eq!(calls.load(Ordering::SeqCst), 1);
270    }
271
272    #[test]
273    fn pre_hook_failure_aborts() {
274        let dir = tempfile::tempdir().unwrap();
275        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
276        let api = test_api();
277        let config = GeneratorConfig {
278            pre_generate: Some("exit 1".into()),
279            ..Default::default()
280        };
281        let calls = Arc::new(AtomicUsize::new(0));
282        let gen = CountingGenerator {
283            calls: Arc::clone(&calls),
284        };
285
286        let orch = Orchestrator::new().with_generator(&gen);
287        let result = orch.run(&api, out_dir, &config, true, None);
288        assert!(result.is_err());
289        assert_eq!(calls.load(Ordering::SeqCst), 0, "generator should not run");
290    }
291
292    #[test]
293    fn post_hook_runs_after_generate() {
294        let dir = tempfile::tempdir().unwrap();
295        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
296        let api = test_api();
297        let config = GeneratorConfig {
298            post_generate: Some("echo ok".into()),
299            ..Default::default()
300        };
301        let calls = Arc::new(AtomicUsize::new(0));
302        let gen = CountingGenerator {
303            calls: Arc::clone(&calls),
304        };
305
306        let orch = Orchestrator::new().with_generator(&gen);
307        orch.run(&api, out_dir, &config, true, None).unwrap();
308        assert_eq!(calls.load(Ordering::SeqCst), 1);
309    }
310
311    #[test]
312    fn post_hook_failure_returns_error() {
313        let dir = tempfile::tempdir().unwrap();
314        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
315        let api = test_api();
316        let config = GeneratorConfig {
317            post_generate: Some("exit 42".into()),
318            ..Default::default()
319        };
320        let calls = Arc::new(AtomicUsize::new(0));
321        let gen = CountingGenerator {
322            calls: Arc::clone(&calls),
323        };
324
325        let orch = Orchestrator::new().with_generator(&gen);
326        let result = orch.run(&api, out_dir, &config, true, None);
327        assert!(result.is_err());
328        assert_eq!(calls.load(Ordering::SeqCst), 1, "generator should have run");
329    }
330}