use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::error::ConfigError;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub session: SessionConfig,
pub worktree: WorktreeConfig,
pub notification: NotificationConfig,
pub log: LogConfig,
pub claude: ClaudeConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SessionConfig {
pub max_sessions: usize,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PullStrategy {
#[default]
Merge,
Rebase,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct WorktreeConfig {
pub auto_cleanup: bool,
pub branch_prefix: String,
pub base_path: String,
pub pull_strategy: PullStrategy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct NotificationConfig {
pub terminal_bell: bool,
pub webhook: WebhookConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct WebhookConfig {
pub enabled: bool,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LogConfig {
pub directory: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ClaudeConfig {
pub default_args: Vec<String>,
}
impl Default for SessionConfig {
fn default() -> Self {
Self { max_sessions: 10 }
}
}
impl Default for WorktreeConfig {
fn default() -> Self {
Self {
auto_cleanup: false,
branch_prefix: "tazuna/".to_string(),
base_path: "~/worktrees".to_string(),
pull_strategy: PullStrategy::default(),
}
}
}
impl Default for NotificationConfig {
fn default() -> Self {
Self {
terminal_bell: true,
webhook: WebhookConfig::default(),
}
}
}
impl Default for LogConfig {
fn default() -> Self {
Self {
directory: "~/.local/share/tazuna/logs".to_string(),
}
}
}
impl Config {
pub fn load() -> Result<Self, ConfigError> {
let path = Self::default_path();
Self::load_from(&path)
}
pub(crate) fn load_from(path: &PathBuf) -> Result<Self, ConfigError> {
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFailed {
path: path.clone(),
source: e,
})?;
toml::from_str(&content).map_err(ConfigError::ParseFailed)
}
#[must_use]
pub fn default_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("tazuna")
.join("config.toml")
}
#[must_use]
pub fn log_directory(&self) -> PathBuf {
expand_tilde(&self.log.directory)
}
#[must_use]
pub fn worktree_base_path(&self) -> PathBuf {
expand_tilde(&self.worktree.base_path)
}
}
fn expand_tilde(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(stripped);
}
PathBuf::from(path)
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use rstest::rstest;
#[test]
fn default_config_values() {
let config = Config::default();
assert_eq!(config.session.max_sessions, 10);
assert!(!config.worktree.auto_cleanup);
assert_eq!(config.worktree.branch_prefix, "tazuna/");
assert_eq!(config.worktree.base_path, "~/worktrees");
assert!(config.notification.terminal_bell);
assert!(!config.notification.webhook.enabled);
assert!(config.notification.webhook.url.is_empty());
assert!(config.claude.default_args.is_empty());
}
#[test]
fn parse_minimal_toml() {
let toml_str = r"
[session]
max_sessions = 5
";
let config: Config = toml::from_str(toml_str).expect("failed to parse toml");
assert_eq!(config.session.max_sessions, 5);
assert!(!config.worktree.auto_cleanup);
assert_eq!(config.worktree.branch_prefix, "tazuna/");
assert_eq!(config.worktree.base_path, "~/worktrees");
}
#[test]
fn parse_full_toml() {
let toml_str = r#"
[session]
max_sessions = 20
[worktree]
auto_cleanup = true
branch_prefix = "feature/"
base_path = "/custom/worktrees"
[notification]
terminal_bell = true
[notification.webhook]
enabled = true
url = "https://hooks.example.com"
[log]
directory = "/var/log/tazuna"
[claude]
default_args = ["--model", "opus"]
"#;
let config: Config = toml::from_str(toml_str).expect("failed to parse toml");
assert_eq!(config.session.max_sessions, 20);
assert!(config.worktree.auto_cleanup);
assert_eq!(config.worktree.branch_prefix, "feature/");
assert_eq!(config.worktree.base_path, "/custom/worktrees");
assert!(config.notification.terminal_bell);
assert!(config.notification.webhook.enabled);
assert_eq!(config.notification.webhook.url, "https://hooks.example.com");
assert_eq!(config.log.directory, "/var/log/tazuna");
assert_eq!(config.claude.default_args, vec!["--model", "opus"]);
}
#[test]
fn load_nonexistent_returns_default() {
let path = PathBuf::from("/nonexistent/path/config.toml");
let config = Config::load_from(&path).expect("should return default for nonexistent path");
assert_eq!(config.session.max_sessions, 10);
}
#[test]
fn expand_tilde_replaces_home() {
let result = expand_tilde("~/some/path");
assert!(!result.to_string_lossy().starts_with('~'));
assert!(result.to_string_lossy().ends_with("some/path"));
}
#[test]
fn expand_tilde_keeps_absolute() {
let result = expand_tilde("/absolute/path");
assert_eq!(result, PathBuf::from("/absolute/path"));
}
#[test]
fn pull_strategy_default_is_merge() {
assert_eq!(PullStrategy::default(), PullStrategy::Merge);
}
#[rstest]
#[case("merge", PullStrategy::Merge)]
#[case("rebase", PullStrategy::Rebase)]
fn pull_strategy_parse(#[case] strategy_str: &str, #[case] expected: PullStrategy) {
let toml_str = format!(
r#"
[worktree]
pull_strategy = "{strategy_str}"
"#
);
let config: Config = toml::from_str(&toml_str).expect("failed to parse toml");
assert_eq!(config.worktree.pull_strategy, expected);
}
#[test]
fn default_path_returns_config_toml() {
let path = Config::default_path();
assert!(path.to_string_lossy().contains("tazuna"));
assert!(path.to_string_lossy().ends_with("config.toml"));
}
#[test]
fn load_from_invalid_toml() {
let temp = tempfile::tempdir().expect("create temp dir");
let config_path = temp.path().join("config.toml");
std::fs::write(&config_path, "invalid [ toml syntax").expect("write config");
let result = Config::load_from(&config_path);
assert!(result.is_err());
assert!(matches!(
result.expect_err("should be parse error"),
ConfigError::ParseFailed(_)
));
}
#[test]
fn load_from_valid_toml() {
let temp = tempfile::tempdir().expect("create temp dir");
let config_path = temp.path().join("config.toml");
std::fs::write(
&config_path,
r"
[session]
max_sessions = 15
",
)
.expect("write config");
let config = Config::load_from(&config_path).expect("load config");
assert_eq!(config.session.max_sessions, 15);
}
#[test]
fn load_from_unreadable_path() {
let temp = tempfile::tempdir().expect("create temp dir");
let config_path = temp.path().join("config.toml");
std::fs::create_dir(&config_path).expect("create dir as file");
let result = Config::load_from(&config_path);
assert!(result.is_err());
assert!(matches!(
result.expect_err("should be read error"),
ConfigError::ReadFailed { .. }
));
}
#[test]
fn load_returns_default_when_no_config() {
let config = Config::load().expect("load should succeed with default");
assert_eq!(config.session.max_sessions, 10);
}
}