Skip to main content

ggen_cli_lib/conventions/
resolver.rs

1//! Project conventions resolver
2//!
3//! This module provides functionality for discovering and resolving project conventions
4//! from the file system. It automatically detects RDF files, templates, SPARQL queries,
5//! and output directories based on common patterns, with support for custom overrides
6//! via `.ggen/conventions.toml`.
7//!
8//! ## Features
9//!
10//! - **Automatic Discovery**: Find RDF files, templates, and queries in standard locations
11//! - **Convention Presets**: Support for presets like "clap-noun-verb" and "default"
12//! - **Custom Overrides**: Override patterns via configuration file
13//! - **File Watching**: Track directories for changes (for watch mode)
14//!
15//! ## Convention Structure
16//!
17//! Conventions define:
18//! - RDF file patterns (e.g., `domain/**/*.ttl`)
19//! - Template patterns (e.g., `templates/**/*.tmpl`)
20//! - Query patterns (e.g., `queries/**/*.rq`)
21//! - Output directory location
22//!
23//! ## Examples
24//!
25//! ### Resolving Conventions
26//!
27//! ```rust,no_run
28//! use ggen_cli::conventions::resolver::resolve_conventions;
29//! use std::path::Path;
30//!
31//! # fn main() -> anyhow::Result<()> {
32//! let conventions = resolve_conventions(Path::new("."), "clap-noun-verb")?;
33//! println!("Found {} RDF files", conventions.rdf_files.len());
34//! println!("Found {} templates", conventions.templates.len());
35//! # Ok(())
36//! # }
37//! ```
38
39use ggen_core::utils::error::{Context, Result};
40use glob::glob;
41use serde::{Deserialize, Serialize};
42use std::collections::HashMap;
43use std::path::PathBuf;
44
45/// Project conventions discovered from the file system
46#[derive(Debug, Clone)]
47pub struct ProjectConventions {
48    /// RDF files discovered in domain/ directory
49    pub rdf_files: Vec<PathBuf>,
50    /// Base directory for RDF files (for watching)
51    pub rdf_dir: PathBuf,
52    /// Templates discovered in templates/ directory (name -> path)
53    pub templates: HashMap<String, PathBuf>,
54    /// Base directory for templates (for watching)
55    pub templates_dir: PathBuf,
56    /// SPARQL queries discovered in queries/ directory (name -> content)
57    pub queries: HashMap<String, String>,
58    /// Output directory for generated code
59    pub output_dir: PathBuf,
60    /// Convention preset name (e.g., "clap-noun-verb", "default")
61    pub preset: String,
62}
63
64/// Convention overrides from .ggen/conventions.toml
65#[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
101/// Convention resolver that discovers project structure
102pub struct ConventionResolver {
103    project_root: PathBuf,
104}
105
106impl ConventionResolver {
107    /// Create a new convention resolver for the given project root
108    pub fn new(project_root: impl Into<PathBuf>) -> Self {
109        Self {
110            project_root: project_root.into(),
111        }
112    }
113
114    /// Discover project conventions by scanning the file system
115    pub fn discover(&self) -> Result<ProjectConventions> {
116        // Load overrides if they exist
117        let overrides = self.load_overrides()?;
118
119        // Discover RDF files
120        let rdf_files = self.discover_rdf(&overrides)?;
121
122        // Discover templates
123        let templates = self.discover_templates(&overrides)?;
124
125        // Discover queries
126        let queries = self.discover_queries(&overrides)?;
127
128        // Resolve output directory
129        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(), // Default preset
139        })
140    }
141
142    /// Load convention overrides from .ggen/conventions.toml if it exists
143    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    /// Discover RDF files in domain/ directory
169    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        // Sort alphabetically for deterministic order
196        files.sort();
197
198        Ok(files)
199    }
200
201    /// Discover templates in templates/ directory
202    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                // Convert nested path to template name
231                // e.g., templates/api/user.tmpl -> api/user
232                let name = if let Ok(rel_path) = path.strip_prefix(&templates_base) {
233                    rel_path
234                        .with_extension("") // Remove .tmpl extension
235                        .to_string_lossy()
236                        .to_string()
237                } else {
238                    // Fallback: use file stem with proper error handling
239                    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; // Skip templates with invalid names
244                        }
245                    }
246                };
247
248                templates.insert(name, path);
249            }
250        }
251
252        Ok(templates)
253    }
254
255    /// Discover SPARQL queries in queries/ directory
256    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                // Read query content
283                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                // Convert nested path to query name
294                // e.g., queries/user/find.sparql -> user/find
295                let name = if let Ok(rel_path) = path.strip_prefix(&queries_base) {
296                    rel_path
297                        .with_extension("") // Remove .sparql extension
298                        .to_string_lossy()
299                        .to_string()
300                } else {
301                    // Fallback: use file stem with proper error handling
302                    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; // Skip queries with invalid names
307                        }
308                    }
309                };
310
311                queries.insert(name, content);
312            }
313        }
314
315        Ok(queries)
316    }
317
318    /// Resolve output directory (default: project root)
319    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        // Create directory structure
339        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        // Create RDF files
344        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        // Create template files
356        fs::write(root.join("templates/main.tmpl"), "Hello {{ name }}").unwrap();
357        fs::write(root.join("templates/api/user.tmpl"), "User API").unwrap();
358
359        // Create query files
360        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        // Verify alphabetical order
395        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        // Create .ggen directory and conventions.toml
444        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        // Create custom RDF location
466        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        // Create override config
474        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        // Create custom template location
497        fs::create_dir_all(root.join("views")).unwrap();
498        fs::write(root.join("views/page.tmpl"), "Page template").unwrap();
499
500        // Create override config
501        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        // The template name will be just "page" since views is not recognized as templates base
516        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        // Create custom query location
528        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        // Create override config
536        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        // Create nested template structure
575        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        // Create invalid TOML
591        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        // Should return an error
601        assert!(resolver.discover().is_err());
602    }
603}