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}