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