ggen_cli_lib/conventions/
resolver.rs1use ggen_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_utils::error::Error::new(&format!("Failed to read conventions.toml: {}", e))
149 })?;
150 let overrides: ConventionOverrides = Context::context(
151 toml::from_str(&content).map_err(|e| {
152 ggen_utils::error::Error::new(&format!(
153 "Failed to parse conventions.toml: {}",
154 e
155 ))
156 }),
157 "Failed to parse conventions.toml",
158 )?;
159 Ok(overrides)
160 } else {
161 Ok(ConventionOverrides::default())
162 }
163 }
164
165 fn discover_rdf(&self, overrides: &ConventionOverrides) -> Result<Vec<PathBuf>> {
167 let patterns = if overrides.rdf.patterns.is_empty() {
168 vec!["domain/**/*.ttl".to_string()]
169 } else {
170 overrides.rdf.patterns.clone()
171 };
172
173 let mut files = Vec::new();
174 for pattern in patterns {
175 let full_pattern = self.project_root.join(&pattern);
176 for entry in glob(&full_pattern.to_string_lossy()).map_err(|e| {
177 ggen_utils::error::Error::new(&format!(
178 "Failed to glob pattern {}: {}",
179 full_pattern.display(),
180 e
181 ))
182 })? {
183 files.push(entry.map_err(|e| {
184 ggen_utils::error::Error::new(&format!("Failed to read glob entry: {}", e))
185 })?);
186 }
187 }
188
189 files.sort();
191
192 Ok(files)
193 }
194
195 fn discover_templates(
197 &self, overrides: &ConventionOverrides,
198 ) -> Result<HashMap<String, PathBuf>> {
199 let patterns = if overrides.templates.patterns.is_empty() {
200 vec!["templates/**/*.tmpl".to_string()]
201 } else {
202 overrides.templates.patterns.clone()
203 };
204
205 let mut templates = HashMap::new();
206 let templates_base = self.project_root.join("templates");
207
208 for pattern in patterns {
209 let full_pattern = self.project_root.join(&pattern);
210 for entry in glob(&full_pattern.to_string_lossy()).map_err(|e| {
211 ggen_utils::error::Error::new(&format!(
212 "Failed to glob pattern {}: {}",
213 full_pattern.display(),
214 e
215 ))
216 })? {
217 let path = entry.map_err(|e| {
218 ggen_utils::error::Error::new(&format!("Failed to read glob entry: {}", e))
219 })?;
220
221 let name = if let Ok(rel_path) = path.strip_prefix(&templates_base) {
224 rel_path
225 .with_extension("") .to_string_lossy()
227 .to_string()
228 } else {
229 path.file_stem()
231 .and_then(|s| s.to_str())
232 .unwrap_or("unknown")
233 .to_string()
234 };
235
236 templates.insert(name, path);
237 }
238 }
239
240 Ok(templates)
241 }
242
243 fn discover_queries(&self, overrides: &ConventionOverrides) -> Result<HashMap<String, String>> {
245 let patterns = if overrides.queries.patterns.is_empty() {
246 vec!["queries/**/*.sparql".to_string()]
247 } else {
248 overrides.queries.patterns.clone()
249 };
250
251 let mut queries = HashMap::new();
252 let queries_base = self.project_root.join("queries");
253
254 for pattern in patterns {
255 let full_pattern = self.project_root.join(&pattern);
256 for entry in glob(&full_pattern.to_string_lossy()).map_err(|e| {
257 ggen_utils::error::Error::new(&format!(
258 "Failed to glob pattern {}: {}",
259 full_pattern.display(),
260 e
261 ))
262 })? {
263 let path = entry.map_err(|e| {
264 ggen_utils::error::Error::new(&format!("Failed to read glob entry: {}", e))
265 })?;
266
267 let content = Context::with_context(
269 std::fs::read_to_string(&path).map_err(|e| {
270 ggen_utils::error::Error::new(&format!(
271 "Failed to read query file {:?}: {}",
272 path, e
273 ))
274 }),
275 || format!("Failed to read query file: {:?}", path),
276 )?;
277
278 let name = if let Ok(rel_path) = path.strip_prefix(&queries_base) {
281 rel_path
282 .with_extension("") .to_string_lossy()
284 .to_string()
285 } else {
286 path.file_stem()
288 .and_then(|s| s.to_str())
289 .unwrap_or("unknown")
290 .to_string()
291 };
292
293 queries.insert(name, content);
294 }
295 }
296
297 Ok(queries)
298 }
299
300 fn resolve_output_dir(&self, overrides: &ConventionOverrides) -> PathBuf {
302 if let Some(ref dir) = overrides.output.dir {
303 self.project_root.join(dir)
304 } else {
305 self.project_root.join("generated")
306 }
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use std::fs;
314 use tempfile::TempDir;
315
316 fn setup_test_project() -> TempDir {
317 let temp_dir = TempDir::new().unwrap();
318 let root = temp_dir.path();
319
320 fs::create_dir_all(root.join("domain")).unwrap();
322 fs::create_dir_all(root.join("templates/api")).unwrap();
323 fs::create_dir_all(root.join("queries/user")).unwrap();
324
325 fs::write(
327 root.join("domain/user.ttl"),
328 "@prefix ex: <http://example.org/> .",
329 )
330 .unwrap();
331 fs::write(
332 root.join("domain/order.ttl"),
333 "@prefix ex: <http://example.org/> .",
334 )
335 .unwrap();
336
337 fs::write(root.join("templates/main.tmpl"), "Hello {{ name }}").unwrap();
339 fs::write(root.join("templates/api/user.tmpl"), "User API").unwrap();
340
341 fs::write(
343 root.join("queries/user/find.sparql"),
344 "SELECT * WHERE { ?s ?p ?o }",
345 )
346 .unwrap();
347
348 temp_dir
349 }
350
351 #[test]
352 fn test_new() {
353 let temp_dir = TempDir::new().unwrap();
354 let resolver = ConventionResolver::new(temp_dir.path());
355
356 assert_eq!(resolver.project_root, temp_dir.path());
357 }
358
359 #[test]
360 fn test_discover_rdf_files() {
361 let temp_dir = setup_test_project();
362 let resolver = ConventionResolver::new(temp_dir.path());
363
364 let conventions = resolver.discover().unwrap();
365
366 assert_eq!(conventions.rdf_files.len(), 2);
367 assert!(conventions
368 .rdf_files
369 .iter()
370 .any(|p| p.ends_with("user.ttl")));
371 assert!(conventions
372 .rdf_files
373 .iter()
374 .any(|p| p.ends_with("order.ttl")));
375
376 assert!(conventions.rdf_files[0].ends_with("order.ttl"));
378 assert!(conventions.rdf_files[1].ends_with("user.ttl"));
379 }
380
381 #[test]
382 fn test_discover_templates() {
383 let temp_dir = setup_test_project();
384 let resolver = ConventionResolver::new(temp_dir.path());
385
386 let conventions = resolver.discover().unwrap();
387
388 assert_eq!(conventions.templates.len(), 2);
389 assert!(conventions.templates.contains_key("main"));
390 assert!(conventions.templates.contains_key("api/user"));
391
392 let main_path = &conventions.templates["main"];
393 assert!(main_path.ends_with("templates/main.tmpl"));
394 }
395
396 #[test]
397 fn test_discover_queries() {
398 let temp_dir = setup_test_project();
399 let resolver = ConventionResolver::new(temp_dir.path());
400
401 let conventions = resolver.discover().unwrap();
402
403 assert_eq!(conventions.queries.len(), 1);
404 assert!(conventions.queries.contains_key("user/find"));
405
406 let query = &conventions.queries["user/find"];
407 assert!(query.contains("SELECT * WHERE"));
408 }
409
410 #[test]
411 fn test_resolve_output_dir_default() {
412 let temp_dir = TempDir::new().unwrap();
413 let resolver = ConventionResolver::new(temp_dir.path());
414
415 let conventions = resolver.discover().unwrap();
416
417 assert_eq!(conventions.output_dir, temp_dir.path().join("generated"));
418 }
419
420 #[test]
421 fn test_resolve_output_dir_override() {
422 let temp_dir = TempDir::new().unwrap();
423 let root = temp_dir.path();
424
425 fs::create_dir_all(root.join(".ggen")).unwrap();
427 fs::write(
428 root.join(".ggen/conventions.toml"),
429 r#"
430[output]
431dir = "build/generated"
432"#,
433 )
434 .unwrap();
435
436 let resolver = ConventionResolver::new(root);
437 let conventions = resolver.discover().unwrap();
438
439 assert_eq!(conventions.output_dir, root.join("build/generated"));
440 }
441
442 #[test]
443 fn test_override_rdf_patterns() {
444 let temp_dir = TempDir::new().unwrap();
445 let root = temp_dir.path();
446
447 fs::create_dir_all(root.join("ontology")).unwrap();
449 fs::write(
450 root.join("ontology/custom.ttl"),
451 "@prefix ex: <http://example.org/> .",
452 )
453 .unwrap();
454
455 fs::create_dir_all(root.join(".ggen")).unwrap();
457 fs::write(
458 root.join(".ggen/conventions.toml"),
459 r#"
460[rdf]
461patterns = ["ontology/**/*.ttl"]
462"#,
463 )
464 .unwrap();
465
466 let resolver = ConventionResolver::new(root);
467 let conventions = resolver.discover().unwrap();
468
469 assert_eq!(conventions.rdf_files.len(), 1);
470 assert!(conventions.rdf_files[0].ends_with("custom.ttl"));
471 }
472
473 #[test]
474 fn test_override_template_patterns() {
475 let temp_dir = TempDir::new().unwrap();
476 let root = temp_dir.path();
477
478 fs::create_dir_all(root.join("views")).unwrap();
480 fs::write(root.join("views/page.tmpl"), "Page template").unwrap();
481
482 fs::create_dir_all(root.join(".ggen")).unwrap();
484 fs::write(
485 root.join(".ggen/conventions.toml"),
486 r#"
487[templates]
488patterns = ["views/**/*.tmpl"]
489"#,
490 )
491 .unwrap();
492
493 let resolver = ConventionResolver::new(root);
494 let conventions = resolver.discover().unwrap();
495
496 assert_eq!(conventions.templates.len(), 1);
497 assert!(conventions
499 .templates
500 .values()
501 .any(|p| p.ends_with("page.tmpl")));
502 }
503
504 #[test]
505 fn test_override_query_patterns() {
506 let temp_dir = TempDir::new().unwrap();
507 let root = temp_dir.path();
508
509 fs::create_dir_all(root.join("sparql")).unwrap();
511 fs::write(
512 root.join("sparql/select.sparql"),
513 "SELECT * WHERE { ?s ?p ?o }",
514 )
515 .unwrap();
516
517 fs::create_dir_all(root.join(".ggen")).unwrap();
519 fs::write(
520 root.join(".ggen/conventions.toml"),
521 r#"
522[queries]
523patterns = ["sparql/**/*.sparql"]
524"#,
525 )
526 .unwrap();
527
528 let resolver = ConventionResolver::new(root);
529 let conventions = resolver.discover().unwrap();
530
531 assert_eq!(conventions.queries.len(), 1);
532 assert!(conventions
533 .queries
534 .values()
535 .any(|c| c.contains("SELECT * WHERE")));
536 }
537
538 #[test]
539 fn test_empty_project() {
540 let temp_dir = TempDir::new().unwrap();
541 let resolver = ConventionResolver::new(temp_dir.path());
542
543 let conventions = resolver.discover().unwrap();
544
545 assert!(conventions.rdf_files.is_empty());
546 assert!(conventions.templates.is_empty());
547 assert!(conventions.queries.is_empty());
548 assert_eq!(conventions.output_dir, temp_dir.path().join("generated"));
549 }
550
551 #[test]
552 fn test_nested_template_names() {
553 let temp_dir = TempDir::new().unwrap();
554 let root = temp_dir.path();
555
556 fs::create_dir_all(root.join("templates/api/v1")).unwrap();
558 fs::write(root.join("templates/api/v1/user.tmpl"), "User API").unwrap();
559
560 let resolver = ConventionResolver::new(root);
561 let conventions = resolver.discover().unwrap();
562
563 assert_eq!(conventions.templates.len(), 1);
564 assert!(conventions.templates.contains_key("api/v1/user"));
565 }
566
567 #[test]
568 fn test_load_overrides_invalid_toml() {
569 let temp_dir = TempDir::new().unwrap();
570 let root = temp_dir.path();
571
572 fs::create_dir_all(root.join(".ggen")).unwrap();
574 fs::write(
575 root.join(".ggen/conventions.toml"),
576 "invalid toml content [[[",
577 )
578 .unwrap();
579
580 let resolver = ConventionResolver::new(root);
581
582 assert!(resolver.discover().is_err());
584 }
585}