Skip to main content

suture_core/metadata/
global_config.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, Deserialize, Default)]
6pub struct GlobalConfig {
7    #[serde(default)]
8    pub user: UserConfig,
9    #[serde(default)]
10    pub signing: SigningConfig,
11    #[serde(default)]
12    pub core: CoreConfig,
13    #[serde(default)]
14    pub push: PushConfig,
15    #[serde(default)]
16    pub pull: PullConfig,
17    #[serde(default)]
18    pub extra: HashMap<String, String>,
19}
20
21#[derive(Debug, Clone, Deserialize, Default)]
22pub struct UserConfig {
23    pub name: Option<String>,
24    pub email: Option<String>,
25}
26
27#[derive(Debug, Clone, Deserialize, Default)]
28pub struct SigningConfig {
29    pub key: Option<String>,
30}
31
32#[derive(Debug, Clone, Deserialize, Default)]
33pub struct CoreConfig {
34    pub compression: Option<bool>,
35    pub compression_level: Option<i32>,
36}
37
38#[derive(Debug, Clone, Deserialize, Default)]
39pub struct PushConfig {
40    pub auto: Option<bool>,
41}
42
43#[derive(Debug, Clone, Deserialize, Default)]
44pub struct PullConfig {
45    pub rebase: Option<bool>,
46}
47
48impl GlobalConfig {
49    /// Parse configuration from a TOML string.
50    #[allow(dead_code, clippy::should_implement_trait)]
51    pub fn from_str(s: &str) -> Result<Self, toml::de::Error> {
52        toml::from_str(s)
53    }
54
55    pub fn load() -> Self {
56        let path = Self::config_path();
57        if !path.exists() {
58            return Self::default();
59        }
60        let content = match std::fs::read_to_string(&path) {
61            Ok(c) => c,
62            Err(_) => return Self::default(),
63        };
64        match toml::from_str(&content) {
65            Ok(config) => config,
66            Err(e) => {
67                tracing::warn!(
68                    "warning: failed to parse global config at {}: {}",
69                    path.display(),
70                    e
71                );
72                Self::default()
73            }
74        }
75    }
76
77    pub fn config_path() -> PathBuf {
78        if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
79            PathBuf::from(xdg).join("suture").join("config.toml")
80        } else if let Ok(home) = std::env::var("HOME") {
81            PathBuf::from(home)
82                .join(".config")
83                .join("suture")
84                .join("config.toml")
85        } else {
86            dirs::config_dir()
87                .unwrap_or_else(|| PathBuf::from("."))
88                .join("suture")
89                .join("config.toml")
90        }
91    }
92
93    pub fn get(&self, key: &str) -> Option<String> {
94        let env_key = format!("SUTURE_{}", key.to_uppercase().replace('.', "_"));
95        if let Ok(val) = std::env::var(&env_key) {
96            return Some(val);
97        }
98
99        let parts: Vec<&str> = key.splitn(2, '.').collect();
100        if parts.len() == 2 {
101            match parts[0] {
102                "user" => match parts[1] {
103                    "name" => return self.user.name.clone(),
104                    "email" => return self.user.email.clone(),
105                    _ => {}
106                },
107                "signing" => {
108                    if parts[1] == "key" {
109                        return self.signing.key.clone();
110                    }
111                }
112                "core" => match parts[1] {
113                    "compression" => return self.core.compression.map(|v| v.to_string()),
114                    "compression_level" => {
115                        return self.core.compression_level.map(|v| v.to_string());
116                    }
117                    _ => {}
118                },
119                "push" => {
120                    if parts[1] == "auto" {
121                        return self.push.auto.map(|v| v.to_string());
122                    }
123                }
124                "pull" => {
125                    if parts[1] == "rebase" {
126                        return self.pull.rebase.map(|v| v.to_string());
127                    }
128                }
129                _ => {}
130            }
131        }
132
133        self.extra.get(key).cloned()
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_load_nonexistent_file_returns_defaults() {
143        // Ensure no env vars interfere
144        unsafe {
145            std::env::remove_var("SUTURE_USER_NAME");
146            std::env::remove_var("SUTURE_USER_EMAIL");
147        }
148        let config = GlobalConfig::load();
149        assert!(config.user.name.is_none());
150        assert!(config.user.email.is_none());
151        assert!(config.get("user.name").is_none());
152    }
153
154    #[test]
155    fn test_parse_valid_toml() {
156        let toml_str = r#"
157[user]
158name = "Alice"
159email = "alice@example.com"
160
161[signing]
162key = "default"
163
164[core]
165compression = true
166compression_level = 3
167
168[push]
169auto = false
170
171[pull]
172rebase = false
173"#;
174        let config: GlobalConfig = toml::from_str(toml_str).unwrap();
175        assert_eq!(config.user.name.as_deref(), Some("Alice"));
176        assert_eq!(config.user.email.as_deref(), Some("alice@example.com"));
177        assert_eq!(config.signing.key.as_deref(), Some("default"));
178        assert_eq!(config.core.compression, Some(true));
179        assert_eq!(config.core.compression_level, Some(3));
180        assert_eq!(config.push.auto, Some(false));
181        assert_eq!(config.pull.rebase, Some(false));
182    }
183
184    #[test]
185    fn test_get_dotted_keys() {
186        let toml_str = r#"
187[user]
188name = "Bob"
189email = "bob@example.com"
190
191[core]
192compression = true
193compression_level = 6
194"#;
195        let config: GlobalConfig = toml::from_str(toml_str).unwrap();
196        assert_eq!(config.get("user.name"), Some("Bob".to_string()));
197        assert_eq!(
198            config.get("user.email"),
199            Some("bob@example.com".to_string())
200        );
201        assert_eq!(config.get("core.compression"), Some("true".to_string()));
202        assert_eq!(config.get("core.compression_level"), Some("6".to_string()));
203        assert!(config.get("core.nonexistent").is_none());
204        assert!(config.get("nonexistent.key").is_none());
205    }
206
207    #[test]
208    fn test_env_var_override() {
209        let config = GlobalConfig::default();
210        unsafe {
211            std::env::set_var("SUTURE_USER_NAME", "EnvUser");
212        }
213        assert_eq!(config.get("user.name"), Some("EnvUser".to_string()));
214        unsafe {
215            std::env::remove_var("SUTURE_USER_NAME");
216        }
217    }
218
219    #[test]
220    fn test_config_path() {
221        let path = GlobalConfig::config_path();
222        assert!(path.to_string_lossy().contains("suture"));
223        assert!(path.to_string_lossy().ends_with("config.toml"));
224    }
225
226    #[test]
227    fn test_parse_partial_toml() {
228        let toml_str = r#"
229[user]
230name = "Charlie"
231"#;
232        let config: GlobalConfig = toml::from_str(toml_str).unwrap();
233        assert_eq!(config.get("user.name"), Some("Charlie".to_string()));
234        assert!(config.get("user.email").is_none());
235        assert!(config.get("signing.key").is_none());
236    }
237}