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