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