Skip to main content

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