use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use crate::Error;
use crate::sensitive::{SensitiveAllowlistEntry, SensitiveEnforcement};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum CliBackend {
#[default]
Opencode,
Claude,
Codex,
Gemini,
}
impl fmt::Display for CliBackend {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CliBackend::Opencode => write!(f, "opencode"),
CliBackend::Claude => write!(f, "claude"),
CliBackend::Codex => write!(f, "codex"),
CliBackend::Gemini => write!(f, "gemini"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum CommitMode {
#[default]
Adaptive,
AdaptiveOneliner,
Conventional,
ConventionalOneliner,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum DiffSource {
Staged,
All,
#[default]
Auto,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum BranchMode {
#[default]
Conventional,
Adaptive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SensitiveProfile {
Human,
StrictAgent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct LanguageConfig {
pub label: String,
pub instruction: String,
#[serde(default)]
pub base_module: Option<String>,
#[serde(default)]
pub adaptive_format: Option<String>,
#[serde(default)]
pub conventional_format: Option<String>,
#[serde(default)]
pub multiline_length: Option<String>,
#[serde(default)]
pub oneliner_length: Option<String>,
#[serde(default)]
pub sensitive_content_note: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PromptModules {
pub base_module: String,
pub adaptive_format: String,
pub conventional_format: String,
pub multiline_length: String,
pub oneliner_length: String,
pub sensitive_content_note: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct CustomConfig {
#[serde(default)]
pub prompt: String,
#[serde(default)]
pub type_rules: String,
#[serde(default)]
pub commit_message_rules: String,
#[serde(default)]
pub emojis: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct RefineConfig {
#[serde(default = "default_refine_feedback")]
pub default_feedback: String,
}
impl Default for RefineConfig {
fn default() -> Self {
Self {
default_feedback: default_refine_feedback(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct SensitiveConfig {
#[serde(default)]
pub enforcement: SensitiveEnforcement,
#[serde(default)]
pub allowlist: Vec<SensitiveAllowlistEntry>,
}
impl Default for SensitiveConfig {
fn default() -> Self {
Self {
enforcement: SensitiveEnforcement::Warn,
allowlist: vec![],
}
}
}
fn default_refine_feedback() -> String {
"make it shorter".to_owned()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Config {
#[serde(default = "default_backend")]
pub backend: CliBackend,
#[serde(default = "default_backend_order")]
pub backend_order: Vec<CliBackend>,
#[serde(default = "default_commit_mode")]
pub commit_mode: CommitMode,
#[serde(default = "default_commit_mode")]
pub sparkle_mode: CommitMode,
#[serde(default = "default_provider")]
pub provider: String,
#[serde(default = "default_model")]
pub model: String,
#[serde(default)]
pub cli_path: String,
#[serde(default)]
pub claude_path: String,
#[serde(default)]
pub codex_path: String,
#[serde(default = "default_claude_model")]
pub claude_model: String,
#[serde(default = "default_codex_model")]
pub codex_model: String,
#[serde(default)]
pub codex_provider: String,
#[serde(default)]
pub gemini_path: String,
#[serde(default = "default_gemini_model")]
pub gemini_model: String,
#[serde(default = "default_opencode_pr_provider")]
pub opencode_pr_provider: String,
#[serde(default = "default_opencode_pr_model")]
pub opencode_pr_model: String,
#[serde(default = "default_opencode_cheap_provider")]
pub opencode_cheap_provider: String,
#[serde(default = "default_opencode_cheap_model")]
pub opencode_cheap_model: String,
#[serde(default = "default_claude_pr_model")]
pub claude_pr_model: String,
#[serde(default = "default_claude_cheap_model")]
pub claude_cheap_model: String,
#[serde(default = "default_codex_pr_model")]
pub codex_pr_model: String,
#[serde(default = "default_codex_cheap_model")]
pub codex_cheap_model: String,
#[serde(default)]
pub codex_pr_provider: String,
#[serde(default)]
pub codex_cheap_provider: String,
#[serde(default = "default_gemini_pr_model")]
pub gemini_pr_model: String,
#[serde(default = "default_gemini_cheap_model")]
pub gemini_cheap_model: String,
#[serde(default)]
pub pr_base_branch: String,
#[serde(default)]
pub branch_mode: BranchMode,
#[serde(default = "default_diff_source")]
pub diff_source: DiffSource,
#[serde(default = "default_max_diff_length")]
pub max_diff_length: usize,
#[serde(default)]
pub use_emojis: bool,
#[serde(default = "default_true")]
pub use_lower_case: bool,
#[serde(default = "default_commit_template")]
pub commit_template: String,
#[serde(default = "default_languages")]
pub languages: Vec<LanguageConfig>,
#[serde(default = "default_active_language")]
pub active_language: String,
#[serde(default)]
pub show_language_selector: bool,
#[serde(default = "default_true")]
pub auto_update: bool,
#[serde(default)]
pub refine: RefineConfig,
#[serde(default)]
pub custom: CustomConfig,
#[serde(default)]
pub sensitive: SensitiveConfig,
}
fn default_backend() -> CliBackend {
CliBackend::Opencode
}
fn default_backend_order() -> Vec<CliBackend> {
vec![
CliBackend::Codex,
CliBackend::Opencode,
CliBackend::Claude,
CliBackend::Gemini,
]
}
fn default_commit_mode() -> CommitMode {
CommitMode::Adaptive
}
fn default_provider() -> String {
"openai".to_owned()
}
fn default_model() -> String {
"gpt-5.4-mini".to_owned()
}
fn default_claude_model() -> String {
"claude-sonnet-4-6".to_owned()
}
fn default_codex_model() -> String {
"gpt-5.4-mini".to_owned()
}
fn default_gemini_model() -> String {
"gemini-2.5-flash".to_owned()
}
fn default_diff_source() -> DiffSource {
DiffSource::Auto
}
fn default_max_diff_length() -> usize {
10000
}
fn default_true() -> bool {
true
}
fn default_commit_template() -> String {
"{{type}}: {{message}}".to_owned()
}
fn default_languages() -> Vec<LanguageConfig> {
crate::languages::default_languages()
}
fn default_active_language() -> String {
"English".to_owned()
}
fn default_opencode_pr_provider() -> String {
"openai".to_owned()
}
fn default_opencode_pr_model() -> String {
"gpt-5.4".to_owned()
}
fn default_opencode_cheap_provider() -> String {
"openai".to_owned()
}
fn default_opencode_cheap_model() -> String {
"gpt-5.4-mini".to_owned()
}
fn default_claude_pr_model() -> String {
"claude-opus-4-6".to_owned()
}
fn default_claude_cheap_model() -> String {
"claude-haiku-4-5".to_owned()
}
fn default_codex_pr_model() -> String {
"gpt-5.4".to_owned()
}
fn default_codex_cheap_model() -> String {
"gpt-5.4-mini".to_owned()
}
fn default_gemini_pr_model() -> String {
"gemini-3-flash-preview".to_owned()
}
fn default_gemini_cheap_model() -> String {
"gemini-3.1-flash-lite-preview".to_owned()
}
impl Default for Config {
fn default() -> Self {
Self {
backend: default_backend(),
backend_order: default_backend_order(),
commit_mode: default_commit_mode(),
sparkle_mode: default_commit_mode(),
provider: default_provider(),
model: default_model(),
cli_path: String::new(),
claude_path: String::new(),
codex_path: String::new(),
claude_model: default_claude_model(),
codex_model: default_codex_model(),
codex_provider: String::new(),
gemini_path: String::new(),
gemini_model: default_gemini_model(),
opencode_pr_provider: default_opencode_pr_provider(),
opencode_pr_model: default_opencode_pr_model(),
opencode_cheap_provider: default_opencode_cheap_provider(),
opencode_cheap_model: default_opencode_cheap_model(),
claude_pr_model: default_claude_pr_model(),
claude_cheap_model: default_claude_cheap_model(),
codex_pr_model: default_codex_pr_model(),
codex_cheap_model: default_codex_cheap_model(),
codex_pr_provider: String::new(),
codex_cheap_provider: String::new(),
gemini_pr_model: default_gemini_pr_model(),
gemini_cheap_model: default_gemini_cheap_model(),
pr_base_branch: String::new(),
branch_mode: BranchMode::default(),
diff_source: default_diff_source(),
max_diff_length: default_max_diff_length(),
use_emojis: false,
use_lower_case: true,
commit_template: default_commit_template(),
languages: default_languages(),
active_language: default_active_language(),
show_language_selector: false,
auto_update: true,
refine: RefineConfig::default(),
custom: CustomConfig::default(),
sensitive: SensitiveConfig::default(),
}
}
}
impl Config {
pub fn default_config_dir() -> Option<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
return Some(PathBuf::from(xdg).join("opencodecommit"));
}
if let Ok(home) = std::env::var("HOME") {
return Some(PathBuf::from(home).join(".config/opencodecommit"));
}
#[cfg(target_os = "windows")]
if let Ok(appdata) = std::env::var("APPDATA") {
return Some(PathBuf::from(appdata).join("opencodecommit"));
}
None
}
pub fn active_language_instruction(&self) -> String {
self.languages
.iter()
.find(|l| l.label == self.active_language)
.map(|l| l.instruction.clone())
.unwrap_or_else(|| "Write the commit message in English.".to_owned())
}
pub fn active_prompt_modules(&self) -> PromptModules {
let active = self
.languages
.iter()
.find(|l| l.label == self.active_language);
let fallback = self.languages.first();
let resolve = |getter: fn(&LanguageConfig) -> Option<&str>| -> String {
active
.and_then(getter)
.or_else(|| fallback.and_then(getter))
.unwrap_or("")
.to_owned()
};
PromptModules {
base_module: resolve(|l| l.base_module.as_deref()),
adaptive_format: resolve(|l| l.adaptive_format.as_deref()),
conventional_format: resolve(|l| l.conventional_format.as_deref()),
multiline_length: resolve(|l| l.multiline_length.as_deref()),
oneliner_length: resolve(|l| l.oneliner_length.as_deref()),
sensitive_content_note: resolve(|l| l.sensitive_content_note.as_deref()),
}
}
pub fn backend_cli_path(&self) -> &str {
self.cli_path_for(self.backend)
}
pub fn cli_path_for(&self, backend: CliBackend) -> &str {
match backend {
CliBackend::Opencode => &self.cli_path,
CliBackend::Claude => &self.claude_path,
CliBackend::Codex => &self.codex_path,
CliBackend::Gemini => &self.gemini_path,
}
}
pub fn effective_backend_order(&self) -> &[CliBackend] {
&self.backend_order
}
pub fn backend_model(&self) -> &str {
match self.backend {
CliBackend::Opencode => &self.model,
CliBackend::Claude => &self.claude_model,
CliBackend::Codex => &self.codex_model,
CliBackend::Gemini => &self.gemini_model,
}
}
pub fn backend_pr_model(&self) -> &str {
match self.backend {
CliBackend::Opencode => &self.opencode_pr_model,
CliBackend::Claude => &self.claude_pr_model,
CliBackend::Codex => &self.codex_pr_model,
CliBackend::Gemini => &self.gemini_pr_model,
}
}
pub fn backend_cheap_model(&self) -> &str {
match self.backend {
CliBackend::Opencode => &self.opencode_cheap_model,
CliBackend::Claude => &self.claude_cheap_model,
CliBackend::Codex => &self.codex_cheap_model,
CliBackend::Gemini => &self.gemini_cheap_model,
}
}
pub fn backend_pr_provider(&self) -> &str {
match self.backend {
CliBackend::Opencode => &self.opencode_pr_provider,
CliBackend::Codex => &self.codex_pr_provider,
_ => "",
}
}
pub fn backend_cheap_provider(&self) -> &str {
match self.backend {
CliBackend::Opencode => &self.opencode_cheap_provider,
CliBackend::Codex => &self.codex_cheap_provider,
_ => "",
}
}
pub fn default_config_path() -> Option<PathBuf> {
if let Some(dir) = Self::default_config_dir() {
let p = dir.join("config.toml");
if p.exists() {
return Some(p);
}
}
None
}
fn materialize_default_config_path() -> crate::Result<Option<PathBuf>> {
if let Some(path) = Self::default_config_path() {
return Ok(Some(path));
}
let Some(dir) = Self::default_config_dir() else {
return Ok(None);
};
let path = dir.join("config.toml");
let config = Self::default();
config.save_to_path(&path)?;
Ok(Some(path))
}
pub fn load(path: &Path) -> crate::Result<Self> {
let content = std::fs::read_to_string(path).map_err(|e| {
Error::Config(format!(
"failed to read config file {}: {e}",
path.display()
))
})?;
let config: Self = toml::from_str(&content)
.map_err(|e| Error::Config(format!("failed to parse config file: {e}")))?;
config.validate()?;
Ok(config)
}
pub fn load_or_default(explicit_path: Option<&Path>) -> crate::Result<Self> {
if let Some(path) = explicit_path {
return Self::load(path);
}
if let Some(path) = Self::default_config_path() {
return Self::load(&path);
}
if Self::materialize_default_config_path()?.is_some() {
return Ok(Self::default());
}
Ok(Self::default())
}
pub fn save_to_path(&self, path: &Path) -> crate::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
self.validate()?;
let content = toml::to_string_pretty(self)
.map_err(|err| Error::Config(format!("failed to serialize config file: {err}")))?;
std::fs::write(path, content)?;
Ok(())
}
pub fn save_default(&self) -> crate::Result<PathBuf> {
let dir = Self::default_config_dir()
.ok_or_else(|| Error::Config("failed to resolve config directory".to_owned()))?;
let path = dir.join("config.toml");
self.save_to_path(&path)?;
Ok(path)
}
pub fn validate(&self) -> crate::Result<()> {
for (index, entry) in self.sensitive.allowlist.iter().enumerate() {
if entry.path_regex.is_none() && entry.rule.is_none() && entry.value_regex.is_none() {
return Err(Error::Config(format!(
"sensitive.allowlist[{index}] must define path-regex, rule, or value-regex"
)));
}
if let Some(pattern) = entry.path_regex.as_deref() {
regex::Regex::new(pattern).map_err(|err| {
Error::Config(format!(
"invalid sensitive.allowlist[{index}].path-regex: {err}"
))
})?;
}
if let Some(pattern) = entry.value_regex.as_deref() {
regex::Regex::new(pattern).map_err(|err| {
Error::Config(format!(
"invalid sensitive.allowlist[{index}].value-regex: {err}"
))
})?;
}
}
Ok(())
}
pub fn apply_sensitive_profile(&mut self, profile: SensitiveProfile) {
self.sensitive.enforcement = match profile {
SensitiveProfile::Human => SensitiveEnforcement::Warn,
SensitiveProfile::StrictAgent => SensitiveEnforcement::StrictAll,
};
}
}
pub const DEFAULT_EMOJIS: &[(&str, &str)] = &[
("feat", "\u{2728}"), ("fix", "\u{1f41b}"), ("docs", "\u{1f4dd}"), ("style", "\u{1f48e}"), ("refactor", "\u{267b}\u{fe0f}"), ("test", "\u{1f9ea}"), ("chore", "\u{1f4e6}"), ("perf", "\u{26a1}"), ("security", "\u{1f512}"), ("revert", "\u{23ea}"), ];
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{LazyLock, Mutex};
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
use std::io::Write;
#[test]
fn default_values_match_typescript() {
let cfg = Config::default();
assert_eq!(cfg.backend, CliBackend::Opencode);
assert_eq!(
cfg.backend_order,
vec![
CliBackend::Codex,
CliBackend::Opencode,
CliBackend::Claude,
CliBackend::Gemini
]
);
assert_eq!(cfg.commit_mode, CommitMode::Adaptive);
assert_eq!(cfg.sparkle_mode, CommitMode::Adaptive);
assert_eq!(cfg.provider, "openai");
assert_eq!(cfg.model, "gpt-5.4-mini");
assert_eq!(cfg.cli_path, "");
assert_eq!(cfg.claude_path, "");
assert_eq!(cfg.codex_path, "");
assert_eq!(cfg.claude_model, "claude-sonnet-4-6");
assert_eq!(cfg.codex_model, "gpt-5.4-mini");
assert_eq!(cfg.codex_provider, "");
assert_eq!(cfg.gemini_path, "");
assert_eq!(cfg.gemini_model, "gemini-2.5-flash");
assert_eq!(cfg.diff_source, DiffSource::Auto);
assert_eq!(cfg.max_diff_length, 10000);
assert!(!cfg.use_emojis);
assert!(cfg.use_lower_case);
assert_eq!(cfg.commit_template, "{{type}}: {{message}}");
assert_eq!(cfg.languages.len(), 12);
assert_eq!(cfg.languages[0].label, "English");
assert_eq!(cfg.languages[1].label, "Finnish");
assert_eq!(cfg.languages[2].label, "Japanese");
assert_eq!(cfg.languages[3].label, "Chinese");
assert_eq!(cfg.languages[4].label, "Spanish");
assert_eq!(cfg.languages[5].label, "Portuguese");
assert_eq!(cfg.languages[6].label, "French");
assert_eq!(cfg.languages[7].label, "Korean");
assert_eq!(cfg.languages[8].label, "Russian");
assert_eq!(cfg.languages[9].label, "Vietnamese");
assert_eq!(cfg.languages[10].label, "German");
assert_eq!(cfg.languages[11].label, "Custom (example)");
assert_eq!(cfg.active_language, "English");
assert_eq!(cfg.refine.default_feedback, "make it shorter");
assert!(cfg.custom.prompt.is_empty());
assert!(cfg.custom.emojis.is_empty());
}
#[test]
fn active_language_instruction_lookup() {
let mut cfg = Config::default();
assert_eq!(
cfg.active_language_instruction(),
"Write the commit message in English."
);
cfg.active_language = "Finnish".to_owned();
assert!(cfg.active_language_instruction().contains("suomeksi"));
cfg.active_language = "Japanese".to_owned();
assert!(cfg.active_language_instruction().contains("日本語"));
cfg.active_language = "Chinese".to_owned();
assert!(cfg.active_language_instruction().contains("中文"));
cfg.active_language = "Spanish".to_owned();
assert!(cfg.active_language_instruction().contains("español"));
cfg.active_language = "Korean".to_owned();
assert!(cfg.active_language_instruction().contains("한국어"));
cfg.active_language = "Nonexistent".to_owned();
assert_eq!(
cfg.active_language_instruction(),
"Write the commit message in English."
);
}
#[test]
fn active_prompt_modules_english() {
let cfg = Config::default();
let mods = cfg.active_prompt_modules();
assert!(
mods.base_module
.contains("expert at writing git commit messages")
);
assert!(mods.adaptive_format.contains("{recentCommits}"));
assert!(
mods.conventional_format
.contains("conventional commit format")
);
assert!(mods.multiline_length.contains("72 characters"));
assert!(mods.oneliner_length.contains("exactly one line"));
assert!(mods.sensitive_content_note.contains("sensitive content"));
}
#[test]
fn active_prompt_modules_finnish() {
let cfg = Config {
active_language: "Finnish".to_owned(),
..Config::default()
};
let mods = cfg.active_prompt_modules();
assert!(mods.base_module.contains("Olet asiantuntija"));
assert!(mods.adaptive_format.contains("Noudata alla"));
assert!(
mods.conventional_format
.contains("conventional commit -muotoa")
);
}
#[test]
fn active_prompt_modules_japanese() {
let cfg = Config {
active_language: "Japanese".to_owned(),
..Config::default()
};
let mods = cfg.active_prompt_modules();
assert!(mods.base_module.contains("コミットメッセージ"));
assert!(mods.adaptive_format.contains("最近のコミット"));
assert!(mods.conventional_format.contains("conventional commit 形式"));
}
#[test]
fn active_prompt_modules_chinese() {
let cfg = Config {
active_language: "Chinese".to_owned(),
..Config::default()
};
let mods = cfg.active_prompt_modules();
assert!(mods.base_module.contains("提交信息"));
assert!(mods.adaptive_format.contains("最近的提交"));
assert!(mods.conventional_format.contains("conventional commit 格式"));
}
#[test]
fn active_prompt_modules_additional_languages() {
for (label, needle) in [
("Spanish", "mensaje de commit"),
("Portuguese", "mensagem de commit"),
("French", "message de commit"),
("Korean", "커밋 메시지"),
("Russian", "сообщением коммита"),
("Vietnamese", "commit message"),
("German", "Commit-Nachricht"),
] {
let cfg = Config {
active_language: label.to_owned(),
..Config::default()
};
let mods = cfg.active_prompt_modules();
assert!(mods.base_module.contains(needle), "{label} base module");
assert!(mods.adaptive_format.contains("{recentCommits}"));
assert!(!cfg.active_language_instruction().is_empty());
}
}
#[test]
fn active_prompt_modules_custom_falls_back_to_english() {
let cfg = Config {
active_language: "Custom (example)".to_owned(),
..Config::default()
};
let mods = cfg.active_prompt_modules();
assert!(
mods.base_module
.contains("expert at writing git commit messages")
);
}
#[test]
fn backend_model_and_path() {
let mut cfg = Config::default();
assert_eq!(cfg.backend_model(), "gpt-5.4-mini");
assert_eq!(cfg.backend_cli_path(), "");
cfg.backend = CliBackend::Claude;
cfg.claude_path = "/usr/bin/claude".to_owned();
assert_eq!(cfg.backend_model(), "claude-sonnet-4-6");
assert_eq!(cfg.backend_cli_path(), "/usr/bin/claude");
cfg.backend = CliBackend::Codex;
cfg.codex_path = "/usr/bin/codex".to_owned();
assert_eq!(cfg.backend_model(), "gpt-5.4-mini");
assert_eq!(cfg.backend_cli_path(), "/usr/bin/codex");
cfg.backend = CliBackend::Gemini;
cfg.gemini_path = "/usr/bin/gemini".to_owned();
cfg.gemini_model = "gemini-2.5-flash".to_owned();
assert_eq!(cfg.backend_model(), "gemini-2.5-flash");
assert_eq!(cfg.backend_cli_path(), "/usr/bin/gemini");
}
#[test]
fn load_from_toml() {
let dir = std::env::temp_dir().join("occ-test-config");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
backend = "claude"
commit-mode = "conventional"
provider = "anthropic"
model = "opus"
claude-model = "opus"
max-diff-length = 5000
use-emojis = true
use-lower-case = false
[refine]
default-feedback = "be more specific"
[custom]
prompt = "Generate: {{{{diff}}}}"
"#
)
.unwrap();
drop(f);
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.backend, CliBackend::Claude);
assert_eq!(cfg.commit_mode, CommitMode::Conventional);
assert_eq!(cfg.provider, "anthropic");
assert_eq!(cfg.model, "opus");
assert_eq!(cfg.claude_model, "opus");
assert_eq!(cfg.max_diff_length, 5000);
assert!(cfg.use_emojis);
assert!(!cfg.use_lower_case);
assert_eq!(cfg.refine.default_feedback, "be more specific");
assert!(!cfg.custom.prompt.is_empty());
assert_eq!(cfg.diff_source, DiffSource::Auto);
assert_eq!(cfg.commit_template, "{{type}}: {{message}}");
assert_eq!(cfg.codex_model, "gpt-5.4-mini");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_nonexistent_file_errors() {
let result = Config::load(Path::new("/tmp/nonexistent-occ-config.toml"));
assert!(result.is_err());
}
#[test]
fn load_or_default_with_no_file() {
let _env_guard = ENV_LOCK.lock().unwrap();
let temp_root = std::env::temp_dir().join(format!(
"occ-load-or-default-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let config_root = temp_root.join("xdg");
let config_path = config_root.join("opencodecommit").join("config.toml");
let previous_xdg = std::env::var_os("XDG_CONFIG_HOME");
let _ = std::fs::remove_dir_all(&temp_root);
unsafe {
std::env::set_var("XDG_CONFIG_HOME", &config_root);
}
let cfg = Config::load_or_default(None).unwrap();
let serialized = std::fs::read_to_string(&config_path).unwrap();
assert_eq!(cfg.backend, CliBackend::Opencode);
assert_eq!(cfg.model, "gpt-5.4-mini");
assert!(config_path.exists());
assert!(serialized.contains("backend-order"));
assert!(serialized.contains("sensitive"));
assert!(serialized.contains("[[languages]]"));
assert!(serialized.contains("base-module"));
assert!(serialized.contains("sensitive-content-note"));
match previous_xdg {
Some(value) => unsafe {
std::env::set_var("XDG_CONFIG_HOME", value);
},
None => unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
},
}
let _ = std::fs::remove_dir_all(&temp_root);
}
#[test]
fn backend_pr_and_cheap_models() {
let mut cfg = Config::default();
assert_eq!(cfg.backend_pr_model(), "gpt-5.4");
assert_eq!(cfg.backend_cheap_model(), "gpt-5.4-mini");
assert_eq!(cfg.backend_pr_provider(), "openai");
assert_eq!(cfg.backend_cheap_provider(), "openai");
cfg.backend = CliBackend::Claude;
assert_eq!(cfg.backend_pr_model(), "claude-opus-4-6");
assert_eq!(cfg.backend_cheap_model(), "claude-haiku-4-5");
assert_eq!(cfg.backend_pr_provider(), "");
assert_eq!(cfg.backend_cheap_provider(), "");
cfg.backend = CliBackend::Codex;
assert_eq!(cfg.backend_pr_model(), "gpt-5.4");
assert_eq!(cfg.backend_cheap_model(), "gpt-5.4-mini");
cfg.backend = CliBackend::Gemini;
assert_eq!(cfg.backend_pr_model(), "gemini-3-flash-preview");
assert_eq!(cfg.backend_cheap_model(), "gemini-3.1-flash-lite-preview");
}
#[test]
fn pr_base_branch_defaults_empty() {
let cfg = Config::default();
assert_eq!(cfg.pr_base_branch, "");
}
#[test]
fn pr_model_fields_deserialize() {
let cfg: Config = toml::from_str(
r#"
claude-pr-model = "claude-sonnet-4-6"
claude-cheap-model = "claude-haiku-4-5"
pr-base-branch = "develop"
"#,
)
.unwrap();
assert_eq!(cfg.claude_pr_model, "claude-sonnet-4-6");
assert_eq!(cfg.claude_cheap_model, "claude-haiku-4-5");
assert_eq!(cfg.pr_base_branch, "develop");
}
#[test]
fn branch_mode_serde() {
let cfg: Config = toml::from_str("branch-mode = \"adaptive\"").unwrap();
assert_eq!(cfg.branch_mode, BranchMode::Adaptive);
let cfg2: Config = toml::from_str("branch-mode = \"conventional\"").unwrap();
assert_eq!(cfg2.branch_mode, BranchMode::Conventional);
}
#[test]
fn branch_mode_default() {
let cfg = Config::default();
assert_eq!(cfg.branch_mode, BranchMode::Conventional);
}
#[test]
fn serde_roundtrip() {
let cfg = Config::default();
let toml_str = toml::to_string(&cfg).unwrap();
let parsed: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.backend, cfg.backend);
assert_eq!(parsed.model, cfg.model);
assert_eq!(parsed.commit_mode, cfg.commit_mode);
assert_eq!(parsed.max_diff_length, cfg.max_diff_length);
}
}