diff --git a/src/config/loader.rs b/src/config/loader.rs index f7cec38..90fc40a 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -242,7 +242,7 @@ pub fn load_config( // 1. Load global config (~/.cora/config.yaml) if let Some(cora) = load_global_config()? { - cora.merge_into(&mut config); + cora.merge_into(&mut config)?; } // 2. Load project config (.cora.yaml) @@ -257,10 +257,10 @@ pub fn load_config( path.display() )) })?; - cora.merge_into(&mut config); + cora.merge_into(&mut config)?; debug!(path = %path.display(), "loaded explicit config"); } else if let Some((path, cora)) = find_cora_file(&std::env::current_dir()?)? { - cora.merge_into(&mut config); + cora.merge_into(&mut config)?; debug!(path = %path.display(), "loaded discovered config"); } else { debug!("no .cora.yaml found, using defaults"); diff --git a/src/config/schema.rs b/src/config/schema.rs index ede617b..185a937 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -335,7 +335,7 @@ impl CoraFile { /// Merge this file config into a `Config`, overwriting only fields that are present. #[allow(clippy::assigning_clones)] - pub fn merge_into(&self, config: &mut Config) { + pub fn merge_into(&self, config: &mut Config) -> Result<(), CoraError> { if let Some(v) = &self.model { config.provider.model.clone_from(v); } @@ -473,11 +473,15 @@ impl CoraFile { config.profile = Some(profile); } Err(e) => { - // Warn but don't fail — profile is optional enhancement - tracing::warn!("invalid profile in config, ignoring: {e}"); + // Fail fast — invalid profile means review policy is broken. + // Better to error than silently run without the intended policy. + return Err(CoraError::ConfigParse(format!( + "invalid profile in config: {e}" + ))); } } } + Ok(()) } } @@ -542,7 +546,7 @@ mod tests { fn merge_empty_cora_file_leaves_defaults() { let mut cfg = Config::default(); let cora = CoraFile::default(); - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.provider.provider, "openai"); assert_eq!(cfg.provider.model, "gpt-4o-mini"); assert_eq!(cfg.output.format, "pretty"); @@ -559,7 +563,7 @@ mod tests { }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.provider.provider, "anthropic"); assert_eq!(cfg.provider.model, "claude-3-haiku"); assert_eq!(cfg.provider.base_url, "https://api.anthropic.com/v1"); @@ -577,7 +581,7 @@ base_url: https://api.z.ai/api/coding/paas/v4 ) .unwrap(); - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.provider.provider, "openai"); assert_eq!(cfg.provider.model, "glm-5.1"); @@ -595,7 +599,7 @@ base_url: https://api.z.ai/api/coding/paas/v4 }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.provider.provider, "ollama"); assert_eq!(cfg.provider.model, "llama3.1"); // resolved from ollama preset assert_eq!(cfg.provider.base_url, "http://localhost:11434/v1"); // resolved from ollama preset @@ -611,7 +615,7 @@ provider: zai ) .unwrap(); - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.provider.provider, "zai"); assert_eq!(cfg.provider.model, "glm-5.1"); // resolved from zai preset assert_eq!(cfg.provider.base_url, "https://api.z.ai/api/coding/paas/v4"); // resolved from zai preset @@ -624,7 +628,7 @@ provider: zai focus: Some(vec!["security".to_string(), "bugs".to_string()]), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.focus, vec!["security", "bugs"]); } @@ -635,7 +639,7 @@ provider: zai rules: Some(vec!["no unwrap".to_string()]), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.rules, vec!["no unwrap"]); } @@ -649,7 +653,7 @@ provider: zai }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.ignore.files, vec!["vendor/**"]); assert_eq!(cfg.ignore.rules, vec!["skip-rule-1"]); } @@ -666,7 +670,7 @@ provider: zai }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.hook.mode, "block"); assert_eq!(cfg.hook.min_severity, "critical"); assert_eq!(cfg.hook.max_diff_size, 1024); @@ -683,7 +687,7 @@ provider: zai }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.output.format, "json"); assert!(!cfg.output.color); } @@ -854,7 +858,7 @@ review: }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.response_format, "json_object"); } @@ -871,7 +875,7 @@ review: }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!( cfg.review_system_prompt_override.as_deref(), Some("Custom prompt here.") @@ -891,7 +895,7 @@ review: }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!( cfg.review_system_prompt_file.as_deref(), Some("prompts/review.md") @@ -924,7 +928,7 @@ scan: }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!( cfg.scan_system_prompt_override.as_deref(), Some("Performance only.") @@ -1023,7 +1027,7 @@ llm: }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.temperature, 0.7); // Other LLM fields should remain at defaults assert_eq!(cfg.max_tokens, 4096); @@ -1043,7 +1047,7 @@ llm: }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.max_tokens, 2048); } @@ -1059,7 +1063,7 @@ llm: }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.timeout, 300); } @@ -1075,7 +1079,7 @@ llm: }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.temperature, 1.0); assert_eq!(cfg.max_tokens, 16384); assert_eq!(cfg.timeout, 240); @@ -1194,7 +1198,7 @@ bundling: }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.bundling.max_chars_per_group, 30_000); assert_eq!(cfg.bundling.max_files_per_group, 10); assert_eq!( @@ -1218,7 +1222,7 @@ bundling: }), ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.bundling.max_chars_per_group, 40_000); assert_eq!(cfg.bundling.max_files_per_group, 20); // default unchanged assert_eq!( @@ -1235,7 +1239,7 @@ bundling: let cora = CoraFile { ..Default::default() }; - cora.merge_into(&mut cfg); + cora.merge_into(&mut cfg).unwrap(); assert_eq!(cfg.bundling.max_chars_per_group, 60_000); assert_eq!(cfg.bundling.max_files_per_group, 20); } diff --git a/src/engine/profiles.rs b/src/engine/profiles.rs index 5d18761..ef9911e 100644 --- a/src/engine/profiles.rs +++ b/src/engine/profiles.rs @@ -157,11 +157,19 @@ pub fn load_all_builtins() -> Vec { /// Resolve a `ProfileRef` from `.cora.yaml` into a final `Profile`. /// /// Resolution order: -/// 1. If `Name("builtin")` → load built-in -/// 2. If `Name("./path.yaml")` → load from file -/// 3. If `Inline { extends, ... }` → merge with base -/// 4. If `Inline` without extends → use as-is +/// - If `Name("builtin")` → load built-in +/// - If `Name("./path.yaml")` → load from file (resolve relative to project root) +/// - If `Inline { extends, ... }` → merge with base +/// - If `Inline` without extends → use as-is pub fn resolve_profile(profile_ref: &ProfileRef) -> Result { + resolve_profile_with_root(profile_ref, None) +} + +/// Resolve profile with optional project root for relative file path resolution. +pub fn resolve_profile_with_root( + profile_ref: &ProfileRef, + project_root: Option<&std::path::Path>, +) -> Result { match profile_ref { ProfileRef::Name(name) => { // Try built-in first @@ -169,11 +177,15 @@ pub fn resolve_profile(profile_ref: &ProfileRef) -> Result { debug!(profile = name, "loaded built-in profile"); return Ok(p); } - // Try loading from file (resolve relative to CWD) - let path = std::path::Path::new(name); + // Resolve relative to project root (not CWD) for deterministic behavior + let path = if let Some(root) = project_root { + root.join(name) + } else { + std::path::PathBuf::from(name) + }; if path.is_file() { - let content = std::fs::read_to_string(path) - .map_err(|e| format!("cannot read profile file '{}': {e}", name))?; + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("cannot read profile file '{}': {e}", path.display()))?; let mut profile = parse_profile_yaml(&content)?; // If profile has no name, derive from filename if profile.name.is_empty() { @@ -183,11 +195,11 @@ pub fn resolve_profile(profile_ref: &ProfileRef) -> Result { .unwrap_or("custom") .to_string(); } - debug!(profile = name, "loaded profile from file"); + debug!(profile = %path.display(), "loaded profile from file"); return Ok(profile); } Err(format!( - "unknown profile '{name}'. Available: {}", + "unknown profile '{name}'. Available built-ins: {}", BUILTIN_PROFILES.join(", ") )) } @@ -206,9 +218,18 @@ pub fn resolve_profile(profile_ref: &ProfileRef) -> Result { profile.name = "custom".to_string(); } - // Merge focus areas (append, don't replace) + // Merge focus areas by id — replace existing, append new if !inline.focus_areas.is_empty() { - profile.focus_areas.extend(inline.focus_areas.clone()); + for area in &inline.focus_areas { + if let Some(existing) = profile.focus_areas.iter_mut().find(|a| a.id == area.id) + { + // Replace existing focus area with override + *existing = area.clone(); + } else { + // Append new focus area + profile.focus_areas.push(area.clone()); + } + } } // Override ignore areas