Skip to main content

omni_dev/claude/context/
discovery.rs

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