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