use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use agent_doc_frontmatter::frontmatter::{CodexNetworkAccess, FreeTextExecutionMode};
use agent_doc_model_tier::ModelConfig;
pub mod env;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ExecutionMode {
#[default]
Hybrid,
Parallel,
Sequential,
}
impl std::fmt::Display for ExecutionMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Hybrid => write!(f, "hybrid"),
Self::Parallel => write!(f, "parallel"),
Self::Sequential => write!(f, "sequential"),
}
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub default_agent: Option<String>,
#[serde(default)]
pub agents: BTreeMap<String, AgentConfig>,
#[serde(default)]
pub agent_args: Option<String>,
#[serde(default)]
pub claude_args: Option<String>,
#[serde(default)]
pub codex_args: Option<String>,
#[serde(default)]
pub opencode_args: Option<String>,
#[serde(default)]
pub codex_network_access: Option<CodexNetworkAccess>,
#[serde(default)]
pub managed_proof_max_attempts: Option<u32>,
#[serde(default)]
pub managed_proof_retry_backoff_secs: Option<u64>,
#[serde(default)]
pub managed_proof_probe_timeout_secs: Option<u64>,
#[serde(default, alias = "free_text_execution")]
pub agent_doc_free_text_execution: Option<FreeTextExecutionMode>,
#[serde(default)]
pub execution_mode: Option<ExecutionMode>,
#[serde(default)]
pub terminal: Option<TerminalConfig>,
#[serde(default)]
pub model: ModelConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerminalConfig {
pub command: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub result_path: Option<String>,
#[serde(default)]
pub session_path: Option<String>,
}
pub fn load() -> Result<Config> {
let path = config_path();
if path.exists() {
let content = std::fs::read_to_string(&path)?;
Ok(toml::from_str(&content)?)
} else {
Ok(Config::default())
}
}
fn config_path() -> PathBuf {
dirs_config_dir().join("agent-doc").join("config.toml")
}
fn dirs_config_dir() -> PathBuf {
std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".config")
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_missing_global_config() {
let _cfg = Config::default();
assert!(_cfg.agents.is_empty());
}
#[test]
fn test_execution_mode_display() {
assert_eq!(ExecutionMode::Hybrid.to_string(), "hybrid");
assert_eq!(ExecutionMode::Parallel.to_string(), "parallel");
assert_eq!(ExecutionMode::Sequential.to_string(), "sequential");
}
#[test]
fn test_config_agent_args_deserialization() {
let toml_str = r#"
agent_args = "--json -s workspace-write"
claude_args = "--dangerously-skip-permissions"
codex_args = "-s danger-full-access"
opencode_args = "--dangerously-skip-permissions"
codex_network_access = "enabled"
agent_doc_free_text_execution = "queue"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.agent_args.as_deref(), Some("--json -s workspace-write"));
assert_eq!(
cfg.claude_args.as_deref(),
Some("--dangerously-skip-permissions")
);
assert_eq!(cfg.codex_args.as_deref(), Some("-s danger-full-access"));
assert_eq!(
cfg.opencode_args.as_deref(),
Some("--dangerously-skip-permissions")
);
assert_eq!(cfg.codex_network_access, Some(CodexNetworkAccess::Enabled));
assert_eq!(
cfg.agent_doc_free_text_execution,
Some(FreeTextExecutionMode::Queue)
);
let alias: Config = toml::from_str("free_text_execution = \"goal\"").unwrap();
assert_eq!(
alias.agent_doc_free_text_execution,
Some(FreeTextExecutionMode::Goal)
);
}
#[test]
fn test_config_agent_args_precedence_resolution() {
let cfg = Config {
agent_args: Some("--json".to_string()),
claude_args: Some("--old-flag".to_string()),
..Default::default()
};
let resolved = cfg.agent_args.or(cfg.claude_args);
assert_eq!(resolved.as_deref(), Some("--json"));
}
#[test]
fn test_config_claude_args_fallback() {
let cfg = Config {
agent_args: None,
claude_args: Some("--old-flag".to_string()),
..Default::default()
};
let resolved = cfg.agent_args.or(cfg.claude_args);
assert_eq!(resolved.as_deref(), Some("--old-flag"));
}
#[test]
fn test_config_codex_args_fallback() {
let cfg = Config {
agent_args: None,
codex_args: Some("-s danger-full-access".to_string()),
..Default::default()
};
let resolved = cfg.agent_args.or(cfg.codex_args);
assert_eq!(resolved.as_deref(), Some("-s danger-full-access"));
}
#[test]
fn test_config_opencode_args_fallback() {
let cfg = Config {
agent_args: None,
opencode_args: Some("--dangerously-skip-permissions".to_string()),
..Default::default()
};
let resolved = cfg.agent_args.or(cfg.opencode_args);
assert_eq!(resolved.as_deref(), Some("--dangerously-skip-permissions"));
}
}