use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::core::sm::config::SessionManagerConfig;
#[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 ManifestConfig {
pub repo: Option<String>,
pub git_ref: Option<String>,
pub ttl_hours: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdleAutoStopConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "IdleAutoStopConfig::default_dry_run")]
pub dry_run: bool,
#[serde(default = "IdleAutoStopConfig::default_poll_interval_secs")]
pub poll_interval_secs: u64,
#[serde(default = "IdleAutoStopConfig::default_idle_consecutive_threshold")]
pub idle_consecutive_threshold: u32,
#[serde(default = "IdleAutoStopConfig::default_done_consecutive_threshold")]
pub done_consecutive_threshold: u32,
}
impl IdleAutoStopConfig {
fn default_dry_run() -> bool {
true
}
fn default_poll_interval_secs() -> u64 {
300
}
fn default_idle_consecutive_threshold() -> u32 {
3
}
fn default_done_consecutive_threshold() -> u32 {
1
}
}
impl Default for IdleAutoStopConfig {
fn default() -> Self {
Self {
enabled: false,
dry_run: Self::default_dry_run(),
poll_interval_secs: Self::default_poll_interval_secs(),
idle_consecutive_threshold: Self::default_idle_consecutive_threshold(),
done_consecutive_threshold: Self::default_done_consecutive_threshold(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatchupConfig {
pub auto: bool,
pub include_git: bool,
pub include_palace: bool,
pub git_limit: usize,
pub drawer_limit: usize,
}
impl Default for CatchupConfig {
fn default() -> Self {
Self {
auto: true,
include_git: true,
include_palace: true,
git_limit: 50,
drawer_limit: 15,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PmConfig {
pub circuit_breaker: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct HooksConfig {
pub prompt_context: bool,
}
impl Default for HooksConfig {
fn default() -> Self {
Self {
prompt_context: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct MpmConfig {
pub agents: AgentsConfig,
pub models: ModelsConfig,
pub skills: SkillsConfig,
pub manifest: ManifestConfig,
pub pm: PmConfig,
pub hooks: HooksConfig,
pub session_manager: SessionManagerConfig,
pub control_plane: crate::control::config::ControlPlaneConfig,
pub style: crate::core::output_style::StyleConfig,
pub catchup: CatchupConfig,
pub idle_auto_stop: IdleAutoStopConfig,
pub idle_nudge: crate::core::idle_nudge::IdleNudgeConfig,
pub agent_cost: crate::core::agent_cost::AgentCostConfig,
}
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());
if let Some(doc) = crate::core::config_keys::toml_document(&raw) {
crate::core::config_keys::report_unknown_keys(
&path.display().to_string(),
&doc,
&cfg,
);
}
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 load_effective(root: &Path, project_dir: Option<&Path>) -> Self {
Self::load(root).with_outer_default_model_layers(project_dir)
}
pub fn load_effective_default(project_dir: Option<&Path>) -> Self {
Self::load_default().with_outer_default_model_layers(project_dir)
}
#[must_use]
fn with_outer_default_model_layers(self, project_dir: Option<&Path>) -> Self {
let project_default = project_dir
.and_then(crate::core::project_config::load_or_report)
.and_then(|c| c.default_model);
let host_default =
crate::core::trusty_tools_config::TrustyToolsConfig::load().default_model;
self.with_default_model_layers(project_default.as_deref(), host_default.as_deref())
}
#[must_use]
pub fn with_default_model_layers(mut self, project: Option<&str>, host: Option<&str>) -> Self {
if let Some(m) = project.or(host) {
self.models.default = Some(m.to_string());
}
self
}
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)]
#[path = "config_tests.rs"]
mod tests;