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_lib::conventions::resolver::ConventionResolver;
29//!
30//! # fn main() -> ggen_core::utils::error::Result<()> {
31//! let conventions = ConventionResolver::new(".").discover()?;
32//! println!("Found {} RDF files", conventions.rdf_files.len());
33//! println!("Found {} templates", conventions.templates.len());
34//! # Ok(())
35//! # }
36//! ```
37
38use ggen_core::utils::error::{Context, Result};
39use glob::glob;
40use serde::{Deserialize, Serialize};
41use std::collections::HashMap;
42use std::path::PathBuf;
43
44/// Project conventions discovered from the file system
45#[derive(Debug, Clone)]
46pub struct ProjectConventions {
47    /// RDF files discovered in domain/ directory
48    pub rdf_files: Vec<PathBuf>,
49    /// Base directory for RDF files (for watching)
50    pub rdf_dir: PathBuf,
51    /// Templates discovered in templates/ directory (name -> path)
52    pub templates: HashMap<String, PathBuf>,
53    /// Base directory for templates (for watching)
54    pub templates_dir: PathBuf,
55    /// SPARQL queries discovered in queries/ directory (name -> content)
56    pub queries: HashMap<String, String>,
57    /// Output directory for generated code
58    pub output_dir: PathBuf,
59    /// Convention preset name (e.g., "clap-noun-verb", "default")
60    pub preset: String,
61}
62
63/// Convention overrides from .ggen/conventions.toml
64#[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
100/// Convention resolver that discovers project structure
101pub struct ConventionResolver {
102    project_root: PathBuf,
103}
104
105impl ConventionResolver {
106    /// Create a new convention resolver for the given project root
107    pub fn new(project_root: impl Into<PathBuf>) -> Self {
108        Self {
109            project_root: project_root.into(),
110        }
111    }
112
113    /// Discover project conventions by scanning the file system
114    pub fn discover(&self) -> Result<ProjectConventions> {
115        // Load overrides if they exist
116        let overrides = self.load_overrides()?;
117
118        // Discover RDF files
119        let rdf_files = self.discover_rdf(&overrides)?;
120
121        // Discover templates
122        let templates = self.discover_templates(&overrides)?;
123
124        // Discover queries
125        let queries = self.discover_queries(&overrides)?;
126
127        // Resolve output directory
128        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(), // Default preset
138        })
139    }
140
141    /// Load convention overrides from .ggen/conventions.toml if it exists
142    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    /// Discover RDF files in domain/ directory
168    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        // Sort alphabetically for deterministic order
195        files.sort();
196
197        Ok(files)
198    }
199
200    /// Discover templates in templates/ directory
201    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                // Convert nested path to template name
230                // e.g., templates/api/user.tmpl -> api/user
231                let name = if let Ok(rel_path) = path.strip_prefix(&templates_base) {
232                    rel_path
233                        .with_extension("") // Remove .tmpl extension
234                        .to_string_lossy()
235                        .to_string()
236                } else {
237                    // Fallback: use file stem with proper error handling
238                    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; // Skip templates with invalid names
246                        }
247                    }
248                };
249
250                templates.insert(name, path);
251            }
252        }
253
254        Ok(templates)
255    }
256
257    /// Discover SPARQL queries in queries/ directory
258    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                // Read query content
285                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                // Convert nested path to query name
297                // e.g., queries/user/find.sparql -> user/find
298                let name = if let Ok(rel_path) = path.strip_prefix(&queries_base) {
299                    rel_path
300                        .with_extension("") // Remove .sparql extension
301                        .to_string_lossy()
302                        .to_string()
303                } else {
304                    // Fallback: use file stem with proper error handling
305                    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; // Skip queries with invalid names
313                        }
314                    }
315                };
316
317                queries.insert(name, content);
318            }
319        }
320
321        Ok(queries)
322    }
323
324    /// Resolve output directory (default: project root)
325    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        // Create directory structure
345        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        // Create RDF files
350        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        // Create template files
362        fs::write(root.join("templates/main.tmpl"), "Hello {{ name }}").unwrap();
363        fs::write(root.join("templates/api/user.tmpl"), "User API").unwrap();
364
365        // Create query files
366        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        // Verify alphabetical order
401        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        // Create .ggen directory and conventions.toml
450        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        // Create custom RDF location
472        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        // Create override config
480        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        // Create custom template location
503        fs::create_dir_all(root.join("views")).unwrap();
504        fs::write(root.join("views/page.tmpl"), "Page template").unwrap();
505
506        // Create override config
507        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        // The template name will be just "page" since views is not recognized as templates base
522        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        // Create custom query location
534        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        // Create override config
542        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        // Create nested template structure
581        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        // Create invalid TOML
597        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        // Should return an error
607        assert!(resolver.discover().is_err());
608    }
609}