suture_core/metadata/
global_config.rs1use 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 #[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 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}