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_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_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    /// Discover RDF files in domain/ directory
166    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        // Sort alphabetically for deterministic order
190        files.sort();
191
192        Ok(files)
193    }
194
195    /// Discover templates in templates/ directory
196    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                // Convert nested path to template name
222                // e.g., templates/api/user.tmpl -> api/user
223                let name = if let Ok(rel_path) = path.strip_prefix(&templates_base) {
224                    rel_path
225                        .with_extension("") // Remove .tmpl extension
226                        .to_string_lossy()
227                        .to_string()
228                } else {
229                    // Fallback: use file stem
230                    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    /// Discover SPARQL queries in queries/ directory
244    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                // Read query content
268                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                // Convert nested path to query name
279                // e.g., queries/user/find.sparql -> user/find
280                let name = if let Ok(rel_path) = path.strip_prefix(&queries_base) {
281                    rel_path
282                        .with_extension("") // Remove .sparql extension
283                        .to_string_lossy()
284                        .to_string()
285                } else {
286                    // Fallback: use file stem
287                    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    /// Resolve output directory (default: generated/)
301    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        // Create directory structure
321        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        // Create RDF files
326        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        // Create template files
338        fs::write(root.join("templates/main.tmpl"), "Hello {{ name }}").unwrap();
339        fs::write(root.join("templates/api/user.tmpl"), "User API").unwrap();
340
341        // Create query files
342        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        // Verify alphabetical order
377        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        // Create .ggen directory and conventions.toml
426        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        // Create custom RDF location
448        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        // Create override config
456        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        // Create custom template location
479        fs::create_dir_all(root.join("views")).unwrap();
480        fs::write(root.join("views/page.tmpl"), "Page template").unwrap();
481
482        // Create override config
483        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        // The template name will be just "page" since views is not recognized as templates base
498        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        // Create custom query location
510        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        // Create override config
518        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        // Create nested template structure
557        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        // Create invalid TOML
573        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        // Should return an error
583        assert!(resolver.discover().is_err());
584    }
585}