ggen_cli_lib/conventions/
resolver.rs1use anyhow::{Context, Result};
2use glob::glob;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone)]
9pub struct ProjectConventions {
10 pub rdf_files: Vec<PathBuf>,
12 pub rdf_dir: PathBuf,
14 pub templates: HashMap<String, PathBuf>,
16 pub templates_dir: PathBuf,
18 pub queries: HashMap<String, String>,
20 pub output_dir: PathBuf,
22 pub preset: String,
24}
25
26#[derive(Debug, Clone, Deserialize, Serialize, Default)]
28struct ConventionOverrides {
29 #[serde(default)]
30 rdf: RdfOverrides,
31 #[serde(default)]
32 templates: TemplatesOverrides,
33 #[serde(default)]
34 queries: QueriesOverrides,
35 #[serde(default)]
36 output: OutputOverrides,
37}
38
39#[derive(Debug, Clone, Deserialize, Serialize, Default)]
40struct RdfOverrides {
41 #[serde(default)]
42 patterns: Vec<String>,
43}
44
45#[derive(Debug, Clone, Deserialize, Serialize, Default)]
46struct TemplatesOverrides {
47 #[serde(default)]
48 patterns: Vec<String>,
49}
50
51#[derive(Debug, Clone, Deserialize, Serialize, Default)]
52struct QueriesOverrides {
53 #[serde(default)]
54 patterns: Vec<String>,
55}
56
57#[derive(Debug, Clone, Deserialize, Serialize, Default)]
58struct OutputOverrides {
59 #[serde(default)]
60 dir: Option<String>,
61}
62
63pub struct ConventionResolver {
65 project_root: PathBuf,
66}
67
68impl ConventionResolver {
69 pub fn new(project_root: impl Into<PathBuf>) -> Self {
71 Self {
72 project_root: project_root.into(),
73 }
74 }
75
76 pub fn discover(&self) -> Result<ProjectConventions> {
78 let overrides = self.load_overrides()?;
80
81 let rdf_files = self.discover_rdf(&overrides)?;
83
84 let templates = self.discover_templates(&overrides)?;
86
87 let queries = self.discover_queries(&overrides)?;
89
90 let output_dir = self.resolve_output_dir(&overrides);
92
93 Ok(ProjectConventions {
94 rdf_files,
95 rdf_dir: self.project_root.join("domain"),
96 templates,
97 templates_dir: self.project_root.join("templates"),
98 queries,
99 output_dir,
100 preset: "clap-noun-verb".to_string(), })
102 }
103
104 fn load_overrides(&self) -> Result<ConventionOverrides> {
106 let override_path = self.project_root.join(".ggen").join("conventions.toml");
107
108 if override_path.exists() {
109 let content = std::fs::read_to_string(&override_path)
110 .context("Failed to read conventions.toml")?;
111 let overrides: ConventionOverrides =
112 toml::from_str(&content).context("Failed to parse conventions.toml")?;
113 Ok(overrides)
114 } else {
115 Ok(ConventionOverrides::default())
116 }
117 }
118
119 fn discover_rdf(&self, overrides: &ConventionOverrides) -> Result<Vec<PathBuf>> {
121 let patterns = if overrides.rdf.patterns.is_empty() {
122 vec!["domain/**/*.ttl".to_string()]
123 } else {
124 overrides.rdf.patterns.clone()
125 };
126
127 let mut files = Vec::new();
128 for pattern in patterns {
129 let full_pattern = self.project_root.join(&pattern);
130 for entry in glob(&full_pattern.to_string_lossy())? {
131 files.push(entry?);
132 }
133 }
134
135 files.sort();
137
138 Ok(files)
139 }
140
141 fn discover_templates(&self, overrides: &ConventionOverrides) -> Result<HashMap<String, PathBuf>> {
143 let patterns = if overrides.templates.patterns.is_empty() {
144 vec!["templates/**/*.tmpl".to_string()]
145 } else {
146 overrides.templates.patterns.clone()
147 };
148
149 let mut templates = HashMap::new();
150 let templates_base = self.project_root.join("templates");
151
152 for pattern in patterns {
153 let full_pattern = self.project_root.join(&pattern);
154 for entry in glob(&full_pattern.to_string_lossy())? {
155 let path = entry?;
156
157 let name = if let Ok(rel_path) = path.strip_prefix(&templates_base) {
160 rel_path
161 .with_extension("") .to_string_lossy()
163 .to_string()
164 } else {
165 path.file_stem()
167 .and_then(|s| s.to_str())
168 .unwrap_or("unknown")
169 .to_string()
170 };
171
172 templates.insert(name, path);
173 }
174 }
175
176 Ok(templates)
177 }
178
179 fn discover_queries(&self, overrides: &ConventionOverrides) -> Result<HashMap<String, String>> {
181 let patterns = if overrides.queries.patterns.is_empty() {
182 vec!["queries/**/*.sparql".to_string()]
183 } else {
184 overrides.queries.patterns.clone()
185 };
186
187 let mut queries = HashMap::new();
188 let queries_base = self.project_root.join("queries");
189
190 for pattern in patterns {
191 let full_pattern = self.project_root.join(&pattern);
192 for entry in glob(&full_pattern.to_string_lossy())? {
193 let path = entry?;
194
195 let content = std::fs::read_to_string(&path)
197 .with_context(|| format!("Failed to read query file: {:?}", path))?;
198
199 let name = if let Ok(rel_path) = path.strip_prefix(&queries_base) {
202 rel_path
203 .with_extension("") .to_string_lossy()
205 .to_string()
206 } else {
207 path.file_stem()
209 .and_then(|s| s.to_str())
210 .unwrap_or("unknown")
211 .to_string()
212 };
213
214 queries.insert(name, content);
215 }
216 }
217
218 Ok(queries)
219 }
220
221 fn resolve_output_dir(&self, overrides: &ConventionOverrides) -> PathBuf {
223 if let Some(ref dir) = overrides.output.dir {
224 self.project_root.join(dir)
225 } else {
226 self.project_root.join("generated")
227 }
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use std::fs;
235 use tempfile::TempDir;
236
237 fn setup_test_project() -> TempDir {
238 let temp_dir = TempDir::new().unwrap();
239 let root = temp_dir.path();
240
241 fs::create_dir_all(root.join("domain")).unwrap();
243 fs::create_dir_all(root.join("templates/api")).unwrap();
244 fs::create_dir_all(root.join("queries/user")).unwrap();
245
246 fs::write(root.join("domain/user.ttl"), "@prefix ex: <http://example.org/> .").unwrap();
248 fs::write(root.join("domain/order.ttl"), "@prefix ex: <http://example.org/> .").unwrap();
249
250 fs::write(root.join("templates/main.tmpl"), "Hello {{ name }}").unwrap();
252 fs::write(root.join("templates/api/user.tmpl"), "User API").unwrap();
253
254 fs::write(
256 root.join("queries/user/find.sparql"),
257 "SELECT * WHERE { ?s ?p ?o }",
258 )
259 .unwrap();
260
261 temp_dir
262 }
263
264 #[test]
265 fn test_new() {
266 let temp_dir = TempDir::new().unwrap();
267 let resolver = ConventionResolver::new(temp_dir.path());
268
269 assert_eq!(resolver.project_root, temp_dir.path());
270 }
271
272 #[test]
273 fn test_discover_rdf_files() {
274 let temp_dir = setup_test_project();
275 let resolver = ConventionResolver::new(temp_dir.path());
276
277 let conventions = resolver.discover().unwrap();
278
279 assert_eq!(conventions.rdf_files.len(), 2);
280 assert!(conventions
281 .rdf_files
282 .iter()
283 .any(|p| p.ends_with("user.ttl")));
284 assert!(conventions
285 .rdf_files
286 .iter()
287 .any(|p| p.ends_with("order.ttl")));
288
289 assert!(conventions.rdf_files[0].ends_with("order.ttl"));
291 assert!(conventions.rdf_files[1].ends_with("user.ttl"));
292 }
293
294 #[test]
295 fn test_discover_templates() {
296 let temp_dir = setup_test_project();
297 let resolver = ConventionResolver::new(temp_dir.path());
298
299 let conventions = resolver.discover().unwrap();
300
301 assert_eq!(conventions.templates.len(), 2);
302 assert!(conventions.templates.contains_key("main"));
303 assert!(conventions.templates.contains_key("api/user"));
304
305 let main_path = &conventions.templates["main"];
306 assert!(main_path.ends_with("templates/main.tmpl"));
307 }
308
309 #[test]
310 fn test_discover_queries() {
311 let temp_dir = setup_test_project();
312 let resolver = ConventionResolver::new(temp_dir.path());
313
314 let conventions = resolver.discover().unwrap();
315
316 assert_eq!(conventions.queries.len(), 1);
317 assert!(conventions.queries.contains_key("user/find"));
318
319 let query = &conventions.queries["user/find"];
320 assert!(query.contains("SELECT * WHERE"));
321 }
322
323 #[test]
324 fn test_resolve_output_dir_default() {
325 let temp_dir = TempDir::new().unwrap();
326 let resolver = ConventionResolver::new(temp_dir.path());
327
328 let conventions = resolver.discover().unwrap();
329
330 assert_eq!(
331 conventions.output_dir,
332 temp_dir.path().join("generated")
333 );
334 }
335
336 #[test]
337 fn test_resolve_output_dir_override() {
338 let temp_dir = TempDir::new().unwrap();
339 let root = temp_dir.path();
340
341 fs::create_dir_all(root.join(".ggen")).unwrap();
343 fs::write(
344 root.join(".ggen/conventions.toml"),
345 r#"
346[output]
347dir = "build/generated"
348"#,
349 )
350 .unwrap();
351
352 let resolver = ConventionResolver::new(root);
353 let conventions = resolver.discover().unwrap();
354
355 assert_eq!(conventions.output_dir, root.join("build/generated"));
356 }
357
358 #[test]
359 fn test_override_rdf_patterns() {
360 let temp_dir = TempDir::new().unwrap();
361 let root = temp_dir.path();
362
363 fs::create_dir_all(root.join("ontology")).unwrap();
365 fs::write(
366 root.join("ontology/custom.ttl"),
367 "@prefix ex: <http://example.org/> .",
368 )
369 .unwrap();
370
371 fs::create_dir_all(root.join(".ggen")).unwrap();
373 fs::write(
374 root.join(".ggen/conventions.toml"),
375 r#"
376[rdf]
377patterns = ["ontology/**/*.ttl"]
378"#,
379 )
380 .unwrap();
381
382 let resolver = ConventionResolver::new(root);
383 let conventions = resolver.discover().unwrap();
384
385 assert_eq!(conventions.rdf_files.len(), 1);
386 assert!(conventions.rdf_files[0].ends_with("custom.ttl"));
387 }
388
389 #[test]
390 fn test_override_template_patterns() {
391 let temp_dir = TempDir::new().unwrap();
392 let root = temp_dir.path();
393
394 fs::create_dir_all(root.join("views")).unwrap();
396 fs::write(root.join("views/page.tmpl"), "Page template").unwrap();
397
398 fs::create_dir_all(root.join(".ggen")).unwrap();
400 fs::write(
401 root.join(".ggen/conventions.toml"),
402 r#"
403[templates]
404patterns = ["views/**/*.tmpl"]
405"#,
406 )
407 .unwrap();
408
409 let resolver = ConventionResolver::new(root);
410 let conventions = resolver.discover().unwrap();
411
412 assert_eq!(conventions.templates.len(), 1);
413 assert!(conventions.templates.values().any(|p| p.ends_with("page.tmpl")));
415 }
416
417 #[test]
418 fn test_override_query_patterns() {
419 let temp_dir = TempDir::new().unwrap();
420 let root = temp_dir.path();
421
422 fs::create_dir_all(root.join("sparql")).unwrap();
424 fs::write(
425 root.join("sparql/select.sparql"),
426 "SELECT * WHERE { ?s ?p ?o }",
427 )
428 .unwrap();
429
430 fs::create_dir_all(root.join(".ggen")).unwrap();
432 fs::write(
433 root.join(".ggen/conventions.toml"),
434 r#"
435[queries]
436patterns = ["sparql/**/*.sparql"]
437"#,
438 )
439 .unwrap();
440
441 let resolver = ConventionResolver::new(root);
442 let conventions = resolver.discover().unwrap();
443
444 assert_eq!(conventions.queries.len(), 1);
445 assert!(conventions
446 .queries
447 .values()
448 .any(|c| c.contains("SELECT * WHERE")));
449 }
450
451 #[test]
452 fn test_empty_project() {
453 let temp_dir = TempDir::new().unwrap();
454 let resolver = ConventionResolver::new(temp_dir.path());
455
456 let conventions = resolver.discover().unwrap();
457
458 assert!(conventions.rdf_files.is_empty());
459 assert!(conventions.templates.is_empty());
460 assert!(conventions.queries.is_empty());
461 assert_eq!(
462 conventions.output_dir,
463 temp_dir.path().join("generated")
464 );
465 }
466
467 #[test]
468 fn test_nested_template_names() {
469 let temp_dir = TempDir::new().unwrap();
470 let root = temp_dir.path();
471
472 fs::create_dir_all(root.join("templates/api/v1")).unwrap();
474 fs::write(root.join("templates/api/v1/user.tmpl"), "User API").unwrap();
475
476 let resolver = ConventionResolver::new(root);
477 let conventions = resolver.discover().unwrap();
478
479 assert_eq!(conventions.templates.len(), 1);
480 assert!(conventions.templates.contains_key("api/v1/user"));
481 }
482
483 #[test]
484 fn test_load_overrides_invalid_toml() {
485 let temp_dir = TempDir::new().unwrap();
486 let root = temp_dir.path();
487
488 fs::create_dir_all(root.join(".ggen")).unwrap();
490 fs::write(
491 root.join(".ggen/conventions.toml"),
492 "invalid toml content [[[",
493 )
494 .unwrap();
495
496 let resolver = ConventionResolver::new(root);
497
498 assert!(resolver.discover().is_err());
500 }
501}