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