ggen_cli_lib/conventions/
resolver.rs

1use anyhow::{Context, Result};
2use glob::glob;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7/// Project conventions discovered from the file system
8#[derive(Debug, Clone)]
9pub struct ProjectConventions {
10    /// RDF files discovered in domain/ directory
11    pub rdf_files: Vec<PathBuf>,
12    /// Base directory for RDF files (for watching)
13    pub rdf_dir: PathBuf,
14    /// Templates discovered in templates/ directory (name -> path)
15    pub templates: HashMap<String, PathBuf>,
16    /// Base directory for templates (for watching)
17    pub templates_dir: PathBuf,
18    /// SPARQL queries discovered in queries/ directory (name -> content)
19    pub queries: HashMap<String, String>,
20    /// Output directory for generated code
21    pub output_dir: PathBuf,
22    /// Convention preset name (e.g., "clap-noun-verb", "default")
23    pub preset: String,
24}
25
26/// Convention overrides from .ggen/conventions.toml
27#[derive(Debug, Clone, Deserialize, Serialize, Default)]
28struct ConventionOverrides {
29    #[serde(default)]
30    rdf: RdfOverrides,
31    #[serde(default)]
32    templates: TemplatesOverrides,
33    #[serde(default)]
34    queries: QueriesOverrides,
35    #[serde(default)]
36    output: OutputOverrides,
37}
38
39#[derive(Debug, Clone, Deserialize, Serialize, Default)]
40struct RdfOverrides {
41    #[serde(default)]
42    patterns: Vec<String>,
43}
44
45#[derive(Debug, Clone, Deserialize, Serialize, Default)]
46struct TemplatesOverrides {
47    #[serde(default)]
48    patterns: Vec<String>,
49}
50
51#[derive(Debug, Clone, Deserialize, Serialize, Default)]
52struct QueriesOverrides {
53    #[serde(default)]
54    patterns: Vec<String>,
55}
56
57#[derive(Debug, Clone, Deserialize, Serialize, Default)]
58struct OutputOverrides {
59    #[serde(default)]
60    dir: Option<String>,
61}
62
63/// Convention resolver that discovers project structure
64pub struct ConventionResolver {
65    project_root: PathBuf,
66}
67
68impl ConventionResolver {
69    /// Create a new convention resolver for the given project root
70    pub fn new(project_root: impl Into<PathBuf>) -> Self {
71        Self {
72            project_root: project_root.into(),
73        }
74    }
75
76    /// Discover project conventions by scanning the file system
77    pub fn discover(&self) -> Result<ProjectConventions> {
78        // Load overrides if they exist
79        let overrides = self.load_overrides()?;
80
81        // Discover RDF files
82        let rdf_files = self.discover_rdf(&overrides)?;
83
84        // Discover templates
85        let templates = self.discover_templates(&overrides)?;
86
87        // Discover queries
88        let queries = self.discover_queries(&overrides)?;
89
90        // Resolve output directory
91        let output_dir = self.resolve_output_dir(&overrides);
92
93        Ok(ProjectConventions {
94            rdf_files,
95            rdf_dir: self.project_root.join("domain"),
96            templates,
97            templates_dir: self.project_root.join("templates"),
98            queries,
99            output_dir,
100            preset: "clap-noun-verb".to_string(), // Default preset
101        })
102    }
103
104    /// Load convention overrides from .ggen/conventions.toml if it exists
105    fn load_overrides(&self) -> Result<ConventionOverrides> {
106        let override_path = self.project_root.join(".ggen").join("conventions.toml");
107
108        if override_path.exists() {
109            let content = std::fs::read_to_string(&override_path)
110                .context("Failed to read conventions.toml")?;
111            let overrides: ConventionOverrides =
112                toml::from_str(&content).context("Failed to parse conventions.toml")?;
113            Ok(overrides)
114        } else {
115            Ok(ConventionOverrides::default())
116        }
117    }
118
119    /// Discover RDF files in domain/ directory
120    fn discover_rdf(&self, overrides: &ConventionOverrides) -> Result<Vec<PathBuf>> {
121        let patterns = if overrides.rdf.patterns.is_empty() {
122            vec!["domain/**/*.ttl".to_string()]
123        } else {
124            overrides.rdf.patterns.clone()
125        };
126
127        let mut files = Vec::new();
128        for pattern in patterns {
129            let full_pattern = self.project_root.join(&pattern);
130            for entry in glob(&full_pattern.to_string_lossy())? {
131                files.push(entry?);
132            }
133        }
134
135        // Sort alphabetically for deterministic order
136        files.sort();
137
138        Ok(files)
139    }
140
141    /// Discover templates in templates/ directory
142    fn discover_templates(&self, overrides: &ConventionOverrides) -> Result<HashMap<String, PathBuf>> {
143        let patterns = if overrides.templates.patterns.is_empty() {
144            vec!["templates/**/*.tmpl".to_string()]
145        } else {
146            overrides.templates.patterns.clone()
147        };
148
149        let mut templates = HashMap::new();
150        let templates_base = self.project_root.join("templates");
151
152        for pattern in patterns {
153            let full_pattern = self.project_root.join(&pattern);
154            for entry in glob(&full_pattern.to_string_lossy())? {
155                let path = entry?;
156
157                // Convert nested path to template name
158                // e.g., templates/api/user.tmpl -> api/user
159                let name = if let Ok(rel_path) = path.strip_prefix(&templates_base) {
160                    rel_path
161                        .with_extension("") // Remove .tmpl extension
162                        .to_string_lossy()
163                        .to_string()
164                } else {
165                    // Fallback: use file stem
166                    path.file_stem()
167                        .and_then(|s| s.to_str())
168                        .unwrap_or("unknown")
169                        .to_string()
170                };
171
172                templates.insert(name, path);
173            }
174        }
175
176        Ok(templates)
177    }
178
179    /// Discover SPARQL queries in queries/ directory
180    fn discover_queries(&self, overrides: &ConventionOverrides) -> Result<HashMap<String, String>> {
181        let patterns = if overrides.queries.patterns.is_empty() {
182            vec!["queries/**/*.sparql".to_string()]
183        } else {
184            overrides.queries.patterns.clone()
185        };
186
187        let mut queries = HashMap::new();
188        let queries_base = self.project_root.join("queries");
189
190        for pattern in patterns {
191            let full_pattern = self.project_root.join(&pattern);
192            for entry in glob(&full_pattern.to_string_lossy())? {
193                let path = entry?;
194
195                // Read query content
196                let content = std::fs::read_to_string(&path)
197                    .with_context(|| format!("Failed to read query file: {:?}", path))?;
198
199                // Convert nested path to query name
200                // e.g., queries/user/find.sparql -> user/find
201                let name = if let Ok(rel_path) = path.strip_prefix(&queries_base) {
202                    rel_path
203                        .with_extension("") // Remove .sparql extension
204                        .to_string_lossy()
205                        .to_string()
206                } else {
207                    // Fallback: use file stem
208                    path.file_stem()
209                        .and_then(|s| s.to_str())
210                        .unwrap_or("unknown")
211                        .to_string()
212                };
213
214                queries.insert(name, content);
215            }
216        }
217
218        Ok(queries)
219    }
220
221    /// Resolve output directory (default: generated/)
222    fn resolve_output_dir(&self, overrides: &ConventionOverrides) -> PathBuf {
223        if let Some(ref dir) = overrides.output.dir {
224            self.project_root.join(dir)
225        } else {
226            self.project_root.join("generated")
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use std::fs;
235    use tempfile::TempDir;
236
237    fn setup_test_project() -> TempDir {
238        let temp_dir = TempDir::new().unwrap();
239        let root = temp_dir.path();
240
241        // Create directory structure
242        fs::create_dir_all(root.join("domain")).unwrap();
243        fs::create_dir_all(root.join("templates/api")).unwrap();
244        fs::create_dir_all(root.join("queries/user")).unwrap();
245
246        // Create RDF files
247        fs::write(root.join("domain/user.ttl"), "@prefix ex: <http://example.org/> .").unwrap();
248        fs::write(root.join("domain/order.ttl"), "@prefix ex: <http://example.org/> .").unwrap();
249
250        // Create template files
251        fs::write(root.join("templates/main.tmpl"), "Hello {{ name }}").unwrap();
252        fs::write(root.join("templates/api/user.tmpl"), "User API").unwrap();
253
254        // Create query files
255        fs::write(
256            root.join("queries/user/find.sparql"),
257            "SELECT * WHERE { ?s ?p ?o }",
258        )
259        .unwrap();
260
261        temp_dir
262    }
263
264    #[test]
265    fn test_new() {
266        let temp_dir = TempDir::new().unwrap();
267        let resolver = ConventionResolver::new(temp_dir.path());
268
269        assert_eq!(resolver.project_root, temp_dir.path());
270    }
271
272    #[test]
273    fn test_discover_rdf_files() {
274        let temp_dir = setup_test_project();
275        let resolver = ConventionResolver::new(temp_dir.path());
276
277        let conventions = resolver.discover().unwrap();
278
279        assert_eq!(conventions.rdf_files.len(), 2);
280        assert!(conventions
281            .rdf_files
282            .iter()
283            .any(|p| p.ends_with("user.ttl")));
284        assert!(conventions
285            .rdf_files
286            .iter()
287            .any(|p| p.ends_with("order.ttl")));
288
289        // Verify alphabetical order
290        assert!(conventions.rdf_files[0].ends_with("order.ttl"));
291        assert!(conventions.rdf_files[1].ends_with("user.ttl"));
292    }
293
294    #[test]
295    fn test_discover_templates() {
296        let temp_dir = setup_test_project();
297        let resolver = ConventionResolver::new(temp_dir.path());
298
299        let conventions = resolver.discover().unwrap();
300
301        assert_eq!(conventions.templates.len(), 2);
302        assert!(conventions.templates.contains_key("main"));
303        assert!(conventions.templates.contains_key("api/user"));
304
305        let main_path = &conventions.templates["main"];
306        assert!(main_path.ends_with("templates/main.tmpl"));
307    }
308
309    #[test]
310    fn test_discover_queries() {
311        let temp_dir = setup_test_project();
312        let resolver = ConventionResolver::new(temp_dir.path());
313
314        let conventions = resolver.discover().unwrap();
315
316        assert_eq!(conventions.queries.len(), 1);
317        assert!(conventions.queries.contains_key("user/find"));
318
319        let query = &conventions.queries["user/find"];
320        assert!(query.contains("SELECT * WHERE"));
321    }
322
323    #[test]
324    fn test_resolve_output_dir_default() {
325        let temp_dir = TempDir::new().unwrap();
326        let resolver = ConventionResolver::new(temp_dir.path());
327
328        let conventions = resolver.discover().unwrap();
329
330        assert_eq!(
331            conventions.output_dir,
332            temp_dir.path().join("generated")
333        );
334    }
335
336    #[test]
337    fn test_resolve_output_dir_override() {
338        let temp_dir = TempDir::new().unwrap();
339        let root = temp_dir.path();
340
341        // Create .ggen directory and conventions.toml
342        fs::create_dir_all(root.join(".ggen")).unwrap();
343        fs::write(
344            root.join(".ggen/conventions.toml"),
345            r#"
346[output]
347dir = "build/generated"
348"#,
349        )
350        .unwrap();
351
352        let resolver = ConventionResolver::new(root);
353        let conventions = resolver.discover().unwrap();
354
355        assert_eq!(conventions.output_dir, root.join("build/generated"));
356    }
357
358    #[test]
359    fn test_override_rdf_patterns() {
360        let temp_dir = TempDir::new().unwrap();
361        let root = temp_dir.path();
362
363        // Create custom RDF location
364        fs::create_dir_all(root.join("ontology")).unwrap();
365        fs::write(
366            root.join("ontology/custom.ttl"),
367            "@prefix ex: <http://example.org/> .",
368        )
369        .unwrap();
370
371        // Create override config
372        fs::create_dir_all(root.join(".ggen")).unwrap();
373        fs::write(
374            root.join(".ggen/conventions.toml"),
375            r#"
376[rdf]
377patterns = ["ontology/**/*.ttl"]
378"#,
379        )
380        .unwrap();
381
382        let resolver = ConventionResolver::new(root);
383        let conventions = resolver.discover().unwrap();
384
385        assert_eq!(conventions.rdf_files.len(), 1);
386        assert!(conventions.rdf_files[0].ends_with("custom.ttl"));
387    }
388
389    #[test]
390    fn test_override_template_patterns() {
391        let temp_dir = TempDir::new().unwrap();
392        let root = temp_dir.path();
393
394        // Create custom template location
395        fs::create_dir_all(root.join("views")).unwrap();
396        fs::write(root.join("views/page.tmpl"), "Page template").unwrap();
397
398        // Create override config
399        fs::create_dir_all(root.join(".ggen")).unwrap();
400        fs::write(
401            root.join(".ggen/conventions.toml"),
402            r#"
403[templates]
404patterns = ["views/**/*.tmpl"]
405"#,
406        )
407        .unwrap();
408
409        let resolver = ConventionResolver::new(root);
410        let conventions = resolver.discover().unwrap();
411
412        assert_eq!(conventions.templates.len(), 1);
413        // The template name will be just "page" since views is not recognized as templates base
414        assert!(conventions.templates.values().any(|p| p.ends_with("page.tmpl")));
415    }
416
417    #[test]
418    fn test_override_query_patterns() {
419        let temp_dir = TempDir::new().unwrap();
420        let root = temp_dir.path();
421
422        // Create custom query location
423        fs::create_dir_all(root.join("sparql")).unwrap();
424        fs::write(
425            root.join("sparql/select.sparql"),
426            "SELECT * WHERE { ?s ?p ?o }",
427        )
428        .unwrap();
429
430        // Create override config
431        fs::create_dir_all(root.join(".ggen")).unwrap();
432        fs::write(
433            root.join(".ggen/conventions.toml"),
434            r#"
435[queries]
436patterns = ["sparql/**/*.sparql"]
437"#,
438        )
439        .unwrap();
440
441        let resolver = ConventionResolver::new(root);
442        let conventions = resolver.discover().unwrap();
443
444        assert_eq!(conventions.queries.len(), 1);
445        assert!(conventions
446            .queries
447            .values()
448            .any(|c| c.contains("SELECT * WHERE")));
449    }
450
451    #[test]
452    fn test_empty_project() {
453        let temp_dir = TempDir::new().unwrap();
454        let resolver = ConventionResolver::new(temp_dir.path());
455
456        let conventions = resolver.discover().unwrap();
457
458        assert!(conventions.rdf_files.is_empty());
459        assert!(conventions.templates.is_empty());
460        assert!(conventions.queries.is_empty());
461        assert_eq!(
462            conventions.output_dir,
463            temp_dir.path().join("generated")
464        );
465    }
466
467    #[test]
468    fn test_nested_template_names() {
469        let temp_dir = TempDir::new().unwrap();
470        let root = temp_dir.path();
471
472        // Create nested template structure
473        fs::create_dir_all(root.join("templates/api/v1")).unwrap();
474        fs::write(root.join("templates/api/v1/user.tmpl"), "User API").unwrap();
475
476        let resolver = ConventionResolver::new(root);
477        let conventions = resolver.discover().unwrap();
478
479        assert_eq!(conventions.templates.len(), 1);
480        assert!(conventions.templates.contains_key("api/v1/user"));
481    }
482
483    #[test]
484    fn test_load_overrides_invalid_toml() {
485        let temp_dir = TempDir::new().unwrap();
486        let root = temp_dir.path();
487
488        // Create invalid TOML
489        fs::create_dir_all(root.join(".ggen")).unwrap();
490        fs::write(
491            root.join(".ggen/conventions.toml"),
492            "invalid toml content [[[",
493        )
494        .unwrap();
495
496        let resolver = ConventionResolver::new(root);
497
498        // Should return an error
499        assert!(resolver.discover().is_err());
500    }
501}