Skip to main content

chronicle/config/
user_config.rs

1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::setup_error::{
6    NoHomeDirectorySnafu, ReadConfigSnafu, ReadFileSnafu, WriteConfigSnafu, WriteFileSnafu,
7};
8use crate::error::SetupError;
9use snafu::ResultExt;
10
11/// User-level config stored at ~/.git-chronicle.toml.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct UserConfig {
14    pub provider: ProviderConfig,
15}
16
17/// Provider configuration within user config.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct ProviderConfig {
20    #[serde(rename = "type")]
21    pub provider_type: ProviderType,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub model: Option<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub api_key_env: Option<String>,
26}
27
28/// Supported provider types.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum ProviderType {
32    ClaudeCode,
33    Anthropic,
34    None,
35}
36
37impl std::fmt::Display for ProviderType {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            ProviderType::ClaudeCode => write!(f, "claude-code"),
41            ProviderType::Anthropic => write!(f, "anthropic"),
42            ProviderType::None => write!(f, "none"),
43        }
44    }
45}
46
47impl UserConfig {
48    /// Path to the user config file (~/.git-chronicle.toml).
49    pub fn path() -> Result<PathBuf, SetupError> {
50        let home = std::env::var("HOME")
51            .ok()
52            .map(PathBuf::from)
53            .filter(|p| p.is_absolute())
54            .ok_or_else(|| NoHomeDirectorySnafu.build())?;
55        Ok(home.join(".git-chronicle.toml"))
56    }
57
58    /// Load user config from ~/.git-chronicle.toml.
59    /// Returns Ok(None) if the file does not exist.
60    pub fn load() -> Result<Option<Self>, SetupError> {
61        let path = Self::path()?;
62        if !path.exists() {
63            return Ok(None);
64        }
65        let contents = std::fs::read_to_string(&path).context(ReadFileSnafu {
66            path: path.display().to_string(),
67        })?;
68        let config: UserConfig = toml::from_str(&contents).context(ReadConfigSnafu)?;
69        Ok(Some(config))
70    }
71
72    /// Save user config to ~/.git-chronicle.toml.
73    pub fn save(&self) -> Result<(), SetupError> {
74        let path = Self::path()?;
75        let contents = toml::to_string_pretty(self).context(WriteConfigSnafu)?;
76        std::fs::write(&path, contents).context(WriteFileSnafu {
77            path: path.display().to_string(),
78        })?;
79        Ok(())
80    }
81}
82
83impl Default for UserConfig {
84    fn default() -> Self {
85        Self {
86            provider: ProviderConfig {
87                provider_type: ProviderType::None,
88                model: None,
89                api_key_env: None,
90            },
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn test_provider_type_serialization() {
101        let config = UserConfig {
102            provider: ProviderConfig {
103                provider_type: ProviderType::ClaudeCode,
104                model: None,
105                api_key_env: None,
106            },
107        };
108        let toml_str = toml::to_string_pretty(&config).unwrap();
109        assert!(toml_str.contains("\"claude-code\""));
110
111        let config2 = UserConfig {
112            provider: ProviderConfig {
113                provider_type: ProviderType::Anthropic,
114                model: Some("claude-sonnet-4-5-20250929".to_string()),
115                api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
116            },
117        };
118        let toml_str2 = toml::to_string_pretty(&config2).unwrap();
119        assert!(toml_str2.contains("\"anthropic\""));
120        assert!(toml_str2.contains("claude-sonnet-4-5-20250929"));
121    }
122
123    #[test]
124    fn test_roundtrip() {
125        let config = UserConfig {
126            provider: ProviderConfig {
127                provider_type: ProviderType::ClaudeCode,
128                model: Some("claude-sonnet-4-5-20250929".to_string()),
129                api_key_env: None,
130            },
131        };
132        let toml_str = toml::to_string_pretty(&config).unwrap();
133        let parsed: UserConfig = toml::from_str(&toml_str).unwrap();
134        assert_eq!(config, parsed);
135    }
136
137    #[test]
138    fn test_none_provider() {
139        let config = UserConfig {
140            provider: ProviderConfig {
141                provider_type: ProviderType::None,
142                model: None,
143                api_key_env: None,
144            },
145        };
146        let toml_str = toml::to_string_pretty(&config).unwrap();
147        assert!(toml_str.contains("\"none\""));
148        let parsed: UserConfig = toml::from_str(&toml_str).unwrap();
149        assert_eq!(config, parsed);
150    }
151
152    #[test]
153    fn test_provider_type_display() {
154        assert_eq!(ProviderType::ClaudeCode.to_string(), "claude-code");
155        assert_eq!(ProviderType::Anthropic.to_string(), "anthropic");
156        assert_eq!(ProviderType::None.to_string(), "none");
157    }
158}