use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AgentsConfig {
#[serde(default)]
pub sources: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TierAliases {
pub haiku: Option<String>,
pub sonnet: Option<String>,
pub opus: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ModelsConfig {
#[serde(default)]
pub agents: HashMap<String, String>,
#[serde(default)]
pub tiers: TierAliases,
pub default: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SkillsConfig {
#[serde(default)]
pub sources: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PmConfig {
pub circuit_breaker: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct MpmConfig {
#[serde(default)]
pub agents: AgentsConfig,
#[serde(default)]
pub models: ModelsConfig,
#[serde(default)]
pub skills: SkillsConfig,
#[serde(default)]
pub pm: PmConfig,
}
impl MpmConfig {
pub fn load(root: &Path) -> Self {
let path = root.join("config.toml");
match std::fs::read_to_string(&path) {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!("no config.toml found at {}; using defaults", path.display());
Self::default()
}
Err(err) => {
tracing::warn!(
"could not read config.toml at {}: {err}; using defaults",
path.display()
);
Self::default()
}
Ok(raw) => match toml::from_str::<Self>(&raw) {
Ok(cfg) => {
tracing::debug!("loaded config from {}", path.display());
cfg
}
Err(err) => {
tracing::warn!(
"config.toml at {} is malformed: {err}; using defaults",
path.display()
);
Self::default()
}
},
}
}
pub fn load_default() -> Self {
match dirs::home_dir() {
Some(home) => Self::load(&home.join(".trusty-mpm")),
None => {
tracing::warn!("home directory unavailable; using default config");
Self::default()
}
}
}
pub fn expand_model_alias<'a>(&'a self, alias: &'a str) -> &'a str {
match alias {
"haiku" => self
.models
.tiers
.haiku
.as_deref()
.unwrap_or("claude-haiku-4-5"),
"sonnet" => self
.models
.tiers
.sonnet
.as_deref()
.unwrap_or("claude-sonnet-4-5"),
"opus" => self
.models
.tiers
.opus
.as_deref()
.unwrap_or("claude-opus-4-5"),
"auto" => "claude-sonnet-4-5",
other => other,
}
}
}
pub fn resolve_agent_model(
config: &MpmConfig,
agent_name: &str,
frontmatter_model: Option<&str>,
explicit: Option<&str>,
) -> String {
if let Some(m) = explicit {
return config.expand_model_alias(m).to_string();
}
if let Some(m) = config.models.agents.get(agent_name) {
return config.expand_model_alias(m).to_string();
}
if let Some(m) = frontmatter_model {
return config.expand_model_alias(m).to_string();
}
let fallback = config
.models
.default
.as_deref()
.unwrap_or("claude-sonnet-4-5");
config.expand_model_alias(fallback).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn load_from_str(dir: &Path, content: &str) -> MpmConfig {
std::fs::write(dir.join("config.toml"), content).unwrap();
MpmConfig::load(dir)
}
#[test]
fn config_absent_yields_defaults() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = MpmConfig::load(dir.path());
assert_eq!(cfg, MpmConfig::default());
assert!(cfg.agents.sources.is_empty());
assert!(cfg.models.agents.is_empty());
}
#[test]
fn config_valid_parsed() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[agents]
sources = ["bundled", "user"]
[models]
default = "sonnet"
[models.agents]
engineer = "haiku"
rust-engineer = "opus"
[models.tiers]
haiku = "claude-haiku-4-5"
sonnet = "claude-sonnet-4-5"
opus = "claude-opus-4-5"
[skills]
sources = ["bundled"]
[pm]
circuit_breaker = true
"#;
let cfg = load_from_str(dir.path(), toml);
assert_eq!(cfg.agents.sources, vec!["bundled", "user"]);
assert_eq!(
cfg.models.agents.get("engineer").map(|s| s.as_str()),
Some("haiku")
);
assert_eq!(
cfg.models.agents.get("rust-engineer").map(|s| s.as_str()),
Some("opus")
);
assert_eq!(cfg.models.tiers.haiku.as_deref(), Some("claude-haiku-4-5"));
assert_eq!(cfg.models.default.as_deref(), Some("sonnet"));
assert_eq!(cfg.skills.sources, vec!["bundled"]);
assert_eq!(cfg.pm.circuit_breaker, Some(true));
}
#[test]
fn config_malformed_falls_back() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = load_from_str(dir.path(), "this is not toml {{{{");
assert_eq!(cfg, MpmConfig::default());
}
#[test]
fn config_partial_sections_are_fine() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[models.agents]
engineer = "haiku"
"#;
let cfg = load_from_str(dir.path(), toml);
assert_eq!(
cfg.models.agents.get("engineer").map(|s| s.as_str()),
Some("haiku")
);
assert!(cfg.agents.sources.is_empty());
assert!(cfg.pm.circuit_breaker.is_none());
}
#[test]
fn tier_alias_expansion() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[models.tiers]
haiku = "claude-haiku-4-5"
sonnet = "claude-sonnet-4-7"
opus = "claude-opus-4-7"
"#;
let cfg = load_from_str(dir.path(), toml);
assert_eq!(cfg.expand_model_alias("haiku"), "claude-haiku-4-5");
assert_eq!(cfg.expand_model_alias("sonnet"), "claude-sonnet-4-7");
assert_eq!(cfg.expand_model_alias("opus"), "claude-opus-4-7");
assert_eq!(cfg.expand_model_alias("claude-opus-4-7"), "claude-opus-4-7");
assert_eq!(cfg.expand_model_alias("auto"), "claude-sonnet-4-5");
}
#[test]
fn tier_alias_defaults_when_not_configured() {
let cfg = MpmConfig::default();
assert_eq!(cfg.expand_model_alias("haiku"), "claude-haiku-4-5");
assert_eq!(cfg.expand_model_alias("sonnet"), "claude-sonnet-4-5");
assert_eq!(cfg.expand_model_alias("opus"), "claude-opus-4-5");
}
#[test]
fn model_resolution_precedence() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[models]
default = "sonnet"
[models.agents]
engineer = "haiku"
"#;
let cfg = load_from_str(dir.path(), toml);
let m = resolve_agent_model(&cfg, "engineer", Some("opus"), Some("claude-opus-4-5"));
assert_eq!(m, "claude-opus-4-5");
let m = resolve_agent_model(&cfg, "engineer", Some("opus"), None);
assert_eq!(m, "claude-haiku-4-5");
let m = resolve_agent_model(&cfg, "unknown-agent", Some("opus"), None);
assert_eq!(m, "claude-opus-4-5");
let m = resolve_agent_model(&cfg, "unknown-agent", None, None);
assert_eq!(m, "claude-sonnet-4-5");
let cfg_empty = MpmConfig::default();
let m = resolve_agent_model(&cfg_empty, "nobody", None, None);
assert_eq!(m, "claude-sonnet-4-5");
}
}