1use 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
14pub struct ProjectDiscovery {
16 repo_path: PathBuf,
17 context_dir: PathBuf,
18}
19
20impl ProjectDiscovery {
21 pub fn new(repo_path: PathBuf, context_dir: PathBuf) -> Self {
23 Self {
24 repo_path,
25 context_dir,
26 }
27 }
28
29 pub fn discover(&self) -> Result<ProjectContext> {
31 let mut context = ProjectContext::default();
32
33 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 self.load_git_config(&mut context)?;
52
53 self.parse_documentation(&mut context)?;
55
56 self.detect_ecosystem(&mut context)?;
58
59 Ok(context)
60 }
61
62 fn load_omni_dev_config(&self, context: &mut ProjectContext, dir: &Path) -> Result<()> {
64 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 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 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 let local_contexts_dir = dir.join("local").join("context").join("feature-contexts");
113 let contexts_dir = dir.join("context").join("feature-contexts");
114
115 if contexts_dir.exists() {
117 self.load_feature_contexts(context, &contexts_dir)?;
118 }
119
120 if local_contexts_dir.exists() {
122 self.load_feature_contexts(context, &local_contexts_dir)?;
123 }
124
125 Ok(())
126 }
127
128 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 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 project_path
155 }
156
157 fn load_git_config(&self, _context: &mut ProjectContext) -> Result<()> {
159 Ok(())
161 }
162
163 fn parse_documentation(&self, context: &mut ProjectContext) -> Result<()> {
165 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 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 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 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 fn parse_contributing_conventions(&self, content: &str) -> Result<ProjectConventions> {
255 let mut conventions = ProjectConventions::default();
256
257 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 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 if in_commit_section && line.starts_with('#') && !line_lower.contains("commit") {
274 in_commit_section = false;
275 }
276
277 if in_commit_section {
278 if line.contains("type(scope):") || line.contains("<type>(<scope>):") {
280 conventions.commit_format = Some("type(scope): description".to_string());
281 }
282
283 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 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 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 fn parse_readme_conventions(&self, context: &mut ProjectContext, content: &str) -> Result<()> {
313 let lines: Vec<&str> = content.lines().collect();
315
316 for line in lines {
317 let _line_lower = line.to_lowercase();
318
319 if line.contains("src/") || line.contains("lib/") {
321 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 fn extract_scope_requirements(&self, lines: &[&str]) -> ScopeRequirements {
338 let mut requirements = ScopeRequirements::default();
339
340 for line in lines.iter().take(10) {
341 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 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 fn apply_rust_conventions(&self, context: &mut ProjectContext) -> Result<()> {
366 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 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 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 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 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#[derive(serde::Deserialize)]
569struct ScopesConfig {
570 scopes: Vec<ScopeDefinition>,
571}
572
573fn 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
589fn extract_scope_from_structure(line: &str) -> Option<String> {
591 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
602fn 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}