use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::workflow::schema::WorkflowConfig;
const EMBEDDED_DEFAULTS_YAML: &str = r#"
global:
timeout: 300s
working_directory: "."
agent:
command: claude
model: claude-sonnet-4-20250514
flags:
- "-p"
- "--output-format"
- "stream-json"
permissions: skip
chat:
provider: anthropic
model: claude-sonnet-4-20250514
api_key_env: ANTHROPIC_API_KEY
temperature: 0.2
max_tokens: 4096
cmd:
fail_on_error: true
timeout: 60s
"#;
fn embedded_defaults() -> &'static WorkflowConfig {
static DEFAULTS: OnceLock<WorkflowConfig> = OnceLock::new();
DEFAULTS.get_or_init(|| {
serde_yaml::from_str(EMBEDDED_DEFAULTS_YAML)
.expect("BUG: embedded defaults YAML is invalid — this is a compile-time error")
})
}
fn override_config_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(home) = dirs::home_dir() {
paths.push(home.join(".minion").join("defaults.yaml"));
}
if let Ok(cwd) = std::env::current_dir() {
paths.push(cwd.join(".minion").join("config.yaml"));
}
paths
}
fn load_config_file(path: &Path) -> Option<WorkflowConfig> {
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(path).ok()?;
let config: WorkflowConfig = serde_yaml::from_str(&content)
.map_err(|e| {
tracing::warn!(
path = %path.display(),
error = %e,
"Failed to parse config file — skipping"
);
e
})
.ok()?;
tracing::info!(path = %path.display(), "Loaded config overrides");
Some(config)
}
fn merge_map(
base: &HashMap<String, serde_yaml::Value>,
overlay: &HashMap<String, serde_yaml::Value>,
) -> HashMap<String, serde_yaml::Value> {
let mut merged = base.clone();
for (k, v) in overlay {
merged.insert(k.clone(), v.clone());
}
merged
}
fn merge_patterns(
base: &HashMap<String, HashMap<String, serde_yaml::Value>>,
overlay: &HashMap<String, HashMap<String, serde_yaml::Value>>,
) -> HashMap<String, HashMap<String, serde_yaml::Value>> {
let mut merged = base.clone();
for (pattern, values) in overlay {
let entry = merged.entry(pattern.clone()).or_default();
for (k, v) in values {
entry.insert(k.clone(), v.clone());
}
}
merged
}
pub fn merge_workflow_config(base: &WorkflowConfig, overlay: &WorkflowConfig) -> WorkflowConfig {
WorkflowConfig {
global: merge_map(&base.global, &overlay.global),
agent: merge_map(&base.agent, &overlay.agent),
cmd: merge_map(&base.cmd, &overlay.cmd),
chat: merge_map(&base.chat, &overlay.chat),
gate: merge_map(&base.gate, &overlay.gate),
patterns: merge_patterns(&base.patterns, &overlay.patterns),
plugins: if overlay.plugins.is_empty() {
base.plugins.clone()
} else {
overlay.plugins.clone()
},
events: overlay.events.clone().or_else(|| base.events.clone()),
}
}
pub fn load_defaults() -> WorkflowConfig {
let mut merged = embedded_defaults().clone();
for path in override_config_paths() {
if let Some(config) = load_config_file(&path) {
merged = merge_workflow_config(&merged, &config);
}
}
merged
}
pub fn apply_defaults(workflow_config: &WorkflowConfig) -> WorkflowConfig {
let defaults = load_defaults();
merge_workflow_config(&defaults, workflow_config)
}
#[cfg(test)]
mod tests {
use super::*;
fn yaml_str(s: &str) -> serde_yaml::Value {
serde_yaml::Value::String(s.to_string())
}
#[test]
fn embedded_defaults_parse_correctly() {
let defaults = embedded_defaults();
assert_eq!(
defaults.chat.get("model").unwrap(),
&yaml_str("claude-sonnet-4-20250514")
);
assert_eq!(
defaults.agent.get("model").unwrap(),
&yaml_str("claude-sonnet-4-20250514")
);
assert_eq!(
defaults.chat.get("provider").unwrap(),
&yaml_str("anthropic")
);
assert_eq!(
defaults.global.get("timeout").unwrap(),
&yaml_str("300s")
);
assert!(defaults.cmd.contains_key("fail_on_error"));
}
#[test]
fn merge_map_overlay_wins() {
let mut base = HashMap::new();
base.insert("model".into(), yaml_str("claude-3-haiku"));
base.insert("timeout".into(), yaml_str("300s"));
let mut overlay = HashMap::new();
overlay.insert("model".into(), yaml_str("claude-sonnet-4-20250514"));
let merged = merge_map(&base, &overlay);
assert_eq!(
merged.get("model").unwrap(),
&yaml_str("claude-sonnet-4-20250514")
);
assert_eq!(merged.get("timeout").unwrap(), &yaml_str("300s"));
}
#[test]
fn merge_map_preserves_base_when_no_overlay() {
let mut base = HashMap::new();
base.insert("key".into(), yaml_str("val"));
let overlay = HashMap::new();
let merged = merge_map(&base, &overlay);
assert_eq!(merged.len(), 1);
assert_eq!(merged.get("key").unwrap(), &yaml_str("val"));
}
#[test]
fn merge_patterns_combines() {
let mut base_patterns = HashMap::new();
let mut base_inner = HashMap::new();
base_inner.insert("model".into(), yaml_str("haiku"));
base_patterns.insert("lint.*".into(), base_inner);
let mut overlay_patterns = HashMap::new();
let mut overlay_inner = HashMap::new();
overlay_inner.insert("timeout".into(), yaml_str("10s"));
overlay_patterns.insert("test.*".into(), overlay_inner);
let merged = merge_patterns(&base_patterns, &overlay_patterns);
assert!(merged.contains_key("lint.*"));
assert!(merged.contains_key("test.*"));
}
#[test]
fn merge_workflow_config_full() {
let base = WorkflowConfig {
global: {
let mut m = HashMap::new();
m.insert("timeout".into(), yaml_str("300s"));
m
},
chat: {
let mut m = HashMap::new();
m.insert("model".into(), yaml_str("claude-3-haiku"));
m.insert("provider".into(), yaml_str("anthropic"));
m
},
..Default::default()
};
let overlay = WorkflowConfig {
chat: {
let mut m = HashMap::new();
m.insert("model".into(), yaml_str("claude-sonnet-4-20250514"));
m.insert("temperature".into(), yaml_str("0.1"));
m
},
..Default::default()
};
let merged = merge_workflow_config(&base, &overlay);
assert_eq!(merged.global.get("timeout").unwrap(), &yaml_str("300s"));
assert_eq!(
merged.chat.get("model").unwrap(),
&yaml_str("claude-sonnet-4-20250514")
);
assert_eq!(merged.chat.get("provider").unwrap(), &yaml_str("anthropic"));
assert_eq!(merged.chat.get("temperature").unwrap(), &yaml_str("0.1"));
}
#[test]
fn apply_defaults_always_provides_embedded_values() {
let config = WorkflowConfig::default();
let result = apply_defaults(&config);
assert_eq!(
result.chat.get("model").unwrap(),
&yaml_str("claude-sonnet-4-20250514")
);
assert_eq!(
result.chat.get("provider").unwrap(),
&yaml_str("anthropic")
);
assert_eq!(
result.agent.get("model").unwrap(),
&yaml_str("claude-sonnet-4-20250514")
);
}
#[test]
fn workflow_config_overrides_embedded_defaults() {
let config = WorkflowConfig {
chat: {
let mut m = HashMap::new();
m.insert("model".into(), yaml_str("claude-3-haiku-20240307"));
m.insert("temperature".into(), yaml_str("0.9"));
m
},
..Default::default()
};
let result = apply_defaults(&config);
assert_eq!(
result.chat.get("model").unwrap(),
&yaml_str("claude-3-haiku-20240307")
);
assert_eq!(
result.chat.get("temperature").unwrap(),
&yaml_str("0.9")
);
assert_eq!(
result.chat.get("provider").unwrap(),
&yaml_str("anthropic")
);
}
#[test]
fn parse_user_override_yaml() {
let yaml = r#"
chat:
model: claude-opus-4-20250514
temperature: 0.0
"#;
let config: WorkflowConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
config.chat.get("model").unwrap(),
&yaml_str("claude-opus-4-20250514")
);
assert!(config.agent.is_empty());
assert!(config.cmd.is_empty());
}
}