1use 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
12pub struct ProjectDiscovery {
14 repo_path: PathBuf,
15 context_dir: PathBuf,
16}
17
18impl ProjectDiscovery {
19 pub fn new(repo_path: PathBuf, context_dir: PathBuf) -> Self {
21 Self {
22 repo_path,
23 context_dir,
24 }
25 }
26
27 pub fn discover(&self) -> Result<ProjectContext> {
29 let mut context = ProjectContext::default();
30
31 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 self.load_git_config(&mut context)?;
50
51 self.parse_documentation(&mut context)?;
53
54 self.detect_ecosystem(&mut context)?;
56
57 Ok(context)
58 }
59
60 fn load_omni_dev_config(&self, context: &mut ProjectContext, dir: &Path) -> Result<()> {
62 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 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 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 let local_contexts_dir = dir.join("local").join("context").join("feature-contexts");
103 let contexts_dir = dir.join("context").join("feature-contexts");
104
105 if contexts_dir.exists() {
107 self.load_feature_contexts(context, &contexts_dir)?;
108 }
109
110 if local_contexts_dir.exists() {
112 self.load_feature_contexts(context, &local_contexts_dir)?;
113 }
114
115 Ok(())
116 }
117
118 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 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 project_path
145 }
146
147 fn load_git_config(&self, _context: &mut ProjectContext) -> Result<()> {
149 Ok(())
151 }
152
153 fn parse_documentation(&self, context: &mut ProjectContext) -> Result<()> {
155 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 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 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 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 fn parse_contributing_conventions(&self, content: &str) -> Result<ProjectConventions> {
231 let mut conventions = ProjectConventions::default();
232
233 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 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 if in_commit_section && line.starts_with('#') && !line_lower.contains("commit") {
250 in_commit_section = false;
251 }
252
253 if in_commit_section {
254 if line.contains("type(scope):") || line.contains("<type>(<scope>):") {
256 conventions.commit_format = Some("type(scope): description".to_string());
257 }
258
259 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 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 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 fn parse_readme_conventions(&self, context: &mut ProjectContext, content: &str) -> Result<()> {
289 let lines: Vec<&str> = content.lines().collect();
291
292 for line in lines {
293 let _line_lower = line.to_lowercase();
294
295 if line.contains("src/") || line.contains("lib/") {
297 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 fn extract_scope_requirements(&self, lines: &[&str]) -> ScopeRequirements {
314 let mut requirements = ScopeRequirements::default();
315
316 for line in lines.iter().take(10) {
317 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 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 fn apply_rust_conventions(&self, context: &mut ProjectContext) -> Result<()> {
342 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 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 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 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 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#[derive(serde::Deserialize)]
545struct ScopesConfig {
546 scopes: Vec<ScopeDefinition>,
547}
548
549fn 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
565fn extract_scope_from_structure(line: &str) -> Option<String> {
567 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
578fn 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}