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 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 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 project_path
41}
42
43pub 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
75fn 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
219pub struct ProjectDiscovery {
221 repo_path: PathBuf,
222 context_dir: PathBuf,
223}
224
225impl ProjectDiscovery {
226 pub fn new(repo_path: PathBuf, context_dir: PathBuf) -> Self {
228 Self {
229 repo_path,
230 context_dir,
231 }
232 }
233
234 pub fn discover(&self) -> Result<ProjectContext> {
236 let mut context = ProjectContext::default();
237
238 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 self.load_git_config(&mut context)?;
255
256 self.parse_documentation(&mut context)?;
258
259 self.detect_ecosystem(&mut context)?;
261
262 Ok(context)
263 }
264
265 fn load_omni_dev_config(&self, context: &mut ProjectContext, dir: &Path) -> Result<()> {
267 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 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 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 let local_contexts_dir = dir.join("local").join("context").join("feature-contexts");
316 let contexts_dir = dir.join("context").join("feature-contexts");
317
318 if contexts_dir.exists() {
320 self.load_feature_contexts(context, &contexts_dir)?;
321 }
322
323 if local_contexts_dir.exists() {
325 self.load_feature_contexts(context, &local_contexts_dir)?;
326 }
327
328 Ok(())
329 }
330
331 fn load_git_config(&self, _context: &mut ProjectContext) -> Result<()> {
333 Ok(())
335 }
336
337 fn parse_documentation(&self, context: &mut ProjectContext) -> Result<()> {
339 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 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 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 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 fn parse_contributing_conventions(&self, content: &str) -> Result<ProjectConventions> {
426 let mut conventions = ProjectConventions::default();
427
428 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 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 if in_commit_section && line.starts_with('#') && !line_lower.contains("commit") {
445 in_commit_section = false;
446 }
447
448 if in_commit_section {
449 if line.contains("type(scope):") || line.contains("<type>(<scope>):") {
451 conventions.commit_format = Some("type(scope): description".to_string());
452 }
453
454 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 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 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 fn parse_readme_conventions(&self, context: &mut ProjectContext, content: &str) -> Result<()> {
484 let lines: Vec<&str> = content.lines().collect();
486
487 for line in lines {
488 let _line_lower = line.to_lowercase();
489
490 if line.contains("src/") || line.contains("lib/") {
492 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 fn extract_scope_requirements(&self, lines: &[&str]) -> ScopeRequirements {
509 let mut requirements = ScopeRequirements::default();
510
511 for line in lines.iter().take(10) {
512 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 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#[derive(serde::Deserialize)]
538struct ScopesConfig {
539 scopes: Vec<ScopeDefinition>,
540}
541
542fn 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
558fn extract_scope_from_structure(line: &str) -> Option<String> {
560 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
571fn 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}