use crate::model;
use colored::Colorize;
use serde::{Deserialize, Serialize};
use std::{process, str::FromStr};
use url::Url;
pub const REASONING_EFFORTS: &[&str] = &["none", "low", "medium", "high", "xhigh", "max"];
pub const VERBOSITY_LEVELS: &[&str] = &["low", "medium", "high"];
#[derive(Debug)]
pub struct ValidationError {
field: String,
message: String,
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.field.red(), self.message)
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct Config {
pub model: model::Model,
pub api_endpoint: String,
pub api_key_env_var: String,
pub default_number_of_choices: i32,
pub disable_auto_update_check: bool,
pub reasoning_effort: String,
pub verbosity: String,
pub jj_rewrite_default: bool,
pub system_msg: String,
}
impl Default for Config {
fn default() -> Self {
Self {
model: model::Model::default(),
api_endpoint: String::from("https://api.openai.com/v1/chat/completions"),
api_key_env_var: String::from("OPENAI_API_KEY"),
default_number_of_choices: 3,
disable_auto_update_check: false,
reasoning_effort: String::from("low"),
verbosity: String::from("medium"),
jj_rewrite_default: true,
system_msg: String::from(
"Generate high-quality conventional commit suggestions from the repository diff. Infer the change's primary intent, prioritizing explicit revision instructions and a Current description when consistent with the diff.\n\nUse `<type>(optional-scope): imperative description` with one of: feat, fix, docs, style, refactor, test, build, ci, chore. Add `!` only for a breaking change, and use a scope only when useful. Set the body to null unless one concise paragraph adds why or impact beyond the title. Avoid vague or file-by-file summaries. Make every suggestion distinct and defensible.",
),
}
}
}
impl Config {
pub fn load_from_path(path: &std::path::Path) -> anyhow::Result<Self> {
//debug log the path we load from
println!("Loading config from path: {}", path.display());
let config = match std::fs::read_to_string(path) {
Ok(config_str) => match serde_yaml::from_str::<Self>(&config_str) {
Ok(config) => config,
Err(err) => {
return Err(anyhow::anyhow!("Configuration file parsing error: {}", err));
}
},
Err(err) => match err.kind() {
std::io::ErrorKind::NotFound => {
let msg = format!("Config file not found at: {}", path.display());
return Err(anyhow::anyhow!(msg));
}
_ => {
return Err(anyhow::anyhow!("Error reading configuration file: {}", err));
}
},
};
if let Err(validation_errors) = config.validate() {
let mut error_msg = String::from("Configuration validation errors:\n");
for error in validation_errors {
error_msg.push_str(&format!(" {}\n", error));
}
error_msg.push_str(&format!(
"\nConfiguration file location: {}",
path.display()
));
if config.system_msg.trim().is_empty() {
error_msg.push_str("\n\nDefault developer instruction:\n");
error_msg.push_str(&Self::default().system_msg);
}
return Err(anyhow::anyhow!(error_msg));
}
Ok(config)
}
pub fn load() -> anyhow::Result<Self> {
let path = home::home_dir().map_or_else(
|| {
println!("{}", "Unable to find home directory.".red());
process::exit(1);
},
|path| path.join(".turbocommit.yaml"),
);
let config = match std::fs::read_to_string(&path) {
Ok(config_str) => match serde_yaml::from_str::<Self>(&config_str) {
Ok(config) => config,
Err(err) => {
return Err(anyhow::anyhow!("Configuration file parsing error: {}", err));
}
},
Err(err) => match err.kind() {
std::io::ErrorKind::NotFound => {
println!(
"{}",
"No configuration file found, creating one with default values."
.bright_black()
);
let default = Self::default();
if let Err(e) = default.save_if_changed() {
println!(
"{}",
format!("Warning: Failed to create default config file: {}", e)
.yellow()
);
}
default
}
_ => {
return Err(anyhow::anyhow!("Error reading configuration file: {}", err));
}
},
};
if let Err(validation_errors) = config.validate() {
let mut error_msg = String::from("Configuration validation errors:\n");
for error in validation_errors {
error_msg.push_str(&format!(" {}\n", error));
}
error_msg.push_str(&format!(
"\nConfiguration file location: {}",
path.display()
));
if config.system_msg.trim().is_empty() {
error_msg.push_str("\n\nDefault developer instruction:\n");
error_msg.push_str(&Self::default().system_msg);
}
return Err(anyhow::anyhow!(error_msg));
}
Ok(config)
}
pub fn save_if_changed(&self) -> Result<(), std::io::Error> {
let path = home::home_dir().map_or_else(
|| {
println!("{}", "Unable to find home directory.".red());
process::exit(1);
},
|path| path.join(".turbocommit.yaml"),
);
let config = match serde_yaml::to_string(self) {
Ok(config) => config,
Err(err) => {
println!("{}", format!("Unable to serialize config: {}", err).red());
return Err(std::io::Error::other("Unable to serialize config"));
}
};
if let Ok(existing_config) = std::fs::read_to_string(&path) {
if existing_config == config {
return Ok(());
}
}
std::fs::write(path, config)
}
pub fn path() -> std::path::PathBuf {
home::home_dir().map_or_else(
|| {
println!("{}", "Unable to find home directory.".red());
process::exit(1);
},
|path| path.join(".turbocommit.yaml"),
)
}
fn validate(&self) -> Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
let default = Self::default();
// Validate model
if self.model.0.is_empty() {
errors.push(ValidationError {
field: "model".to_string(),
message: format!("Model cannot be empty (default: {})", default.model.0),
});
} else if let Err(err) = model::Model::from_str(&self.model.0) {
errors.push(ValidationError {
field: "model".to_string(),
message: err,
});
}
// Validate API endpoint
if Url::parse(&self.api_endpoint).is_err() {
errors.push(ValidationError {
field: "api_endpoint".to_string(),
message: format!("Invalid URL format (default: {})", default.api_endpoint),
});
}
// Validate number of choices
if self.default_number_of_choices < 1 {
errors.push(ValidationError {
field: "default_number_of_choices".to_string(),
message: format!(
"Number of choices must be at least 1 (default: {})",
default.default_number_of_choices
),
});
}
if !REASONING_EFFORTS.contains(&self.reasoning_effort.as_str()) {
errors.push(ValidationError {
field: "reasoning_effort".to_string(),
message: format!(
"Unsupported value '{}'. Valid values: {}",
self.reasoning_effort,
REASONING_EFFORTS.join(", ")
),
});
}
if !VERBOSITY_LEVELS.contains(&self.verbosity.as_str()) {
errors.push(ValidationError {
field: "verbosity".to_string(),
message: format!(
"Unsupported value '{}'. Valid values: {}",
self.verbosity,
VERBOSITY_LEVELS.join(", ")
),
});
}
// Validate developer instruction (legacy config key: system_msg)
if self.system_msg.trim().is_empty() {
errors.push(ValidationError {
field: "system_msg".to_string(),
message: "Developer instruction cannot be empty (see default below)".to_string(),
});
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn create_test_config(content: &str) -> (std::path::PathBuf, tempfile::TempDir) {
let dir = tempdir().unwrap();
let file_path = dir.path().join(".turbocommit.yaml");
fs::write(&file_path, content).unwrap();
(file_path, dir)
}
#[test]
fn test_default_config_is_valid() {
let config = Config::default();
assert!(config.validate().is_ok());
assert_eq!(config.model.0, "gpt-5.6-luna");
assert_eq!(config.reasoning_effort, "low");
assert_eq!(config.verbosity, "medium");
assert!(config.jj_rewrite_default);
assert!(config.system_msg.len() < 700);
}
#[test]
fn test_validate_empty_model() {
let mut config = Config::default();
config.model = model::Model(String::new());
let errors = config.validate().unwrap_err();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].field, "model");
}
#[test]
fn test_validate_invalid_api_endpoint() {
let mut config = Config::default();
config.api_endpoint = "not a url".to_string();
let errors = config.validate().unwrap_err();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].field, "api_endpoint");
}
#[test]
fn test_validate_invalid_number_of_choices() {
let mut config = Config::default();
config.default_number_of_choices = 0;
let errors = config.validate().unwrap_err();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].field, "default_number_of_choices");
}
#[test]
fn test_validate_empty_system_msg() {
let mut config = Config::default();
config.system_msg = "".to_string();
let errors = config.validate().unwrap_err();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].field, "system_msg");
}
#[test]
fn test_validate_reasoning_effort_and_verbosity() {
let mut config = Config::default();
config.reasoning_effort = "minimal".to_string();
config.verbosity = "verbose".to_string();
let errors = config.validate().unwrap_err();
assert!(errors.iter().any(|error| error.field == "reasoning_effort"));
assert!(errors.iter().any(|error| error.field == "verbosity"));
for effort in REASONING_EFFORTS {
let mut config = Config::default();
config.reasoning_effort = (*effort).to_string();
assert!(config.validate().is_ok(), "{effort} should be valid");
}
}
#[test]
fn test_validate_multiple_errors() {
let mut config = Config::default();
config.model = model::Model(String::new());
config.system_msg = "".to_string();
let errors = config.validate().unwrap_err();
assert_eq!(errors.len(), 2);
}
#[test]
fn test_load_valid_config() {
let config_content = r#"
model: gpt-5.6-terra
api_endpoint: https://api.openai.com/v1/chat/completions
default_number_of_choices: 3
disable_auto_update_check: true
system_msg: "Test message"
"#;
let (_file_path, _dir) = create_test_config(config_content);
// Set the home directory to our temp directory for this test
std::env::set_var("HOME", _dir.path());
let config = Config::load();
assert!(config.is_ok());
let config = config.unwrap();
assert!(config.disable_auto_update_check);
}
#[test]
fn test_load_invalid_yaml() {
let config_content = "invalid: yaml: content: [";
let (_file_path, _dir) = create_test_config(config_content);
// Set the home directory to our temp directory for this test
std::env::set_var("HOME", _dir.path());
let config = Config::load();
assert!(
config.is_err(),
"Expected config loading to fail with invalid YAML"
);
}
#[test]
fn test_load_missing_file_creates_default() {
let _dir = tempdir().unwrap();
std::env::set_var("HOME", _dir.path());
// First load should create the file
let config = Config::load();
assert!(config.is_ok());
// Verify the file was created
let config_path = _dir.path().join(".turbocommit.yaml");
assert!(config_path.exists());
// Verify content matches default
let content = std::fs::read_to_string(config_path).unwrap();
let loaded_config: Config = serde_yaml::from_str(&content).unwrap();
assert_eq!(loaded_config.model.0, Config::default().model.0);
assert_eq!(loaded_config.api_endpoint, Config::default().api_endpoint);
}
#[test]
fn test_validation_error_includes_defaults() {
let mut config = Config::default();
config.model = model::Model(String::new());
let errors = config.validate().unwrap_err();
let default = Config::default();
// Find the model error
let model_error = errors.iter().find(|e| e.field == "model").unwrap();
assert!(model_error.message.contains(&default.model.0));
}
#[test]
fn test_empty_system_msg_shows_default() {
let config_content = r#"
model: gpt-5.6-luna
api_endpoint: https://api.openai.com/v1/chat/completions
default_number_of_choices: 3
disable_auto_update_check: false
system_msg: ""
"#;
let (_file_path, _dir) = create_test_config(config_content);
std::env::set_var("HOME", _dir.path());
let error = Config::load().unwrap_err();
let error_msg = error.to_string();
// Error should contain the default developer instruction.
assert!(error_msg.contains("Default developer instruction:"));
assert!(error_msg.contains(&Config::default().system_msg));
}
#[test]
fn test_save_if_changed() {
let _dir = tempdir().unwrap();
// Set the home directory to our temp directory for this test
std::env::set_var("HOME", _dir.path());
// Create a config with some changes
let mut config = Config::default();
config.model = model::Model("gpt-5.6-sol".to_string());
// First save should succeed
assert!(config.save_if_changed().is_ok());
// Second save with no changes should still be ok
assert!(config.save_if_changed().is_ok());
// Verify the file was created with correct content
let config_path = _dir.path().join(".turbocommit.yaml");
assert!(config_path.exists());
let content = std::fs::read_to_string(config_path).unwrap();
let loaded_config: Config = serde_yaml::from_str(&content).unwrap();
assert_eq!(loaded_config.model.0, "gpt-5.6-sol");
}
#[test]
fn test_default_auto_update_check() {
let config = Config::default();
assert!(
!config.disable_auto_update_check,
"Auto update check should be enabled by default"
);
}
#[test]
fn test_load_from_path_valid_config() {
let config_content = r#"
model: gpt-5.6-terra
api_endpoint: https://api.openai.com/v1/chat/completions
default_number_of_choices: 3
disable_auto_update_check: true
system_msg: "Test message"
"#;
let (file_path, _dir) = create_test_config(config_content);
let config = Config::load_from_path(&file_path);
assert!(config.is_ok());
let config = config.unwrap();
assert_eq!(config.model.0, "gpt-5.6-terra");
assert!(config.disable_auto_update_check);
assert_eq!(config.system_msg, "Test message");
}
#[test]
fn test_partial_config_uses_application_defaults() {
let config_content = r#"
model: gpt-5.6-sol
"#;
let (file_path, _dir) = create_test_config(config_content);
let config = Config::load_from_path(&file_path).unwrap();
let defaults = Config::default();
assert_eq!(config.model.0, "gpt-5.6-sol");
assert_eq!(config.api_endpoint, defaults.api_endpoint);
assert_eq!(config.reasoning_effort, defaults.reasoning_effort);
assert_eq!(config.verbosity, defaults.verbosity);
assert_eq!(config.jj_rewrite_default, defaults.jj_rewrite_default);
assert_eq!(config.system_msg, defaults.system_msg);
}
#[test]
fn test_load_from_path_invalid_yaml() {
let config_content = "invalid: yaml: content: [";
let (file_path, _dir) = create_test_config(config_content);
let config = Config::load_from_path(&file_path);
assert!(config.is_err());
assert!(config
.unwrap_err()
.to_string()
.contains("Configuration file parsing error"));
}
#[test]
fn test_load_from_path_nonexistent_file() {
let dir = tempdir().unwrap();
let nonexistent_path = dir.path().join("nonexistent.yaml");
let config = Config::load_from_path(&nonexistent_path);
assert!(config.is_err());
}
#[test]
fn test_load_from_path_invalid_config() {
let config_content = r#"
model: "" # Empty model is invalid
api_endpoint: not-a-url
default_number_of_choices: 3
disable_auto_update_check: false
system_msg: "Test message"
"#;
let (file_path, _dir) = create_test_config(config_content);
let config = Config::load_from_path(&file_path);
assert!(config.is_err());
let err = config.unwrap_err().to_string();
assert!(err.contains("model")); // Should mention empty model error
assert!(err.contains("api_endpoint")); // Should mention invalid URL error
}
#[test]
fn test_load_from_path_invalid_model() {
let config_content = r#"
model: "gpt-5.6-pro"
api_endpoint: "https://api.openai.com/v1/chat/completions"
default_number_of_choices: 3
disable_auto_update_check: false
system_msg: "Test message"
"#;
let (file_path, _dir) = create_test_config(config_content);
let config = Config::load_from_path(&file_path);
assert!(config.is_err());
let err = config.unwrap_err().to_string();
assert!(err.contains("Supported GPT-5.6 models"));
}
#[test]
fn test_load_from_path_empty_system_msg() {
let config_content = r#"
model: "gpt-5.6-luna"
api_endpoint: "https://api.openai.com/v1/chat/completions"
default_number_of_choices: 3
disable_auto_update_check: false
system_msg: ""
"#;
let (file_path, _dir) = create_test_config(config_content);
let config = Config::load_from_path(&file_path);
assert!(config.is_err());
let err = config.unwrap_err().to_string();
assert!(err.contains("system_msg")); // Should mention system message error
assert!(err.contains("Default developer instruction:"));
}
}