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