ggen_cli_lib/conventions/
resolver.rs1use ggen_core::utils::error::{Context, Result};
39use glob::glob;
40use serde::{Deserialize, Serialize};
41use std::collections::HashMap;
42use std::path::PathBuf;
43
44#[derive(Debug, Clone)]
46pub struct ProjectConventions {
47 pub rdf_files: Vec<PathBuf>,
49 pub rdf_dir: PathBuf,
51 pub templates: HashMap<String, PathBuf>,
53 pub templates_dir: PathBuf,
55 pub queries: HashMap<String, String>,
57 pub output_dir: PathBuf,
59 pub preset: String,
61}
62
63#[derive(Debug, Clone, Deserialize, Serialize, Default)]
65struct ConventionOverrides {
66 #[serde(default)]
67 rdf: RdfOverrides,
68 #[serde(default)]
69 templates: TemplatesOverrides,
70 #[serde(default)]
71 queries: QueriesOverrides,
72 #[serde(default)]
73 output: OutputOverrides,
74}
75
76#[derive(Debug, Clone, Deserialize, Serialize, Default)]
77struct RdfOverrides {
78 #[serde(default)]
79 patterns: Vec<String>,
80}
81
82#[derive(Debug, Clone, Deserialize, Serialize, Default)]
83struct TemplatesOverrides {
84 #[serde(default)]
85 patterns: Vec<String>,
86}
87
88#[derive(Debug, Clone, Deserialize, Serialize, Default)]
89struct QueriesOverrides {
90 #[serde(default)]
91 patterns: Vec<String>,
92}
93
94#[derive(Debug, Clone, Deserialize, Serialize, Default)]
95struct OutputOverrides {
96 #[serde(default)]
97 dir: Option<String>,
98}
99
100pub struct ConventionResolver {
102 project_root: PathBuf,
103}
104
105impl ConventionResolver {
106 pub fn new(project_root: impl Into<PathBuf>) -> Self {
108 Self {
109 project_root: project_root.into(),
110 }
111 }
112
113 pub fn discover(&self) -> Result<ProjectConventions> {
115 let overrides = self.load_overrides()?;
117
118 let rdf_files = self.discover_rdf(&overrides)?;
120
121 let templates = self.discover_templates(&overrides)?;
123
124 let queries = self.discover_queries(&overrides)?;
126
127 let output_dir = self.resolve_output_dir(&overrides);
129
130 Ok(ProjectConventions {
131 rdf_files,
132 rdf_dir: self.project_root.join("domain"),
133 templates,
134 templates_dir: self.project_root.join("templates"),
135 queries,
136 output_dir,
137 preset: "clap-noun-verb".to_string(), })
139 }
140
141 fn load_overrides(&self) -> Result<ConventionOverrides> {
143 let override_path = self.project_root.join(".ggen").join("conventions.toml");
144
145 if override_path.exists() {
146 let content = std::fs::read_to_string(&override_path).map_err(|e| {
147 ggen_core::utils::error::Error::new(&format!(
148 "Failed to read conventions.toml: {}",
149 e
150 ))
151 })?;
152 let overrides: ConventionOverrides = Context::context(
153 toml::from_str(&content).map_err(|e| {
154 ggen_core::utils::error::Error::new(&format!(
155 "Failed to parse conventions.toml: {}",
156 e
157 ))
158 }),
159 "Failed to parse conventions.toml",
160 )?;
161 Ok(overrides)
162 } else {
163 Ok(ConventionOverrides::default())
164 }
165 }
166
167 fn discover_rdf(&self, overrides: &ConventionOverrides) -> Result<Vec<PathBuf>> {
169 let patterns = if overrides.rdf.patterns.is_empty() {
170 vec!["domain/**/*.ttl".to_string()]
171 } else {
172 overrides.rdf.patterns.clone()
173 };
174
175 let mut files = Vec::new();
176 for pattern in patterns {
177 let full_pattern = self.project_root.join(&pattern);
178 for entry in glob(&full_pattern.to_string_lossy()).map_err(|e| {
179 ggen_core::utils::error::Error::new(&format!(
180 "Failed to glob pattern {}: {}",
181 full_pattern.display(),
182 e
183 ))
184 })? {
185 files.push(entry.map_err(|e| {
186 ggen_core::utils::error::Error::new(&format!(
187 "Failed to read glob entry: {}",
188 e
189 ))
190 })?);
191 }
192 }
193
194 files.sort();
196
197 Ok(files)
198 }
199
200 fn discover_templates(
202 &self, overrides: &ConventionOverrides,
203 ) -> Result<HashMap<String, PathBuf>> {
204 let patterns = if overrides.templates.patterns.is_empty() {
205 vec!["templates/**/*.tmpl".to_string()]
206 } else {
207 overrides.templates.patterns.clone()
208 };
209
210 let mut templates = HashMap::new();
211 let templates_base = self.project_root.join("templates");
212
213 for pattern in patterns {
214 let full_pattern = self.project_root.join(&pattern);
215 for entry in glob(&full_pattern.to_string_lossy()).map_err(|e| {
216 ggen_core::utils::error::Error::new(&format!(
217 "Failed to glob pattern {}: {}",
218 full_pattern.display(),
219 e
220 ))
221 })? {
222 let path = entry.map_err(|e| {
223 ggen_core::utils::error::Error::new(&format!(
224 "Failed to read glob entry: {}",
225 e
226 ))
227 })?;
228
229 let name = if let Ok(rel_path) = path.strip_prefix(&templates_base) {
232 rel_path
233 .with_extension("") .to_string_lossy()
235 .to_string()
236 } else {
237 match path.file_stem().and_then(|s| s.to_str()) {
239 Some(stem) => stem.to_string(),
240 None => {
241 log::warn!(
242 "Could not extract template name from path: {}",
243 path.display()
244 );
245 continue; }
247 }
248 };
249
250 templates.insert(name, path);
251 }
252 }
253
254 Ok(templates)
255 }
256
257 fn discover_queries(&self, overrides: &ConventionOverrides) -> Result<HashMap<String, String>> {
259 let patterns = if overrides.queries.patterns.is_empty() {
260 vec!["queries/**/*.sparql".to_string()]
261 } else {
262 overrides.queries.patterns.clone()
263 };
264
265 let mut queries = HashMap::new();
266 let queries_base = self.project_root.join("queries");
267
268 for pattern in patterns {
269 let full_pattern = self.project_root.join(&pattern);
270 for entry in glob(&full_pattern.to_string_lossy()).map_err(|e| {
271 ggen_core::utils::error::Error::new(&format!(
272 "Failed to glob pattern {}: {}",
273 full_pattern.display(),
274 e
275 ))
276 })? {
277 let path = entry.map_err(|e| {
278 ggen_core::utils::error::Error::new(&format!(
279 "Failed to read glob entry: {}",
280 e
281 ))
282 })?;
283
284 let content = Context::with_context(
286 std::fs::read_to_string(&path).map_err(|e| {
287 ggen_core::utils::error::Error::new(&format!(
288 "Failed to read query file {}: {}",
289 path.display(),
290 e
291 ))
292 }),
293 || format!("Failed to read query file: {}", path.display()),
294 )?;
295
296 let name = if let Ok(rel_path) = path.strip_prefix(&queries_base) {
299 rel_path
300 .with_extension("") .to_string_lossy()
302 .to_string()
303 } else {
304 match path.file_stem().and_then(|s| s.to_str()) {
306 Some(stem) => stem.to_string(),
307 None => {
308 log::warn!(
309 "Could not extract query name from path: {}",
310 path.display()
311 );
312 continue; }
314 }
315 };
316
317 queries.insert(name, content);
318 }
319 }
320
321 Ok(queries)
322 }
323
324 fn resolve_output_dir(&self, overrides: &ConventionOverrides) -> PathBuf {
326 if let Some(ref dir) = overrides.output.dir {
327 self.project_root.join(dir)
328 } else {
329 self.project_root.clone()
330 }
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use std::fs;
338 use tempfile::TempDir;
339
340 fn setup_test_project() -> TempDir {
341 let temp_dir = TempDir::new().unwrap();
342 let root = temp_dir.path();
343
344 fs::create_dir_all(root.join("domain")).unwrap();
346 fs::create_dir_all(root.join("templates/api")).unwrap();
347 fs::create_dir_all(root.join("queries/user")).unwrap();
348
349 fs::write(
351 root.join("domain/user.ttl"),
352 "@prefix ex: <http://example.org/> .",
353 )
354 .unwrap();
355 fs::write(
356 root.join("domain/order.ttl"),
357 "@prefix ex: <http://example.org/> .",
358 )
359 .unwrap();
360
361 fs::write(root.join("templates/main.tmpl"), "Hello {{ name }}").unwrap();
363 fs::write(root.join("templates/api/user.tmpl"), "User API").unwrap();
364
365 fs::write(
367 root.join("queries/user/find.sparql"),
368 "SELECT * WHERE { ?s ?p ?o }",
369 )
370 .unwrap();
371
372 temp_dir
373 }
374
375 #[test]
376 fn test_new() {
377 let temp_dir = TempDir::new().unwrap();
378 let resolver = ConventionResolver::new(temp_dir.path());
379
380 assert_eq!(resolver.project_root, temp_dir.path());
381 }
382
383 #[test]
384 fn test_discover_rdf_files() {
385 let temp_dir = setup_test_project();
386 let resolver = ConventionResolver::new(temp_dir.path());
387
388 let conventions = resolver.discover().unwrap();
389
390 assert_eq!(conventions.rdf_files.len(), 2);
391 assert!(conventions
392 .rdf_files
393 .iter()
394 .any(|p| p.ends_with("user.ttl")));
395 assert!(conventions
396 .rdf_files
397 .iter()
398 .any(|p| p.ends_with("order.ttl")));
399
400 assert!(conventions.rdf_files[0].ends_with("order.ttl"));
402 assert!(conventions.rdf_files[1].ends_with("user.ttl"));
403 }
404
405 #[test]
406 fn test_discover_templates() {
407 let temp_dir = setup_test_project();
408 let resolver = ConventionResolver::new(temp_dir.path());
409
410 let conventions = resolver.discover().unwrap();
411
412 assert_eq!(conventions.templates.len(), 2);
413 assert!(conventions.templates.contains_key("main"));
414 assert!(conventions.templates.contains_key("api/user"));
415
416 let main_path = &conventions.templates["main"];
417 assert!(main_path.ends_with("templates/main.tmpl"));
418 }
419
420 #[test]
421 fn test_discover_queries() {
422 let temp_dir = setup_test_project();
423 let resolver = ConventionResolver::new(temp_dir.path());
424
425 let conventions = resolver.discover().unwrap();
426
427 assert_eq!(conventions.queries.len(), 1);
428 assert!(conventions.queries.contains_key("user/find"));
429
430 let query = &conventions.queries["user/find"];
431 assert!(query.contains("SELECT * WHERE"));
432 }
433
434 #[test]
435 fn test_resolve_output_dir_default() {
436 let temp_dir = TempDir::new().unwrap();
437 let resolver = ConventionResolver::new(temp_dir.path());
438
439 let conventions = resolver.discover().unwrap();
440
441 assert_eq!(conventions.output_dir, temp_dir.path());
442 }
443
444 #[test]
445 fn test_resolve_output_dir_override() {
446 let temp_dir = TempDir::new().unwrap();
447 let root = temp_dir.path();
448
449 fs::create_dir_all(root.join(".ggen")).unwrap();
451 fs::write(
452 root.join(".ggen/conventions.toml"),
453 r#"
454[output]
455dir = "build/generated"
456"#,
457 )
458 .unwrap();
459
460 let resolver = ConventionResolver::new(root);
461 let conventions = resolver.discover().unwrap();
462
463 assert_eq!(conventions.output_dir, root.join("build/generated"));
464 }
465
466 #[test]
467 fn test_override_rdf_patterns() {
468 let temp_dir = TempDir::new().unwrap();
469 let root = temp_dir.path();
470
471 fs::create_dir_all(root.join("ontology")).unwrap();
473 fs::write(
474 root.join("ontology/custom.ttl"),
475 "@prefix ex: <http://example.org/> .",
476 )
477 .unwrap();
478
479 fs::create_dir_all(root.join(".ggen")).unwrap();
481 fs::write(
482 root.join(".ggen/conventions.toml"),
483 r#"
484[rdf]
485patterns = ["ontology/**/*.ttl"]
486"#,
487 )
488 .unwrap();
489
490 let resolver = ConventionResolver::new(root);
491 let conventions = resolver.discover().unwrap();
492
493 assert_eq!(conventions.rdf_files.len(), 1);
494 assert!(conventions.rdf_files[0].ends_with("custom.ttl"));
495 }
496
497 #[test]
498 fn test_override_template_patterns() {
499 let temp_dir = TempDir::new().unwrap();
500 let root = temp_dir.path();
501
502 fs::create_dir_all(root.join("views")).unwrap();
504 fs::write(root.join("views/page.tmpl"), "Page template").unwrap();
505
506 fs::create_dir_all(root.join(".ggen")).unwrap();
508 fs::write(
509 root.join(".ggen/conventions.toml"),
510 r#"
511[templates]
512patterns = ["views/**/*.tmpl"]
513"#,
514 )
515 .unwrap();
516
517 let resolver = ConventionResolver::new(root);
518 let conventions = resolver.discover().unwrap();
519
520 assert_eq!(conventions.templates.len(), 1);
521 assert!(conventions
523 .templates
524 .values()
525 .any(|p| p.ends_with("page.tmpl")));
526 }
527
528 #[test]
529 fn test_override_query_patterns() {
530 let temp_dir = TempDir::new().unwrap();
531 let root = temp_dir.path();
532
533 fs::create_dir_all(root.join("sparql")).unwrap();
535 fs::write(
536 root.join("sparql/select.sparql"),
537 "SELECT * WHERE { ?s ?p ?o }",
538 )
539 .unwrap();
540
541 fs::create_dir_all(root.join(".ggen")).unwrap();
543 fs::write(
544 root.join(".ggen/conventions.toml"),
545 r#"
546[queries]
547patterns = ["sparql/**/*.sparql"]
548"#,
549 )
550 .unwrap();
551
552 let resolver = ConventionResolver::new(root);
553 let conventions = resolver.discover().unwrap();
554
555 assert_eq!(conventions.queries.len(), 1);
556 assert!(conventions
557 .queries
558 .values()
559 .any(|c| c.contains("SELECT * WHERE")));
560 }
561
562 #[test]
563 fn test_empty_project() {
564 let temp_dir = TempDir::new().unwrap();
565 let resolver = ConventionResolver::new(temp_dir.path());
566
567 let conventions = resolver.discover().unwrap();
568
569 assert!(conventions.rdf_files.is_empty());
570 assert!(conventions.templates.is_empty());
571 assert!(conventions.queries.is_empty());
572 assert_eq!(conventions.output_dir, temp_dir.path());
573 }
574
575 #[test]
576 fn test_nested_template_names() {
577 let temp_dir = TempDir::new().unwrap();
578 let root = temp_dir.path();
579
580 fs::create_dir_all(root.join("templates/api/v1")).unwrap();
582 fs::write(root.join("templates/api/v1/user.tmpl"), "User API").unwrap();
583
584 let resolver = ConventionResolver::new(root);
585 let conventions = resolver.discover().unwrap();
586
587 assert_eq!(conventions.templates.len(), 1);
588 assert!(conventions.templates.contains_key("api/v1/user"));
589 }
590
591 #[test]
592 fn test_load_overrides_invalid_toml() {
593 let temp_dir = TempDir::new().unwrap();
594 let root = temp_dir.path();
595
596 fs::create_dir_all(root.join(".ggen")).unwrap();
598 fs::write(
599 root.join(".ggen/conventions.toml"),
600 "invalid toml content [[[",
601 )
602 .unwrap();
603
604 let resolver = ConventionResolver::new(root);
605
606 assert!(resolver.discover().is_err());
608 }
609}