omni_dev/claude/context/
discovery.rs

1//! Project context discovery system
2
3use crate::data::context::{
4    Ecosystem, FeatureContext, ProjectContext, ProjectConventions, ScopeDefinition,
5    ScopeRequirements,
6};
7use anyhow::Result;
8use std::fs;
9use std::path::{Path, PathBuf};
10use tracing::debug;
11
12/// Project context discovery system
13pub struct ProjectDiscovery {
14    repo_path: PathBuf,
15    context_dir: PathBuf,
16}
17
18impl ProjectDiscovery {
19    /// Create a new project discovery instance
20    pub fn new(repo_path: PathBuf, context_dir: PathBuf) -> Self {
21        Self {
22            repo_path,
23            context_dir,
24        }
25    }
26
27    /// Discover all project context
28    pub fn discover(&self) -> Result<ProjectContext> {
29        let mut context = ProjectContext::default();
30
31        // 1. Check custom context directory (highest priority)
32        let context_dir_path = if self.context_dir.is_absolute() {
33            self.context_dir.clone()
34        } else {
35            self.repo_path.join(&self.context_dir)
36        };
37        debug!(
38            context_dir = ?context_dir_path,
39            exists = context_dir_path.exists(),
40            "Looking for context directory"
41        );
42        if context_dir_path.exists() {
43            debug!("Loading omni-dev config");
44            self.load_omni_dev_config(&mut context, &context_dir_path)?;
45            debug!("Config loading completed");
46        }
47
48        // 2. Standard git configuration files
49        self.load_git_config(&mut context)?;
50
51        // 3. Parse project documentation
52        self.parse_documentation(&mut context)?;
53
54        // 4. Detect ecosystem conventions
55        self.detect_ecosystem(&mut context)?;
56
57        Ok(context)
58    }
59
60    /// Load configuration from .omni-dev/ directory with local override support
61    fn load_omni_dev_config(&self, context: &mut ProjectContext, dir: &Path) -> Result<()> {
62        // Load commit guidelines (with local override)
63        let guidelines_path = self.resolve_config_file(dir, "commit-guidelines.md");
64        debug!(
65            path = ?guidelines_path,
66            exists = guidelines_path.exists(),
67            "Checking for commit guidelines"
68        );
69        if guidelines_path.exists() {
70            let content = fs::read_to_string(&guidelines_path)?;
71            debug!(bytes = content.len(), "Loaded commit guidelines");
72            context.commit_guidelines = Some(content);
73        } else {
74            debug!("No commit guidelines file found");
75        }
76
77        // Load scopes configuration (with local override)
78        let scopes_path = self.resolve_config_file(dir, "scopes.yaml");
79        if scopes_path.exists() {
80            let scopes_yaml = fs::read_to_string(scopes_path)?;
81            if let Ok(scopes_config) = serde_yaml::from_str::<ScopesConfig>(&scopes_yaml) {
82                context.valid_scopes = scopes_config.scopes;
83            }
84        }
85
86        // Load feature contexts (check both local and standard directories)
87        let local_contexts_dir = dir.join("local").join("context").join("feature-contexts");
88        let contexts_dir = dir.join("context").join("feature-contexts");
89
90        // Load standard feature contexts first
91        if contexts_dir.exists() {
92            self.load_feature_contexts(context, &contexts_dir)?;
93        }
94
95        // Load local feature contexts (will override if same name)
96        if local_contexts_dir.exists() {
97            self.load_feature_contexts(context, &local_contexts_dir)?;
98        }
99
100        Ok(())
101    }
102
103    /// Resolve configuration file path with local override support and home fallback
104    ///
105    /// Priority:
106    /// 1. .omni-dev/local/{filename} (local override)
107    /// 2. .omni-dev/{filename} (shared project config)
108    /// 3. $HOME/.omni-dev/{filename} (global user config)
109    fn resolve_config_file(&self, dir: &Path, filename: &str) -> PathBuf {
110        let local_path = dir.join("local").join(filename);
111        if local_path.exists() {
112            return local_path;
113        }
114
115        let project_path = dir.join(filename);
116        if project_path.exists() {
117            return project_path;
118        }
119
120        // Check home directory fallback
121        if let Ok(home_dir) = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("No home directory")) {
122            let home_path = home_dir.join(".omni-dev").join(filename);
123            if home_path.exists() {
124                return home_path;
125            }
126        }
127
128        // Return project path as default (even if it doesn't exist)
129        project_path
130    }
131
132    /// Load git configuration files
133    fn load_git_config(&self, _context: &mut ProjectContext) -> Result<()> {
134        // Git configuration loading can be extended here if needed
135        Ok(())
136    }
137
138    /// Parse project documentation for conventions
139    fn parse_documentation(&self, context: &mut ProjectContext) -> Result<()> {
140        // Parse CONTRIBUTING.md
141        let contributing_path = self.repo_path.join("CONTRIBUTING.md");
142        if contributing_path.exists() {
143            let content = fs::read_to_string(contributing_path)?;
144            context.project_conventions = self.parse_contributing_conventions(&content)?;
145        }
146
147        // Parse README.md for additional conventions
148        let readme_path = self.repo_path.join("README.md");
149        if readme_path.exists() {
150            let content = fs::read_to_string(readme_path)?;
151            self.parse_readme_conventions(context, &content)?;
152        }
153
154        Ok(())
155    }
156
157    /// Detect project ecosystem and apply conventions
158    fn detect_ecosystem(&self, context: &mut ProjectContext) -> Result<()> {
159        context.ecosystem = if self.repo_path.join("Cargo.toml").exists() {
160            self.apply_rust_conventions(context)?;
161            Ecosystem::Rust
162        } else if self.repo_path.join("package.json").exists() {
163            self.apply_node_conventions(context)?;
164            Ecosystem::Node
165        } else if self.repo_path.join("pyproject.toml").exists()
166            || self.repo_path.join("requirements.txt").exists()
167        {
168            self.apply_python_conventions(context)?;
169            Ecosystem::Python
170        } else if self.repo_path.join("go.mod").exists() {
171            self.apply_go_conventions(context)?;
172            Ecosystem::Go
173        } else if self.repo_path.join("pom.xml").exists()
174            || self.repo_path.join("build.gradle").exists()
175        {
176            self.apply_java_conventions(context)?;
177            Ecosystem::Java
178        } else {
179            Ecosystem::Generic
180        };
181
182        Ok(())
183    }
184
185    /// Load feature contexts from directory
186    fn load_feature_contexts(
187        &self,
188        context: &mut ProjectContext,
189        contexts_dir: &Path,
190    ) -> Result<()> {
191        if let Ok(entries) = fs::read_dir(contexts_dir) {
192            for entry in entries.flatten() {
193                if let Some(name) = entry.file_name().to_str() {
194                    if name.ends_with(".yaml") || name.ends_with(".yml") {
195                        let content = fs::read_to_string(entry.path())?;
196                        if let Ok(feature_context) =
197                            serde_yaml::from_str::<FeatureContext>(&content)
198                        {
199                            let feature_name = name
200                                .trim_end_matches(".yaml")
201                                .trim_end_matches(".yml")
202                                .to_string();
203                            context
204                                .feature_contexts
205                                .insert(feature_name, feature_context);
206                        }
207                    }
208                }
209            }
210        }
211        Ok(())
212    }
213
214    /// Parse CONTRIBUTING.md for conventions
215    fn parse_contributing_conventions(&self, content: &str) -> Result<ProjectConventions> {
216        let mut conventions = ProjectConventions::default();
217
218        // Look for commit message sections
219        let lines: Vec<&str> = content.lines().collect();
220        let mut in_commit_section = false;
221
222        for (i, line) in lines.iter().enumerate() {
223            let line_lower = line.to_lowercase();
224
225            // Detect commit message sections
226            if line_lower.contains("commit")
227                && (line_lower.contains("message") || line_lower.contains("format"))
228            {
229                in_commit_section = true;
230                continue;
231            }
232
233            // End commit section if we hit another header
234            if in_commit_section && line.starts_with('#') && !line_lower.contains("commit") {
235                in_commit_section = false;
236            }
237
238            if in_commit_section {
239                // Extract commit format examples
240                if line.contains("type(scope):") || line.contains("<type>(<scope>):") {
241                    conventions.commit_format = Some("type(scope): description".to_string());
242                }
243
244                // Extract required trailers
245                if line_lower.contains("signed-off-by") {
246                    conventions
247                        .required_trailers
248                        .push("Signed-off-by".to_string());
249                }
250
251                if line_lower.contains("fixes") && line_lower.contains("#") {
252                    conventions.required_trailers.push("Fixes".to_string());
253                }
254
255                // Extract preferred types
256                if line.contains("feat") || line.contains("fix") || line.contains("docs") {
257                    let types = extract_commit_types(line);
258                    conventions.preferred_types.extend(types);
259                }
260
261                // Look ahead for scope examples
262                if line_lower.contains("scope") && i + 1 < lines.len() {
263                    let scope_requirements = self.extract_scope_requirements(&lines[i..]);
264                    conventions.scope_requirements = scope_requirements;
265                }
266            }
267        }
268
269        Ok(conventions)
270    }
271
272    /// Parse README.md for additional conventions
273    fn parse_readme_conventions(&self, context: &mut ProjectContext, content: &str) -> Result<()> {
274        // Look for development or contribution sections
275        let lines: Vec<&str> = content.lines().collect();
276
277        for line in lines {
278            let _line_lower = line.to_lowercase();
279
280            // Extract additional scope information from project structure
281            if line.contains("src/") || line.contains("lib/") {
282                // Try to extract scope information from directory structure mentions
283                if let Some(scope) = extract_scope_from_structure(line) {
284                    context.valid_scopes.push(ScopeDefinition {
285                        name: scope.clone(),
286                        description: format!("{} related changes", scope),
287                        examples: vec![],
288                        file_patterns: vec![format!("{}/**", scope)],
289                    });
290                }
291            }
292        }
293
294        Ok(())
295    }
296
297    /// Extract scope requirements from contributing documentation
298    fn extract_scope_requirements(&self, lines: &[&str]) -> ScopeRequirements {
299        let mut requirements = ScopeRequirements::default();
300
301        for line in lines.iter().take(10) {
302            // Stop at next major section
303            if line.starts_with("##") {
304                break;
305            }
306
307            let line_lower = line.to_lowercase();
308
309            if line_lower.contains("required") || line_lower.contains("must") {
310                requirements.required = true;
311            }
312
313            // Extract scope examples
314            if line.contains(":")
315                && (line.contains("auth") || line.contains("api") || line.contains("ui"))
316            {
317                let scopes = extract_scopes_from_examples(line);
318                requirements.valid_scopes.extend(scopes);
319            }
320        }
321
322        requirements
323    }
324
325    /// Apply Rust ecosystem conventions
326    fn apply_rust_conventions(&self, context: &mut ProjectContext) -> Result<()> {
327        // Add common Rust scopes if not already defined
328        let rust_scopes = vec![
329            (
330                "cargo",
331                "Cargo.toml and dependency management",
332                vec!["Cargo.toml", "Cargo.lock"],
333            ),
334            (
335                "lib",
336                "Library code and public API",
337                vec!["src/lib.rs", "src/**"],
338            ),
339            (
340                "cli",
341                "Command-line interface",
342                vec!["src/main.rs", "src/cli/**"],
343            ),
344            (
345                "core",
346                "Core application logic",
347                vec!["src/core/**", "src/lib/**"],
348            ),
349            ("test", "Test code", vec!["tests/**", "src/**/test*"]),
350            (
351                "docs",
352                "Documentation",
353                vec!["docs/**", "README.md", "**/*.md"],
354            ),
355            (
356                "ci",
357                "Continuous integration",
358                vec![".github/**", ".gitlab-ci.yml"],
359            ),
360        ];
361
362        for (name, description, patterns) in rust_scopes {
363            if !context.valid_scopes.iter().any(|s| s.name == name) {
364                context.valid_scopes.push(ScopeDefinition {
365                    name: name.to_string(),
366                    description: description.to_string(),
367                    examples: vec![],
368                    file_patterns: patterns.into_iter().map(String::from).collect(),
369                });
370            }
371        }
372
373        Ok(())
374    }
375
376    /// Apply Node.js ecosystem conventions
377    fn apply_node_conventions(&self, context: &mut ProjectContext) -> Result<()> {
378        let node_scopes = vec![
379            (
380                "deps",
381                "Dependencies and package.json",
382                vec!["package.json", "package-lock.json"],
383            ),
384            (
385                "config",
386                "Configuration files",
387                vec!["*.config.js", "*.config.json", ".env*"],
388            ),
389            (
390                "build",
391                "Build system and tooling",
392                vec!["webpack.config.js", "rollup.config.js"],
393            ),
394            (
395                "test",
396                "Test files",
397                vec!["test/**", "tests/**", "**/*.test.js"],
398            ),
399            (
400                "docs",
401                "Documentation",
402                vec!["docs/**", "README.md", "**/*.md"],
403            ),
404        ];
405
406        for (name, description, patterns) in node_scopes {
407            if !context.valid_scopes.iter().any(|s| s.name == name) {
408                context.valid_scopes.push(ScopeDefinition {
409                    name: name.to_string(),
410                    description: description.to_string(),
411                    examples: vec![],
412                    file_patterns: patterns.into_iter().map(String::from).collect(),
413                });
414            }
415        }
416
417        Ok(())
418    }
419
420    /// Apply Python ecosystem conventions
421    fn apply_python_conventions(&self, context: &mut ProjectContext) -> Result<()> {
422        let python_scopes = vec![
423            (
424                "deps",
425                "Dependencies and requirements",
426                vec!["requirements.txt", "pyproject.toml", "setup.py"],
427            ),
428            (
429                "config",
430                "Configuration files",
431                vec!["*.ini", "*.cfg", "*.toml"],
432            ),
433            (
434                "test",
435                "Test files",
436                vec!["test/**", "tests/**", "**/*_test.py"],
437            ),
438            (
439                "docs",
440                "Documentation",
441                vec!["docs/**", "README.md", "**/*.md", "**/*.rst"],
442            ),
443        ];
444
445        for (name, description, patterns) in python_scopes {
446            if !context.valid_scopes.iter().any(|s| s.name == name) {
447                context.valid_scopes.push(ScopeDefinition {
448                    name: name.to_string(),
449                    description: description.to_string(),
450                    examples: vec![],
451                    file_patterns: patterns.into_iter().map(String::from).collect(),
452                });
453            }
454        }
455
456        Ok(())
457    }
458
459    /// Apply Go ecosystem conventions
460    fn apply_go_conventions(&self, context: &mut ProjectContext) -> Result<()> {
461        let go_scopes = vec![
462            (
463                "mod",
464                "Go modules and dependencies",
465                vec!["go.mod", "go.sum"],
466            ),
467            ("cmd", "Command-line applications", vec!["cmd/**"]),
468            ("pkg", "Library packages", vec!["pkg/**"]),
469            ("internal", "Internal packages", vec!["internal/**"]),
470            ("test", "Test files", vec!["**/*_test.go"]),
471            (
472                "docs",
473                "Documentation",
474                vec!["docs/**", "README.md", "**/*.md"],
475            ),
476        ];
477
478        for (name, description, patterns) in go_scopes {
479            if !context.valid_scopes.iter().any(|s| s.name == name) {
480                context.valid_scopes.push(ScopeDefinition {
481                    name: name.to_string(),
482                    description: description.to_string(),
483                    examples: vec![],
484                    file_patterns: patterns.into_iter().map(String::from).collect(),
485                });
486            }
487        }
488
489        Ok(())
490    }
491
492    /// Apply Java ecosystem conventions
493    fn apply_java_conventions(&self, context: &mut ProjectContext) -> Result<()> {
494        let java_scopes = vec![
495            (
496                "build",
497                "Build system",
498                vec!["pom.xml", "build.gradle", "build.gradle.kts"],
499            ),
500            (
501                "config",
502                "Configuration",
503                vec!["src/main/resources/**", "application.properties"],
504            ),
505            ("test", "Test files", vec!["src/test/**"]),
506            (
507                "docs",
508                "Documentation",
509                vec!["docs/**", "README.md", "**/*.md"],
510            ),
511        ];
512
513        for (name, description, patterns) in java_scopes {
514            if !context.valid_scopes.iter().any(|s| s.name == name) {
515                context.valid_scopes.push(ScopeDefinition {
516                    name: name.to_string(),
517                    description: description.to_string(),
518                    examples: vec![],
519                    file_patterns: patterns.into_iter().map(String::from).collect(),
520                });
521            }
522        }
523
524        Ok(())
525    }
526}
527
528/// Configuration structure for scopes.yaml
529#[derive(serde::Deserialize)]
530struct ScopesConfig {
531    scopes: Vec<ScopeDefinition>,
532}
533
534/// Extract commit types from a line
535fn extract_commit_types(line: &str) -> Vec<String> {
536    let mut types = Vec::new();
537    let common_types = [
538        "feat", "fix", "docs", "style", "refactor", "test", "chore", "ci", "build", "perf",
539    ];
540
541    for &type_str in &common_types {
542        if line.to_lowercase().contains(type_str) {
543            types.push(type_str.to_string());
544        }
545    }
546
547    types
548}
549
550/// Extract scope from project structure description
551fn extract_scope_from_structure(line: &str) -> Option<String> {
552    // Look for patterns like "src/auth/", "lib/config/", etc.
553    if let Some(start) = line.find("src/") {
554        let after_src = &line[start + 4..];
555        if let Some(end) = after_src.find('/') {
556            return Some(after_src[..end].to_string());
557        }
558    }
559
560    None
561}
562
563/// Extract scopes from examples in documentation
564fn extract_scopes_from_examples(line: &str) -> Vec<String> {
565    let mut scopes = Vec::new();
566    let common_scopes = ["auth", "api", "ui", "db", "config", "core", "cli", "web"];
567
568    for &scope in &common_scopes {
569        if line.to_lowercase().contains(scope) {
570            scopes.push(scope.to_string());
571        }
572    }
573
574    scopes
575}