use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::theme::{apply_overrides, Theme, ThemeOverrides};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ApiKeysConfig {
#[serde(default)]
pub openai: Option<String>,
#[serde(default)]
pub anthropic: Option<String>,
#[serde(default)]
pub moonshot: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgenticReviewConfig {
#[serde(default)]
pub provider: Option<String>,
pub parent_model: String,
pub child_model: String,
#[serde(default)]
pub parent_provider: Option<String>,
#[serde(default)]
pub child_provider: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default = "default_max_agent_turns")]
pub max_agent_turns: usize,
}
fn default_max_agent_turns() -> usize {
50
}
impl AgenticReviewConfig {
pub fn resolved_parent_provider(&self) -> &str {
self.parent_provider
.as_deref()
.or(self.provider.as_deref())
.unwrap_or("anthropic")
}
pub fn resolved_child_provider(&self) -> &str {
self.child_provider
.as_deref()
.or(self.provider.as_deref())
.unwrap_or("anthropic")
}
}
impl Default for AgenticReviewConfig {
fn default() -> Self {
Self {
provider: None,
parent_model: "claude-sonnet-4-6".to_string(),
child_model: "claude-sonnet-4-5".to_string(),
parent_provider: Some("anthropic".to_string()),
child_provider: Some("anthropic".to_string()),
base_url: None,
max_agent_turns: 50,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct AgentProviderConfig {
pub name: String,
pub command: String,
#[serde(default)]
pub models: Vec<String>,
#[serde(default)]
pub default_model: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MouseConfig {
#[serde(default = "default_mouse_enabled")]
pub enabled: bool,
}
impl Default for MouseConfig {
fn default() -> Self {
Self { enabled: true }
}
}
fn default_mouse_enabled() -> bool {
true
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChecklistItemConfig {
pub label: String,
pub key: String, }
#[derive(Debug, Clone, Deserialize)]
pub struct ChecklistConfig {
pub items: Vec<ChecklistItemConfig>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct MdiffConfig {
pub agents: Vec<AgentProviderConfig>,
pub agents_by_name: HashMap<String, usize>,
pub theme: Theme,
pub unified: Option<bool>,
pub ignore_whitespace: Option<bool>,
pub context_lines: Option<usize>,
pub agent_models: HashMap<String, String>,
pub mouse: MouseConfig,
pub checklist: Option<ChecklistConfig>,
pub tree_mode: Option<bool>,
pub api_keys: Option<ApiKeysConfig>,
pub agentic_review: AgenticReviewConfig,
}
impl Default for MdiffConfig {
fn default() -> Self {
let agents = detect_agents();
let agents_by_name = agents
.iter()
.enumerate()
.map(|(i, a)| (a.name.clone(), i))
.collect();
Self {
agents,
agents_by_name,
theme: Theme::from_name("one-dark"),
unified: None,
ignore_whitespace: None,
context_lines: None,
agent_models: HashMap::new(),
mouse: MouseConfig::default(),
checklist: None,
tree_mode: None,
api_keys: None,
agentic_review: AgenticReviewConfig::default(),
}
}
}
fn has_command(name: &str) -> bool {
std::process::Command::new("which")
.arg(name)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn known_agents() -> Vec<AgentProviderConfig> {
vec![
AgentProviderConfig {
name: "claude".to_string(),
command: "claude --permission-mode acceptEdits --model {model} '{rendered_prompt}'"
.to_string(),
models: vec![
"claude-sonnet-4-6".to_string(),
"claude-opus-4-6".to_string(),
"claude-haiku-4-5".to_string(),
],
default_model: "claude-sonnet-4-6".to_string(),
description: "Anthropic Claude Code".to_string(),
},
AgentProviderConfig {
name: "codex".to_string(),
command: "codex --model {model} --sandbox workspace-write --ask-for-approval untrusted '{rendered_prompt}'"
.to_string(),
models: vec![
"gpt-5.4".to_string(),
"gpt-5.3-codex".to_string(),
"gpt-5.2-codex".to_string(),
],
default_model: "gpt-5.4".to_string(),
description: "OpenAI Codex CLI".to_string(),
},
AgentProviderConfig {
name: "opencode".to_string(),
command: "opencode -m {model} --prompt '{rendered_prompt}'".to_string(),
models: vec![
"openai/gpt-5.4".to_string(),
"openai/gpt-5.3-codex".to_string(),
"anthropic/claude-sonnet-4-6".to_string(),
"openai/gpt-5.2-codex".to_string(),
"openai/o3".to_string(),
],
default_model: "anthropic/claude-sonnet-4-6".to_string(),
description: "OpenCode CLI".to_string(),
},
AgentProviderConfig {
name: "gemini".to_string(),
command: "gemini --approval-mode auto_edit '{rendered_prompt}'".to_string(),
models: vec![
"gemini-3-flash-preview".to_string(),
"gemini-3-pro-preview".to_string(),
"gemini-2.5-pro".to_string(),
"gemini-2.5-flash".to_string(),
],
default_model: "gemini-3-flash-preview".to_string(),
description: "Google Gemini CLI".to_string(),
},
]
}
fn detect_agents() -> Vec<AgentProviderConfig> {
known_agents()
.into_iter()
.filter(|a| has_command(&a.name))
.collect()
}
#[derive(Debug, Deserialize)]
struct ConfigFile {
#[serde(default)]
agents: Vec<AgentProviderConfig>,
#[serde(default)]
theme: Option<String>,
#[serde(default)]
colors: Option<ThemeOverrides>,
#[serde(default)]
unified: Option<bool>,
#[serde(default)]
ignore_whitespace: Option<bool>,
#[serde(default)]
context_lines: Option<usize>,
#[serde(default)]
agent_models: HashMap<String, String>,
#[serde(default)]
mouse: MouseConfig,
#[serde(default)]
checklist: Option<ChecklistConfig>,
#[serde(default)]
tree_mode: Option<bool>,
#[serde(default)]
api_keys: Option<ApiKeysConfig>,
#[serde(default)]
agentic_review: Option<AgenticReviewConfig>,
}
fn config_path() -> PathBuf {
let mut path = dirs_home().unwrap_or_else(|| PathBuf::from("."));
path.push(".config");
path.push("mdiff");
path.push("config.toml");
path
}
fn dirs_home() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
fn build_agents_index(agents: &[AgentProviderConfig]) -> HashMap<String, usize> {
agents
.iter()
.enumerate()
.map(|(i, a)| (a.name.clone(), i))
.collect()
}
pub fn load_config() -> MdiffConfig {
let path = config_path();
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return MdiffConfig::default(),
};
let file: ConfigFile = match toml::from_str(&contents) {
Ok(f) => f,
Err(_) => return MdiffConfig::default(),
};
let agents = if file.agents.is_empty() {
detect_agents()
} else {
file.agents
};
let agents_by_name = build_agents_index(&agents);
let theme_name = file.theme.as_deref().unwrap_or("one-dark");
let mut theme = Theme::from_name(theme_name);
if let Some(ref overrides) = file.colors {
apply_overrides(&mut theme, overrides);
}
MdiffConfig {
agents,
agents_by_name,
theme,
unified: file.unified,
ignore_whitespace: file.ignore_whitespace,
context_lines: file.context_lines,
agent_models: file.agent_models,
mouse: file.mouse,
checklist: file.checklist,
tree_mode: file.tree_mode,
api_keys: file.api_keys,
agentic_review: file.agentic_review.unwrap_or_default(),
}
}
pub struct PersistentSettings {
pub theme: String,
pub unified: bool,
pub ignore_whitespace: bool,
pub context_lines: usize,
pub tree_mode: bool,
pub agentic_parent_provider: String,
pub agentic_parent_model: String,
pub agentic_child_provider: String,
pub agentic_child_model: String,
pub max_agent_turns: usize,
}
pub fn save_settings(settings: &PersistentSettings) {
let path = config_path();
let mut table = if let Ok(contents) = std::fs::read_to_string(&path) {
contents
.parse::<toml::Table>()
.unwrap_or_else(|_| toml::Table::new())
} else {
toml::Table::new()
};
table.insert(
"theme".to_string(),
toml::Value::String(settings.theme.clone()),
);
table.insert(
"unified".to_string(),
toml::Value::Boolean(settings.unified),
);
table.insert(
"ignore_whitespace".to_string(),
toml::Value::Boolean(settings.ignore_whitespace),
);
table.insert(
"context_lines".to_string(),
toml::Value::Integer(settings.context_lines as i64),
);
table.insert(
"tree_mode".to_string(),
toml::Value::Boolean(settings.tree_mode),
);
let agentic = table
.entry("agentic_review")
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
if let toml::Value::Table(ref mut t) = agentic {
t.insert(
"parent_provider".to_string(),
toml::Value::String(settings.agentic_parent_provider.clone()),
);
t.insert(
"parent_model".to_string(),
toml::Value::String(settings.agentic_parent_model.clone()),
);
t.insert(
"child_provider".to_string(),
toml::Value::String(settings.agentic_child_provider.clone()),
);
t.insert(
"child_model".to_string(),
toml::Value::String(settings.agentic_child_model.clone()),
);
t.insert(
"max_agent_turns".to_string(),
toml::Value::Integer(settings.max_agent_turns as i64),
);
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let toml_string = toml::to_string_pretty(&table).unwrap_or_default();
let _ = std::fs::write(&path, toml_string);
}
pub fn load_checklist_config(repo_path: &Path) -> Option<ChecklistConfig> {
let project_config_path = repo_path.join(".mdiff.toml");
if let Ok(contents) = std::fs::read_to_string(&project_config_path) {
if let Ok(file) = toml::from_str::<ConfigFile>(&contents) {
if let Some(checklist) = file.checklist {
return Some(checklist);
}
}
}
let global_config = load_config();
global_config.checklist
}
pub fn checklist_config_to_items(config: &ChecklistConfig) -> Vec<(String, char)> {
config
.items
.iter()
.take(20) .filter_map(|item| {
let chars: Vec<char> = item.key.chars().collect();
if chars.len() == 1 {
Some((item.label.clone(), chars[0]))
} else {
None }
})
.collect()
}
pub const AGENTIC_PROVIDERS: &[&str] = &["anthropic", "openai", "moonshot"];
pub fn agentic_models_for_provider(provider: &str) -> &'static [&'static str] {
match provider {
"anthropic" => &["claude-opus-4-6", "claude-sonnet-4-6", "claude-sonnet-4-5"],
"openai" => &[
"gpt-5.4",
"gpt-5.3-codex",
"gpt-5.2",
"gpt-5.1",
"gpt-5.4-mini",
"gpt-4.1-mini",
],
"moonshot" => &["kimi-k2.5", "kimi-k2-thinking-turbo"],
_ => &[],
}
}
pub fn next_agentic_model(provider: &str, current: &str) -> String {
let models = agentic_models_for_provider(provider);
if models.is_empty() {
return current.to_string();
}
let idx = models.iter().position(|m| *m == current).unwrap_or(0);
models[(idx + 1) % models.len()].to_string()
}
pub fn prev_agentic_model(provider: &str, current: &str) -> String {
let models = agentic_models_for_provider(provider);
if models.is_empty() {
return current.to_string();
}
let idx = models.iter().position(|m| *m == current).unwrap_or(0);
models[(idx + models.len() - 1) % models.len()].to_string()
}
pub fn next_agentic_provider(current: &str) -> &'static str {
let idx = AGENTIC_PROVIDERS
.iter()
.position(|p| *p == current)
.unwrap_or(0);
AGENTIC_PROVIDERS[(idx + 1) % AGENTIC_PROVIDERS.len()]
}
pub fn prev_agentic_provider(current: &str) -> &'static str {
let idx = AGENTIC_PROVIDERS
.iter()
.position(|p| *p == current)
.unwrap_or(0);
AGENTIC_PROVIDERS[(idx + AGENTIC_PROVIDERS.len() - 1) % AGENTIC_PROVIDERS.len()]
}
pub fn save_agent_model(agent_name: &str, model: &str) {
let path = config_path();
let mut table = if let Ok(contents) = std::fs::read_to_string(&path) {
contents
.parse::<toml::Table>()
.unwrap_or_else(|_| toml::Table::new())
} else {
toml::Table::new()
};
let agent_models = table
.entry("agent_models")
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
if let toml::Value::Table(ref mut t) = agent_models {
t.insert(
agent_name.to_string(),
toml::Value::String(model.to_string()),
);
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let toml_string = toml::to_string_pretty(&table).unwrap_or_default();
let _ = std::fs::write(&path, toml_string);
}