1use std::fs;
4use std::path::{Path, PathBuf};
5
6use std::fmt;
7
8use anyhow::{Context, Result};
9use tracing::debug;
10
11use crate::data::context::{
12 Ecosystem, FeatureContext, ProjectContext, ProjectConventions, ScopeDefinition,
13 ScopeRequirements,
14};
15
16fn xdg_config_dir() -> Option<PathBuf> {
26 if let Ok(xdg_home) = std::env::var("XDG_CONFIG_HOME") {
27 if !xdg_home.is_empty() {
28 return Some(PathBuf::from(xdg_home).join("omni-dev"));
29 }
30 }
31
32 dirs::home_dir().map(|home| home.join(".config").join("omni-dev"))
34}
35
36pub fn resolve_config_file(dir: &Path, filename: &str) -> PathBuf {
44 let local_path = dir.join("local").join(filename);
45 if local_path.exists() {
46 return local_path;
47 }
48
49 let project_path = dir.join(filename);
50 if project_path.exists() {
51 return project_path;
52 }
53
54 if let Some(xdg_dir) = xdg_config_dir() {
56 let xdg_path = xdg_dir.join(filename);
57 if xdg_path.exists() {
58 return xdg_path;
59 }
60 }
61
62 if let Ok(home_dir) = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("No home directory")) {
64 let home_path = home_dir.join(".omni-dev").join(filename);
65 if home_path.exists() {
66 return home_path;
67 }
68 }
69
70 project_path
72}
73
74fn walk_up_find_config_dir(start: &Path) -> Option<PathBuf> {
80 let mut current = start.to_path_buf();
81 loop {
82 let candidate = current.join(".omni-dev");
83 if candidate.is_dir() {
84 return Some(candidate);
85 }
86 if current.join(".git").exists() {
88 break;
89 }
90 if !current.pop() {
91 break;
92 }
93 }
94 None
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum ConfigDirSource {
100 CliFlag,
102 EnvVar,
104 WalkUp,
106 Default,
108}
109
110impl fmt::Display for ConfigDirSource {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 match self {
113 Self::CliFlag => write!(f, "--context-dir"),
114 Self::EnvVar => write!(f, "OMNI_DEV_CONFIG_DIR"),
115 Self::WalkUp => write!(f, "walk-up"),
116 Self::Default => write!(f, "default"),
117 }
118 }
119}
120
121pub fn resolve_context_dir_with_source(override_dir: Option<&Path>) -> (PathBuf, ConfigDirSource) {
129 if let Some(dir) = override_dir {
130 return (dir.to_path_buf(), ConfigDirSource::CliFlag);
131 }
132
133 if let Ok(env_dir) = std::env::var("OMNI_DEV_CONFIG_DIR") {
134 if !env_dir.is_empty() {
135 return (PathBuf::from(env_dir), ConfigDirSource::EnvVar);
136 }
137 }
138
139 if let Ok(cwd) = std::env::current_dir() {
141 if let Some(config_dir) = walk_up_find_config_dir(&cwd) {
142 return (config_dir, ConfigDirSource::WalkUp);
143 }
144 }
145
146 (PathBuf::from(".omni-dev"), ConfigDirSource::Default)
147}
148
149pub fn resolve_context_dir(override_dir: Option<&Path>) -> PathBuf {
154 resolve_context_dir_with_source(override_dir).0
155}
156
157pub fn load_config_content(dir: &Path, filename: &str) -> Result<Option<String>> {
162 let path = resolve_config_file(dir, filename);
163 if path.exists() {
164 let content = fs::read_to_string(&path)
165 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
166 Ok(Some(content))
167 } else {
168 Ok(None)
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum ConfigSourceLabel {
175 LocalOverride(PathBuf),
177 Project(PathBuf),
179 Xdg(PathBuf),
181 Global(PathBuf),
183 NotFound,
185}
186
187impl fmt::Display for ConfigSourceLabel {
188 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189 match self {
190 Self::LocalOverride(p) => write!(f, "Local override: {}", p.display()),
191 Self::Project(p) => write!(f, "Project: {}", p.display()),
192 Self::Xdg(p) => write!(f, "Global (XDG): {}", p.display()),
193 Self::Global(p) => write!(f, "Global: {}", p.display()),
194 Self::NotFound => write!(f, "(not found)"),
195 }
196 }
197}
198
199pub fn config_source_label(dir: &Path, filename: &str) -> ConfigSourceLabel {
204 let local_path = dir.join("local").join(filename);
205 if local_path.exists() {
206 return ConfigSourceLabel::LocalOverride(local_path);
207 }
208
209 let project_path = dir.join(filename);
210 if project_path.exists() {
211 return ConfigSourceLabel::Project(project_path);
212 }
213
214 if let Some(xdg_dir) = xdg_config_dir() {
215 let xdg_path = xdg_dir.join(filename);
216 if xdg_path.exists() {
217 return ConfigSourceLabel::Xdg(xdg_path);
218 }
219 }
220
221 if let Some(home_dir) = dirs::home_dir() {
222 let home_path = home_dir.join(".omni-dev").join(filename);
223 if home_path.exists() {
224 return ConfigSourceLabel::Global(home_path);
225 }
226 }
227
228 ConfigSourceLabel::NotFound
229}
230
231pub fn load_project_scopes(context_dir: &Path, repo_path: &Path) -> Vec<ScopeDefinition> {
236 let scopes_path = resolve_config_file(context_dir, "scopes.yaml");
237 let mut scopes = if scopes_path.exists() {
238 let scopes_yaml = match fs::read_to_string(&scopes_path) {
239 Ok(content) => content,
240 Err(e) => {
241 tracing::warn!("Cannot read scopes file {}: {e}", scopes_path.display());
242 return vec![];
243 }
244 };
245 match serde_yaml::from_str::<ScopesConfig>(&scopes_yaml) {
246 Ok(config) => config.scopes,
247 Err(e) => {
248 tracing::warn!(
249 "Ignoring malformed scopes file {}: {e}",
250 scopes_path.display()
251 );
252 vec![]
253 }
254 }
255 } else {
256 vec![]
257 };
258
259 merge_ecosystem_scopes(&mut scopes, repo_path);
260 scopes
261}
262
263fn merge_ecosystem_scopes(scopes: &mut Vec<ScopeDefinition>, repo_path: &Path) {
268 let ecosystem_scopes: Vec<(&str, &str, Vec<&str>)> = if repo_path.join("Cargo.toml").exists() {
269 vec![
270 (
271 "cargo",
272 "Cargo.toml and dependency management",
273 vec!["Cargo.toml", "Cargo.lock"],
274 ),
275 (
276 "lib",
277 "Library code and public API",
278 vec!["src/lib.rs", "src/**"],
279 ),
280 (
281 "cli",
282 "Command-line interface",
283 vec!["src/main.rs", "src/cli/**"],
284 ),
285 (
286 "core",
287 "Core application logic",
288 vec!["src/core/**", "src/lib/**"],
289 ),
290 ("test", "Test code", vec!["tests/**", "src/**/test*"]),
291 (
292 "docs",
293 "Documentation",
294 vec!["docs/**", "README.md", "**/*.md"],
295 ),
296 (
297 "ci",
298 "Continuous integration",
299 vec![".github/**", ".gitlab-ci.yml"],
300 ),
301 ]
302 } else if repo_path.join("package.json").exists() {
303 vec![
304 (
305 "deps",
306 "Dependencies and package.json",
307 vec!["package.json", "package-lock.json"],
308 ),
309 (
310 "config",
311 "Configuration files",
312 vec!["*.config.js", "*.config.json", ".env*"],
313 ),
314 (
315 "build",
316 "Build system and tooling",
317 vec!["webpack.config.js", "rollup.config.js"],
318 ),
319 (
320 "test",
321 "Test files",
322 vec!["test/**", "tests/**", "**/*.test.js"],
323 ),
324 (
325 "docs",
326 "Documentation",
327 vec!["docs/**", "README.md", "**/*.md"],
328 ),
329 ]
330 } else if repo_path.join("pyproject.toml").exists()
331 || repo_path.join("requirements.txt").exists()
332 {
333 vec![
334 (
335 "deps",
336 "Dependencies and requirements",
337 vec!["requirements.txt", "pyproject.toml", "setup.py"],
338 ),
339 (
340 "config",
341 "Configuration files",
342 vec!["*.ini", "*.cfg", "*.toml"],
343 ),
344 (
345 "test",
346 "Test files",
347 vec!["test/**", "tests/**", "**/*_test.py"],
348 ),
349 (
350 "docs",
351 "Documentation",
352 vec!["docs/**", "README.md", "**/*.md", "**/*.rst"],
353 ),
354 ]
355 } else if repo_path.join("go.mod").exists() {
356 vec![
357 (
358 "mod",
359 "Go modules and dependencies",
360 vec!["go.mod", "go.sum"],
361 ),
362 ("cmd", "Command-line applications", vec!["cmd/**"]),
363 ("pkg", "Library packages", vec!["pkg/**"]),
364 ("internal", "Internal packages", vec!["internal/**"]),
365 ("test", "Test files", vec!["**/*_test.go"]),
366 (
367 "docs",
368 "Documentation",
369 vec!["docs/**", "README.md", "**/*.md"],
370 ),
371 ]
372 } else if repo_path.join("pom.xml").exists() || repo_path.join("build.gradle").exists() {
373 vec![
374 (
375 "build",
376 "Build system",
377 vec!["pom.xml", "build.gradle", "build.gradle.kts"],
378 ),
379 (
380 "config",
381 "Configuration",
382 vec!["src/main/resources/**", "application.properties"],
383 ),
384 ("test", "Test files", vec!["src/test/**"]),
385 (
386 "docs",
387 "Documentation",
388 vec!["docs/**", "README.md", "**/*.md"],
389 ),
390 ]
391 } else {
392 vec![]
393 };
394
395 for (name, description, patterns) in ecosystem_scopes {
396 if !scopes.iter().any(|s| s.name == name) {
397 scopes.push(ScopeDefinition {
398 name: name.to_string(),
399 description: description.to_string(),
400 examples: vec![],
401 file_patterns: patterns.into_iter().map(String::from).collect(),
402 });
403 }
404 }
405}
406
407pub struct ProjectDiscovery {
409 repo_path: PathBuf,
410 context_dir: PathBuf,
411}
412
413impl ProjectDiscovery {
414 pub fn new(repo_path: PathBuf, context_dir: PathBuf) -> Self {
416 Self {
417 repo_path,
418 context_dir,
419 }
420 }
421
422 pub fn discover(&self) -> Result<ProjectContext> {
424 let mut context = ProjectContext::default();
425
426 let context_dir_path = if self.context_dir.is_absolute() {
428 self.context_dir.clone()
429 } else {
430 self.repo_path.join(&self.context_dir)
431 };
432 debug!(
433 context_dir = ?context_dir_path,
434 exists = context_dir_path.exists(),
435 "Looking for context directory"
436 );
437 debug!("Loading omni-dev config");
438 self.load_omni_dev_config(&mut context, &context_dir_path)?;
439 debug!("Config loading completed");
440
441 self.load_git_config(&mut context)?;
443
444 self.parse_documentation(&mut context)?;
446
447 self.detect_ecosystem(&mut context)?;
449
450 Ok(context)
451 }
452
453 fn load_omni_dev_config(&self, context: &mut ProjectContext, dir: &Path) -> Result<()> {
455 let guidelines_path = resolve_config_file(dir, "commit-guidelines.md");
457 debug!(
458 path = ?guidelines_path,
459 exists = guidelines_path.exists(),
460 "Checking for commit guidelines"
461 );
462 if guidelines_path.exists() {
463 let content = fs::read_to_string(&guidelines_path)?;
464 debug!(bytes = content.len(), "Loaded commit guidelines");
465 context.commit_guidelines = Some(content);
466 } else {
467 debug!("No commit guidelines file found");
468 }
469
470 let pr_guidelines_path = resolve_config_file(dir, "pr-guidelines.md");
472 debug!(
473 path = ?pr_guidelines_path,
474 exists = pr_guidelines_path.exists(),
475 "Checking for PR guidelines"
476 );
477 if pr_guidelines_path.exists() {
478 let content = fs::read_to_string(&pr_guidelines_path)?;
479 debug!(bytes = content.len(), "Loaded PR guidelines");
480 context.pr_guidelines = Some(content);
481 } else {
482 debug!("No PR guidelines file found");
483 }
484
485 let scopes_path = resolve_config_file(dir, "scopes.yaml");
487 if scopes_path.exists() {
488 let scopes_yaml = fs::read_to_string(&scopes_path)?;
489 match serde_yaml::from_str::<ScopesConfig>(&scopes_yaml) {
490 Ok(scopes_config) => {
491 context.valid_scopes = scopes_config.scopes;
492 }
493 Err(e) => {
494 tracing::warn!(
495 "Ignoring malformed scopes file {}: {e}",
496 scopes_path.display()
497 );
498 }
499 }
500 }
501
502 let local_contexts_dir = dir.join("local").join("context").join("feature-contexts");
504 let contexts_dir = dir.join("context").join("feature-contexts");
505
506 if contexts_dir.exists() {
508 self.load_feature_contexts(context, &contexts_dir)?;
509 }
510
511 if local_contexts_dir.exists() {
513 self.load_feature_contexts(context, &local_contexts_dir)?;
514 }
515
516 Ok(())
517 }
518
519 fn load_git_config(&self, _context: &mut ProjectContext) -> Result<()> {
521 Ok(())
523 }
524
525 fn parse_documentation(&self, context: &mut ProjectContext) -> Result<()> {
527 let contributing_path = self.repo_path.join("CONTRIBUTING.md");
529 if contributing_path.exists() {
530 let content = fs::read_to_string(contributing_path)?;
531 context.project_conventions = self.parse_contributing_conventions(&content)?;
532 }
533
534 let readme_path = self.repo_path.join("README.md");
536 if readme_path.exists() {
537 let content = fs::read_to_string(readme_path)?;
538 self.parse_readme_conventions(context, &content)?;
539 }
540
541 Ok(())
542 }
543
544 fn detect_ecosystem(&self, context: &mut ProjectContext) -> Result<()> {
546 context.ecosystem = if self.repo_path.join("Cargo.toml").exists() {
547 Ecosystem::Rust
548 } else if self.repo_path.join("package.json").exists() {
549 Ecosystem::Node
550 } else if self.repo_path.join("pyproject.toml").exists()
551 || self.repo_path.join("requirements.txt").exists()
552 {
553 Ecosystem::Python
554 } else if self.repo_path.join("go.mod").exists() {
555 Ecosystem::Go
556 } else if self.repo_path.join("pom.xml").exists()
557 || self.repo_path.join("build.gradle").exists()
558 {
559 Ecosystem::Java
560 } else {
561 Ecosystem::Generic
562 };
563
564 merge_ecosystem_scopes(&mut context.valid_scopes, &self.repo_path);
565
566 Ok(())
567 }
568
569 fn load_feature_contexts(
571 &self,
572 context: &mut ProjectContext,
573 contexts_dir: &Path,
574 ) -> Result<()> {
575 let entries = match fs::read_dir(contexts_dir) {
576 Ok(entries) => entries,
577 Err(e) => {
578 tracing::warn!(
579 "Cannot read feature contexts dir {}: {e}",
580 contexts_dir.display()
581 );
582 return Ok(());
583 }
584 };
585 for entry in entries.flatten() {
586 if let Some(name) = entry.file_name().to_str() {
587 if name.ends_with(".yaml") || name.ends_with(".yml") {
588 let content = fs::read_to_string(entry.path())?;
589 match serde_yaml::from_str::<FeatureContext>(&content) {
590 Ok(feature_context) => {
591 let feature_name = name
592 .trim_end_matches(".yaml")
593 .trim_end_matches(".yml")
594 .to_string();
595 context
596 .feature_contexts
597 .insert(feature_name, feature_context);
598 }
599 Err(e) => {
600 tracing::warn!(
601 "Ignoring malformed feature context {}: {e}",
602 entry.path().display()
603 );
604 }
605 }
606 }
607 }
608 }
609 Ok(())
610 }
611
612 fn parse_contributing_conventions(&self, content: &str) -> Result<ProjectConventions> {
614 let mut conventions = ProjectConventions::default();
615
616 let lines: Vec<&str> = content.lines().collect();
618 let mut in_commit_section = false;
619
620 for (i, line) in lines.iter().enumerate() {
621 let line_lower = line.to_lowercase();
622
623 if line_lower.contains("commit")
625 && (line_lower.contains("message") || line_lower.contains("format"))
626 {
627 in_commit_section = true;
628 continue;
629 }
630
631 if in_commit_section && line.starts_with('#') && !line_lower.contains("commit") {
633 in_commit_section = false;
634 }
635
636 if in_commit_section {
637 if line.contains("type(scope):") || line.contains("<type>(<scope>):") {
639 conventions.commit_format = Some("type(scope): description".to_string());
640 }
641
642 if line_lower.contains("signed-off-by") {
644 conventions
645 .required_trailers
646 .push("Signed-off-by".to_string());
647 }
648
649 if line_lower.contains("fixes") && line_lower.contains('#') {
650 conventions.required_trailers.push("Fixes".to_string());
651 }
652
653 if line.contains("feat") || line.contains("fix") || line.contains("docs") {
655 let types = extract_commit_types(line);
656 conventions.preferred_types.extend(types);
657 }
658
659 if line_lower.contains("scope") && i + 1 < lines.len() {
661 let scope_requirements = self.extract_scope_requirements(&lines[i..]);
662 conventions.scope_requirements = scope_requirements;
663 }
664 }
665 }
666
667 Ok(conventions)
668 }
669
670 fn parse_readme_conventions(&self, context: &mut ProjectContext, content: &str) -> Result<()> {
672 let lines: Vec<&str> = content.lines().collect();
674
675 for line in lines {
676 let _line_lower = line.to_lowercase();
677
678 if line.contains("src/") || line.contains("lib/") {
680 if let Some(scope) = extract_scope_from_structure(line) {
682 context.valid_scopes.push(ScopeDefinition {
683 name: scope.clone(),
684 description: format!("{scope} related changes"),
685 examples: vec![],
686 file_patterns: vec![format!("{}/**", scope)],
687 });
688 }
689 }
690 }
691
692 Ok(())
693 }
694
695 fn extract_scope_requirements(&self, lines: &[&str]) -> ScopeRequirements {
697 let mut requirements = ScopeRequirements::default();
698
699 for line in lines.iter().take(10) {
700 if line.starts_with("##") {
702 break;
703 }
704
705 let line_lower = line.to_lowercase();
706
707 if line_lower.contains("required") || line_lower.contains("must") {
708 requirements.required = true;
709 }
710
711 if line.contains(':')
713 && (line.contains("auth") || line.contains("api") || line.contains("ui"))
714 {
715 let scopes = extract_scopes_from_examples(line);
716 requirements.valid_scopes.extend(scopes);
717 }
718 }
719
720 requirements
721 }
722}
723
724#[derive(serde::Deserialize)]
726struct ScopesConfig {
727 scopes: Vec<ScopeDefinition>,
728}
729
730fn extract_commit_types(line: &str) -> Vec<String> {
732 let mut types = Vec::new();
733 let common_types = [
734 "feat", "fix", "docs", "style", "refactor", "test", "chore", "ci", "build", "perf",
735 ];
736
737 for &type_str in &common_types {
738 if line.to_lowercase().contains(type_str) {
739 types.push(type_str.to_string());
740 }
741 }
742
743 types
744}
745
746fn extract_scope_from_structure(line: &str) -> Option<String> {
748 if let Some(start) = line.find("src/") {
750 let after_src = &line[start + 4..];
751 if let Some(end) = after_src.find('/') {
752 return Some(after_src[..end].to_string());
753 }
754 }
755
756 None
757}
758
759fn extract_scopes_from_examples(line: &str) -> Vec<String> {
761 let mut scopes = Vec::new();
762 let common_scopes = ["auth", "api", "ui", "db", "config", "core", "cli", "web"];
763
764 for &scope in &common_scopes {
765 if line.to_lowercase().contains(scope) {
766 scopes.push(scope.to_string());
767 }
768 }
769
770 scopes
771}
772
773#[cfg(test)]
774#[allow(clippy::unwrap_used, clippy::expect_used)]
775mod tests {
776 use super::*;
777 use tempfile::TempDir;
778
779 #[test]
782 fn local_override_wins() -> anyhow::Result<()> {
783 let dir = {
784 std::fs::create_dir_all("tmp")?;
785 TempDir::new_in("tmp")?
786 };
787 let base = dir.path();
788
789 std::fs::create_dir_all(base.join("local"))?;
791 std::fs::write(base.join("local").join("scopes.yaml"), "local")?;
792 std::fs::write(base.join("scopes.yaml"), "project")?;
793
794 let resolved = resolve_config_file(base, "scopes.yaml");
795 assert_eq!(resolved, base.join("local").join("scopes.yaml"));
796 Ok(())
797 }
798
799 #[test]
800 fn project_fallback() -> anyhow::Result<()> {
801 let dir = {
802 std::fs::create_dir_all("tmp")?;
803 TempDir::new_in("tmp")?
804 };
805 let base = dir.path();
806
807 std::fs::write(base.join("scopes.yaml"), "project")?;
809
810 let resolved = resolve_config_file(base, "scopes.yaml");
811 assert_eq!(resolved, base.join("scopes.yaml"));
812 Ok(())
813 }
814
815 #[test]
816 fn returns_default_when_nothing_exists() {
817 let dir = {
818 std::fs::create_dir_all("tmp").ok();
819 TempDir::new_in("tmp").unwrap()
820 };
821 let base = dir.path();
822
823 let resolved = resolve_config_file(base, "scopes.yaml");
824 assert_ne!(resolved, base.join("local").join("scopes.yaml"));
829 }
830
831 #[test]
834 fn rust_ecosystem_detected() -> anyhow::Result<()> {
835 let dir = {
836 std::fs::create_dir_all("tmp")?;
837 TempDir::new_in("tmp")?
838 };
839 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
840
841 let mut scopes = vec![];
842 merge_ecosystem_scopes(&mut scopes, dir.path());
843
844 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
845 assert!(names.contains(&"cargo"), "missing 'cargo' scope");
846 assert!(names.contains(&"cli"), "missing 'cli' scope");
847 assert!(names.contains(&"core"), "missing 'core' scope");
848 assert!(names.contains(&"test"), "missing 'test' scope");
849 assert!(names.contains(&"docs"), "missing 'docs' scope");
850 assert!(names.contains(&"ci"), "missing 'ci' scope");
851 Ok(())
852 }
853
854 #[test]
855 fn node_ecosystem_detected() -> anyhow::Result<()> {
856 let dir = {
857 std::fs::create_dir_all("tmp")?;
858 TempDir::new_in("tmp")?
859 };
860 std::fs::write(dir.path().join("package.json"), "{}")?;
861
862 let mut scopes = vec![];
863 merge_ecosystem_scopes(&mut scopes, dir.path());
864
865 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
866 assert!(names.contains(&"deps"), "missing 'deps' scope");
867 assert!(names.contains(&"config"), "missing 'config' scope");
868 Ok(())
869 }
870
871 #[test]
872 fn go_ecosystem_detected() -> anyhow::Result<()> {
873 let dir = {
874 std::fs::create_dir_all("tmp")?;
875 TempDir::new_in("tmp")?
876 };
877 std::fs::write(dir.path().join("go.mod"), "module example")?;
878
879 let mut scopes = vec![];
880 merge_ecosystem_scopes(&mut scopes, dir.path());
881
882 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
883 assert!(names.contains(&"mod"), "missing 'mod' scope");
884 assert!(names.contains(&"cmd"), "missing 'cmd' scope");
885 assert!(names.contains(&"pkg"), "missing 'pkg' scope");
886 Ok(())
887 }
888
889 #[test]
890 fn existing_scope_not_overridden() -> anyhow::Result<()> {
891 let dir = {
892 std::fs::create_dir_all("tmp")?;
893 TempDir::new_in("tmp")?
894 };
895 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
896
897 let mut scopes = vec![ScopeDefinition {
898 name: "cli".to_string(),
899 description: "Custom CLI scope".to_string(),
900 examples: vec![],
901 file_patterns: vec!["custom/**".to_string()],
902 }];
903 merge_ecosystem_scopes(&mut scopes, dir.path());
904
905 let cli_scope = scopes.iter().find(|s| s.name == "cli").unwrap();
907 assert_eq!(cli_scope.description, "Custom CLI scope");
908 assert_eq!(cli_scope.file_patterns, vec!["custom/**"]);
909 Ok(())
910 }
911
912 #[test]
913 fn no_marker_files_produces_empty() {
914 let dir = {
915 std::fs::create_dir_all("tmp").ok();
916 TempDir::new_in("tmp").unwrap()
917 };
918 let mut scopes = vec![];
919 merge_ecosystem_scopes(&mut scopes, dir.path());
920 assert!(scopes.is_empty());
921 }
922
923 #[test]
926 fn load_project_scopes_with_yaml() -> anyhow::Result<()> {
927 let dir = {
928 std::fs::create_dir_all("tmp")?;
929 TempDir::new_in("tmp")?
930 };
931 let config_dir = dir.path().join("config");
932 std::fs::create_dir_all(&config_dir)?;
933
934 let scopes_yaml = r#"
935scopes:
936 - name: custom
937 description: Custom scope
938 examples: []
939 file_patterns:
940 - "src/custom/**"
941"#;
942 std::fs::write(config_dir.join("scopes.yaml"), scopes_yaml)?;
943
944 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
946
947 let scopes = load_project_scopes(&config_dir, dir.path());
948 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
949 assert!(names.contains(&"custom"), "missing custom scope");
950 assert!(names.contains(&"cargo"), "missing ecosystem scope");
952 Ok(())
953 }
954
955 #[test]
956 fn load_project_scopes_no_file() -> anyhow::Result<()> {
957 let dir = {
958 std::fs::create_dir_all("tmp")?;
959 TempDir::new_in("tmp")?
960 };
961 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
962
963 let scopes = load_project_scopes(dir.path(), dir.path());
964 assert!(!scopes.is_empty());
966 Ok(())
967 }
968
969 #[test]
972 fn extract_scope_from_structure_src() {
973 assert_eq!(
974 extract_scope_from_structure("- `src/auth/` - Authentication"),
975 Some("auth".to_string())
976 );
977 }
978
979 #[test]
980 fn extract_scope_from_structure_no_match() {
981 assert_eq!(extract_scope_from_structure("No source paths here"), None);
982 }
983
984 #[test]
985 fn extract_commit_types_from_line() {
986 let types = extract_commit_types("feat, fix, docs, test");
987 assert!(types.contains(&"feat".to_string()));
988 assert!(types.contains(&"fix".to_string()));
989 assert!(types.contains(&"docs".to_string()));
990 assert!(types.contains(&"test".to_string()));
991 }
992
993 #[test]
994 fn extract_commit_types_empty_line() {
995 let types = extract_commit_types("no types here");
996 assert!(types.is_empty());
997 }
998
999 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1003
1004 #[test]
1005 fn context_dir_defaults_to_omni_dev() {
1006 let _lock = ENV_MUTEX.lock().unwrap();
1007 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1008 let result = resolve_context_dir(None);
1009 assert!(
1011 result.ends_with(".omni-dev"),
1012 "expected path ending in .omni-dev, got {result:?}"
1013 );
1014 }
1015
1016 #[test]
1017 fn context_dir_uses_override() {
1018 let _lock = ENV_MUTEX.lock().unwrap();
1019 let custom = PathBuf::from("custom-config");
1020 let result = resolve_context_dir(Some(&custom));
1021 assert_eq!(result, custom);
1022 }
1023
1024 #[test]
1025 fn context_dir_env_var() {
1026 let _lock = ENV_MUTEX.lock().unwrap();
1027 std::env::set_var("OMNI_DEV_CONFIG_DIR", "/tmp/my-config");
1028 let result = resolve_context_dir(None);
1029 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1030 assert_eq!(result, PathBuf::from("/tmp/my-config"));
1031 }
1032
1033 #[test]
1034 fn context_dir_cli_flag_beats_env_var() {
1035 let _lock = ENV_MUTEX.lock().unwrap();
1036 std::env::set_var("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1037 let cli = PathBuf::from("cli-config");
1038 let result = resolve_context_dir(Some(&cli));
1039 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1040 assert_eq!(result, cli);
1041 }
1042
1043 #[test]
1044 fn context_dir_ignores_empty_env_var() {
1045 let _lock = ENV_MUTEX.lock().unwrap();
1046 std::env::set_var("OMNI_DEV_CONFIG_DIR", "");
1047 let result = resolve_context_dir(None);
1048 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1049 assert!(
1051 result.ends_with(".omni-dev"),
1052 "expected path ending in .omni-dev, got {result:?}"
1053 );
1054 }
1055
1056 #[test]
1059 fn with_source_cli_flag() {
1060 let _lock = ENV_MUTEX.lock().unwrap();
1061 let custom = PathBuf::from("custom-config");
1062 let (path, source) = resolve_context_dir_with_source(Some(&custom));
1063 assert_eq!(path, custom);
1064 assert_eq!(source, ConfigDirSource::CliFlag);
1065 }
1066
1067 #[test]
1068 fn with_source_env_var() {
1069 let _lock = ENV_MUTEX.lock().unwrap();
1070 std::env::set_var("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1071 let (path, source) = resolve_context_dir_with_source(None);
1072 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1073 assert_eq!(path, PathBuf::from("/tmp/env-config"));
1074 assert_eq!(source, ConfigDirSource::EnvVar);
1075 }
1076
1077 #[test]
1078 fn with_source_cli_beats_env() {
1079 let _lock = ENV_MUTEX.lock().unwrap();
1080 std::env::set_var("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1081 let custom = PathBuf::from("cli-config");
1082 let (path, source) = resolve_context_dir_with_source(Some(&custom));
1083 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1084 assert_eq!(path, custom);
1085 assert_eq!(source, ConfigDirSource::CliFlag);
1086 }
1087
1088 #[test]
1089 fn with_source_walk_up_or_default() {
1090 let _lock = ENV_MUTEX.lock().unwrap();
1091 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1092 let (path, source) = resolve_context_dir_with_source(None);
1093 assert!(
1095 path.ends_with(".omni-dev"),
1096 "expected path ending in .omni-dev, got {path:?}"
1097 );
1098 assert!(
1099 source == ConfigDirSource::WalkUp || source == ConfigDirSource::Default,
1100 "expected WalkUp or Default, got {source:?}"
1101 );
1102 }
1103
1104 #[test]
1107 fn display_config_dir_source_cli_flag() {
1108 assert_eq!(ConfigDirSource::CliFlag.to_string(), "--context-dir");
1109 }
1110
1111 #[test]
1112 fn display_config_dir_source_env_var() {
1113 assert_eq!(ConfigDirSource::EnvVar.to_string(), "OMNI_DEV_CONFIG_DIR");
1114 }
1115
1116 #[test]
1117 fn display_config_dir_source_walk_up() {
1118 assert_eq!(ConfigDirSource::WalkUp.to_string(), "walk-up");
1119 }
1120
1121 #[test]
1122 fn display_config_dir_source_default() {
1123 assert_eq!(ConfigDirSource::Default.to_string(), "default");
1124 }
1125
1126 #[test]
1129 fn load_config_content_reads_project_file() -> anyhow::Result<()> {
1130 let dir = {
1131 std::fs::create_dir_all("tmp")?;
1132 TempDir::new_in("tmp")?
1133 };
1134 let base = dir.path();
1135
1136 std::fs::write(
1137 base.join("commit-guidelines.md"),
1138 "# Guidelines\nBe concise.",
1139 )?;
1140
1141 let content = load_config_content(base, "commit-guidelines.md")?;
1142 assert_eq!(content, Some("# Guidelines\nBe concise.".to_string()));
1143 Ok(())
1144 }
1145
1146 #[test]
1147 fn load_config_content_prefers_local_override() -> anyhow::Result<()> {
1148 let dir = {
1149 std::fs::create_dir_all("tmp")?;
1150 TempDir::new_in("tmp")?
1151 };
1152 let base = dir.path();
1153
1154 std::fs::create_dir_all(base.join("local"))?;
1155 std::fs::write(base.join("local").join("guidelines.md"), "local content")?;
1156 std::fs::write(base.join("guidelines.md"), "project content")?;
1157
1158 let content = load_config_content(base, "guidelines.md")?;
1159 assert_eq!(content, Some("local content".to_string()));
1160 Ok(())
1161 }
1162
1163 #[test]
1164 fn load_config_content_returns_none_when_missing() -> anyhow::Result<()> {
1165 let dir = {
1166 std::fs::create_dir_all("tmp")?;
1167 TempDir::new_in("tmp")?
1168 };
1169
1170 let content = load_config_content(dir.path(), "nonexistent.md")?;
1171 assert_eq!(content, None);
1172 Ok(())
1173 }
1174
1175 #[test]
1178 fn source_label_local_override() -> anyhow::Result<()> {
1179 let dir = {
1180 std::fs::create_dir_all("tmp")?;
1181 TempDir::new_in("tmp")?
1182 };
1183 let base = dir.path();
1184
1185 std::fs::create_dir_all(base.join("local"))?;
1186 std::fs::write(base.join("local").join("scopes.yaml"), "local")?;
1187 std::fs::write(base.join("scopes.yaml"), "project")?;
1188
1189 let label = config_source_label(base, "scopes.yaml");
1190 assert_eq!(
1191 label,
1192 ConfigSourceLabel::LocalOverride(base.join("local").join("scopes.yaml"))
1193 );
1194 Ok(())
1195 }
1196
1197 #[test]
1198 fn source_label_project() -> anyhow::Result<()> {
1199 let dir = {
1200 std::fs::create_dir_all("tmp")?;
1201 TempDir::new_in("tmp")?
1202 };
1203 let base = dir.path();
1204
1205 std::fs::write(base.join("scopes.yaml"), "project")?;
1206
1207 let label = config_source_label(base, "scopes.yaml");
1208 assert_eq!(label, ConfigSourceLabel::Project(base.join("scopes.yaml")));
1209 Ok(())
1210 }
1211
1212 #[test]
1213 fn source_label_not_found() {
1214 let dir = {
1215 std::fs::create_dir_all("tmp").ok();
1216 TempDir::new_in("tmp").unwrap()
1217 };
1218
1219 let label = config_source_label(dir.path(), "nonexistent.yaml");
1220 assert_eq!(label, ConfigSourceLabel::NotFound);
1221 }
1222
1223 #[test]
1226 fn display_local_override() {
1227 let label = ConfigSourceLabel::LocalOverride(PathBuf::from(".omni-dev/local/scopes.yaml"));
1228 assert_eq!(
1229 label.to_string(),
1230 "Local override: .omni-dev/local/scopes.yaml"
1231 );
1232 }
1233
1234 #[test]
1235 fn display_project() {
1236 let label = ConfigSourceLabel::Project(PathBuf::from(".omni-dev/scopes.yaml"));
1237 assert_eq!(label.to_string(), "Project: .omni-dev/scopes.yaml");
1238 }
1239
1240 #[test]
1241 fn display_global() {
1242 let label = ConfigSourceLabel::Global(PathBuf::from("/home/user/.omni-dev/scopes.yaml"));
1243 assert_eq!(
1244 label.to_string(),
1245 "Global: /home/user/.omni-dev/scopes.yaml"
1246 );
1247 }
1248
1249 #[test]
1250 fn display_xdg() {
1251 let label =
1252 ConfigSourceLabel::Xdg(PathBuf::from("/home/user/.config/omni-dev/scopes.yaml"));
1253 assert_eq!(
1254 label.to_string(),
1255 "Global (XDG): /home/user/.config/omni-dev/scopes.yaml"
1256 );
1257 }
1258
1259 #[test]
1260 fn display_not_found() {
1261 let label = ConfigSourceLabel::NotFound;
1262 assert_eq!(label.to_string(), "(not found)");
1263 }
1264
1265 #[test]
1268 fn xdg_config_dir_uses_env_var() {
1269 let _lock = ENV_MUTEX.lock().unwrap();
1270 std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-test");
1271 let result = xdg_config_dir();
1272 std::env::remove_var("XDG_CONFIG_HOME");
1273 assert_eq!(result, Some(PathBuf::from("/tmp/xdg-test/omni-dev")));
1274 }
1275
1276 #[test]
1277 fn xdg_config_dir_ignores_empty_env_var() {
1278 let _lock = ENV_MUTEX.lock().unwrap();
1279 std::env::set_var("XDG_CONFIG_HOME", "");
1280 let result = xdg_config_dir();
1281 std::env::remove_var("XDG_CONFIG_HOME");
1282 if let Some(home) = dirs::home_dir() {
1284 assert_eq!(result, Some(home.join(".config").join("omni-dev")));
1285 }
1286 }
1287
1288 #[test]
1289 fn xdg_config_dir_defaults_to_home_config() {
1290 let _lock = ENV_MUTEX.lock().unwrap();
1291 std::env::remove_var("XDG_CONFIG_HOME");
1292 let result = xdg_config_dir();
1293 if let Some(home) = dirs::home_dir() {
1294 assert_eq!(result, Some(home.join(".config").join("omni-dev")));
1295 }
1296 }
1297
1298 #[test]
1301 fn resolve_config_file_finds_xdg() -> anyhow::Result<()> {
1302 let _lock = ENV_MUTEX.lock().unwrap();
1303
1304 let xdg_dir = {
1305 std::fs::create_dir_all("tmp")?;
1306 TempDir::new_in("tmp")?
1307 };
1308 let xdg_omni = xdg_dir.path().join("omni-dev");
1309 std::fs::create_dir_all(&xdg_omni)?;
1310 std::fs::write(xdg_omni.join("commit-guidelines.md"), "xdg content")?;
1311
1312 std::env::set_var("XDG_CONFIG_HOME", xdg_dir.path());
1313 let project_dir = {
1314 std::fs::create_dir_all("tmp")?;
1315 TempDir::new_in("tmp")?
1316 };
1317 let resolved = resolve_config_file(project_dir.path(), "commit-guidelines.md");
1318 std::env::remove_var("XDG_CONFIG_HOME");
1319
1320 assert_eq!(resolved, xdg_omni.join("commit-guidelines.md"));
1321 Ok(())
1322 }
1323
1324 #[test]
1325 fn resolve_config_file_xdg_beats_home() -> anyhow::Result<()> {
1326 let _lock = ENV_MUTEX.lock().unwrap();
1327
1328 let xdg_dir = {
1330 std::fs::create_dir_all("tmp")?;
1331 TempDir::new_in("tmp")?
1332 };
1333 let xdg_omni = xdg_dir.path().join("omni-dev");
1334 std::fs::create_dir_all(&xdg_omni)?;
1335 std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1336
1337 std::env::set_var("XDG_CONFIG_HOME", xdg_dir.path());
1338
1339 let project_dir = {
1341 std::fs::create_dir_all("tmp")?;
1342 TempDir::new_in("tmp")?
1343 };
1344
1345 let resolved = resolve_config_file(project_dir.path(), "scopes.yaml");
1346 std::env::remove_var("XDG_CONFIG_HOME");
1347
1348 assert_eq!(resolved, xdg_omni.join("scopes.yaml"));
1350 Ok(())
1351 }
1352
1353 #[test]
1354 fn resolve_config_file_project_beats_xdg() -> anyhow::Result<()> {
1355 let _lock = ENV_MUTEX.lock().unwrap();
1356
1357 let xdg_dir = {
1359 std::fs::create_dir_all("tmp")?;
1360 TempDir::new_in("tmp")?
1361 };
1362 let xdg_omni = xdg_dir.path().join("omni-dev");
1363 std::fs::create_dir_all(&xdg_omni)?;
1364 std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1365
1366 std::env::set_var("XDG_CONFIG_HOME", xdg_dir.path());
1367
1368 let project_dir = {
1370 std::fs::create_dir_all("tmp")?;
1371 TempDir::new_in("tmp")?
1372 };
1373 std::fs::write(project_dir.path().join("scopes.yaml"), "project")?;
1374
1375 let resolved = resolve_config_file(project_dir.path(), "scopes.yaml");
1376 std::env::remove_var("XDG_CONFIG_HOME");
1377
1378 assert_eq!(resolved, project_dir.path().join("scopes.yaml"));
1380 Ok(())
1381 }
1382
1383 #[test]
1386 fn source_label_xdg() -> anyhow::Result<()> {
1387 let _lock = ENV_MUTEX.lock().unwrap();
1388
1389 let xdg_dir = {
1390 std::fs::create_dir_all("tmp")?;
1391 TempDir::new_in("tmp")?
1392 };
1393 let xdg_omni = xdg_dir.path().join("omni-dev");
1394 std::fs::create_dir_all(&xdg_omni)?;
1395 std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1396
1397 std::env::set_var("XDG_CONFIG_HOME", xdg_dir.path());
1398
1399 let project_dir = {
1400 std::fs::create_dir_all("tmp")?;
1401 TempDir::new_in("tmp")?
1402 };
1403 let label = config_source_label(project_dir.path(), "scopes.yaml");
1404 std::env::remove_var("XDG_CONFIG_HOME");
1405
1406 assert_eq!(label, ConfigSourceLabel::Xdg(xdg_omni.join("scopes.yaml")));
1407 Ok(())
1408 }
1409
1410 fn make_repo_tree() -> anyhow::Result<TempDir> {
1415 let dir = {
1416 std::fs::create_dir_all("tmp")?;
1417 TempDir::new_in("tmp")?
1418 };
1419 std::fs::create_dir(dir.path().join(".git"))?;
1421 Ok(dir)
1422 }
1423
1424 #[test]
1425 fn walk_up_finds_omni_dev_in_start_dir() -> anyhow::Result<()> {
1426 let repo = make_repo_tree()?;
1427 let sub = repo.path().join("packages").join("frontend");
1428 std::fs::create_dir_all(&sub)?;
1429 std::fs::create_dir(sub.join(".omni-dev"))?;
1430
1431 let result = walk_up_find_config_dir(&sub);
1432 assert_eq!(result, Some(sub.join(".omni-dev")));
1433 Ok(())
1434 }
1435
1436 #[test]
1437 fn walk_up_finds_omni_dev_in_parent() -> anyhow::Result<()> {
1438 let repo = make_repo_tree()?;
1439 let pkg = repo.path().join("packages").join("frontend");
1440 let src = pkg.join("src");
1441 std::fs::create_dir_all(&src)?;
1442 std::fs::create_dir(pkg.join(".omni-dev"))?;
1443
1444 let result = walk_up_find_config_dir(&src);
1445 assert_eq!(result, Some(pkg.join(".omni-dev")));
1446 Ok(())
1447 }
1448
1449 #[test]
1450 fn walk_up_finds_omni_dev_at_repo_root() -> anyhow::Result<()> {
1451 let repo = make_repo_tree()?;
1452 let deep = repo.path().join("a").join("b").join("c");
1453 std::fs::create_dir_all(&deep)?;
1454 std::fs::create_dir(repo.path().join(".omni-dev"))?;
1455
1456 let result = walk_up_find_config_dir(&deep);
1457 assert_eq!(result, Some(repo.path().join(".omni-dev")));
1458 Ok(())
1459 }
1460
1461 #[test]
1462 fn walk_up_nearest_wins() -> anyhow::Result<()> {
1463 let repo = make_repo_tree()?;
1464 let pkg = repo.path().join("packages").join("frontend");
1465 let src = pkg.join("src");
1466 std::fs::create_dir_all(&src)?;
1467 std::fs::create_dir(repo.path().join(".omni-dev"))?;
1469 std::fs::create_dir(pkg.join(".omni-dev"))?;
1470
1471 let result = walk_up_find_config_dir(&src);
1472 assert_eq!(result, Some(pkg.join(".omni-dev")));
1474 Ok(())
1475 }
1476
1477 #[test]
1478 fn walk_up_stops_at_git_boundary() -> anyhow::Result<()> {
1479 let dir = {
1480 std::fs::create_dir_all("tmp")?;
1481 TempDir::new_in("tmp")?
1482 };
1483 std::fs::create_dir(dir.path().join(".omni-dev"))?;
1485 let repo_root = dir.path().join("repo");
1487 std::fs::create_dir_all(&repo_root)?;
1488 std::fs::create_dir(repo_root.join(".git"))?;
1489 let sub = repo_root.join("sub");
1490 std::fs::create_dir(&sub)?;
1491
1492 let result = walk_up_find_config_dir(&sub);
1493 assert_eq!(result, None);
1495 Ok(())
1496 }
1497
1498 #[test]
1499 fn walk_up_returns_none_when_no_omni_dev() -> anyhow::Result<()> {
1500 let repo = make_repo_tree()?;
1501 let sub = repo.path().join("src");
1502 std::fs::create_dir(&sub)?;
1503
1504 let result = walk_up_find_config_dir(&sub);
1505 assert_eq!(result, None);
1506 Ok(())
1507 }
1508
1509 #[test]
1510 fn walk_up_handles_git_worktree_file() -> anyhow::Result<()> {
1511 let dir = {
1512 std::fs::create_dir_all("tmp")?;
1513 TempDir::new_in("tmp")?
1514 };
1515 std::fs::write(dir.path().join(".git"), "gitdir: /some/path")?;
1517 std::fs::create_dir(dir.path().join(".omni-dev"))?;
1518 let sub = dir.path().join("src");
1519 std::fs::create_dir(&sub)?;
1520
1521 let result = walk_up_find_config_dir(&sub);
1522 assert_eq!(result, Some(dir.path().join(".omni-dev")));
1523 Ok(())
1524 }
1525
1526 #[test]
1527 fn walk_up_no_omni_dev_in_repo_returns_none() -> anyhow::Result<()> {
1528 let repo = make_repo_tree()?;
1530 let sub = repo.path().join("a").join("b");
1531 std::fs::create_dir_all(&sub)?;
1532 let result = walk_up_find_config_dir(&sub);
1533 assert_eq!(result, None);
1534 Ok(())
1535 }
1536}