1pub mod cache;
8pub mod error;
9pub mod keys;
10pub mod paths;
11pub mod remote;
12pub mod theme;
13
14pub use cache::CacheConfig;
15pub use error::{ConfigError, ConfigWarning};
16pub use keys::KeybindingsConfig;
17pub use paths::AppPaths;
18pub use remote::RemoteConfig;
19pub use theme::ThemeConfig;
20
21use serde::Deserialize;
22
23#[derive(Debug, Clone, Default, Deserialize)]
39#[serde(default)]
40pub struct Config {
41 pub editor: Option<String>,
44 pub cache: CacheConfig,
46 pub remotes: Vec<RemoteConfig>,
48 pub keybindings: KeybindingsConfig,
50 pub theme: ThemeConfig,
52}
53
54impl Config {
55 pub fn load() -> Result<(Self, Vec<ConfigWarning>), ConfigError> {
65 let mut warnings = Vec::new();
66 let paths = AppPaths::resolve()?;
67
68 if !paths.config_file.exists() {
69 warnings.push(ConfigWarning::NoFile);
70 return Ok((Self::default(), warnings));
71 }
72
73 let content = std::fs::read_to_string(&paths.config_file)?;
74 let config: Self = toml::from_str(&content)?;
75
76 for remote in &config.remotes {
78 if remote.url.is_empty()
79 || (!remote.url.starts_with("http://") && !remote.url.starts_with("https://"))
80 {
81 warnings.push(ConfigWarning::InvalidRemoteUrl {
82 name: remote.name.clone(),
83 url: remote.url.clone(),
84 });
85 }
86 }
87
88 Ok((config, warnings))
89 }
90
91 pub fn from_toml(toml_str: &str) -> Result<Self, ConfigError> {
97 let config: Self = toml::from_str(toml_str)?;
98 Ok(config)
99 }
100
101 #[must_use]
104 pub fn resolved_editor(&self) -> String {
105 self.editor
106 .clone()
107 .or_else(|| std::env::var("VISUAL").ok())
108 .or_else(|| std::env::var("EDITOR").ok())
109 .unwrap_or_else(|| "vi".to_string())
110 }
111}
112
113#[cfg(test)]
114#[allow(clippy::expect_used)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn default_config() {
120 let config = Config::default();
121 assert!(config.remotes.is_empty());
122 assert!(config.editor.is_none());
123 assert_eq!(config.cache.ttl_seconds, 300);
124 }
125
126 #[test]
127 fn load_returns_no_file_warning_when_missing() {
128 let result = Config::load();
132 assert!(result.is_ok());
133 }
134
135 #[test]
136 fn parse_minimal_toml() {
137 let toml_str = r#"
138 [[remotes]]
139 name = "upstream"
140 url = "https://sashiko.dev"
141 "#;
142 let config = Config::from_toml(toml_str).expect("parse minimal toml");
143 assert_eq!(config.remotes.len(), 1);
144 assert_eq!(config.remotes[0].name, "upstream");
145 assert_eq!(config.remotes[0].url, "https://sashiko.dev");
146 assert_eq!(config.cache.ttl_seconds, 300);
148 assert!(config.editor.is_none());
149 }
150
151 #[test]
152 fn parse_full_toml() {
153 let toml_str = r#"
154 editor = "nvim"
155
156 [cache]
157 ttl_seconds = 60
158
159 [[remotes]]
160 name = "upstream"
161 url = "https://sashiko.dev"
162
163 [[remotes]]
164 name = "staging"
165 url = "https://staging.sashiko.dev"
166 auth_env = "SASHIKO_TOKEN"
167 timeout_seconds = 30
168 "#;
169 let config = Config::from_toml(toml_str).expect("parse full toml");
170 assert_eq!(config.editor.as_deref(), Some("nvim"));
171 assert_eq!(config.cache.ttl_seconds, 60);
172 assert_eq!(config.remotes.len(), 2);
173 assert_eq!(config.remotes[1].timeout_seconds, 30);
174 }
175
176 #[test]
177 fn invalid_toml_returns_parse_error() {
178 let bad_toml = "this is not { valid toml";
179 let result = Config::from_toml(bad_toml);
180 assert!(result.is_err());
181 assert!(
182 matches!(result, Err(ConfigError::Parse(_))),
183 "expected ConfigError::Parse"
184 );
185 }
186
187 #[test]
188 fn partial_toml_fills_defaults() {
189 let toml_str = r"
190 [cache]
191 ttl_seconds = 120
192 ";
193 let config = Config::from_toml(toml_str).expect("parse partial toml");
194 assert!(config.remotes.is_empty());
195 assert!(config.editor.is_none());
196 assert_eq!(config.cache.ttl_seconds, 120);
197 }
198
199 #[test]
200 fn resolved_editor_falls_back_to_vi() {
201 let config = Config {
202 editor: None,
203 ..Config::default()
204 };
205 let editor = config.resolved_editor();
208 assert!(!editor.is_empty());
209 }
210
211 #[test]
212 fn resolved_editor_config_overrides_env() {
213 let config = Config {
214 editor: Some("helix".to_string()),
215 ..Config::default()
216 };
217 assert_eq!(config.resolved_editor(), "helix");
218 }
219
220 #[test]
221 fn multiple_remotes_preserve_order() {
222 let toml_str = r#"
223 [[remotes]]
224 name = "first"
225 url = "https://first.example.com"
226
227 [[remotes]]
228 name = "second"
229 url = "https://second.example.com"
230
231 [[remotes]]
232 name = "third"
233 url = "https://third.example.com"
234 "#;
235 let config = Config::from_toml(toml_str).expect("parse multiple remotes");
236 assert_eq!(config.remotes.len(), 3);
237 assert_eq!(config.remotes[0].name, "first");
238 assert_eq!(config.remotes[1].name, "second");
239 assert_eq!(config.remotes[2].name, "third");
240 }
241}
242
243#[cfg(test)]
244#[allow(clippy::expect_used)]
245mod load_integration_tests {
246 use super::*;
247
248 #[test]
249 fn load_does_not_error() {
250 let result = Config::load();
253 assert!(result.is_ok(), "Config::load() failed: {result:?}");
254 }
255
256 #[test]
257 fn load_from_xdg_no_false_nofile_warning() {
258 let paths = AppPaths::resolve().expect("resolve paths");
259 let (_, warnings) = Config::load().expect("load config");
260
261 if paths.config_file.exists() {
264 assert!(
265 !warnings.iter().any(|w| matches!(w, ConfigWarning::NoFile)),
266 "config file exists at {:?} but got NoFile warning",
267 paths.config_file
268 );
269 }
270 }
271
272 #[test]
273 fn load_produces_nofile_warning_when_missing() {
274 let paths = AppPaths::resolve().expect("resolve paths");
278 let (_, warnings) = Config::load().expect("load config");
279
280 if !paths.config_file.exists() {
281 assert!(
282 warnings.iter().any(|w| matches!(w, ConfigWarning::NoFile)),
283 "no config file at {:?} but no NoFile warning",
284 paths.config_file
285 );
286 }
287 }
288
289 #[test]
290 fn load_populates_keybindings() {
291 let (config, _) = Config::load().expect("load config");
292 assert!(
295 !config.keybindings.bindings.is_empty(),
296 "keybindings should have default entries"
297 );
298 }
299
300 #[test]
301 fn from_toml_with_remotes_populates_list() {
302 let toml_str = r#"
303 [[remotes]]
304 name = "test"
305 url = "https://sashiko.example.com"
306 "#;
307 let config = Config::from_toml(toml_str).expect("parse toml");
308 assert_eq!(config.remotes.len(), 1);
309 assert_eq!(config.remotes[0].name, "test");
310 assert_eq!(config.remotes[0].url, "https://sashiko.example.com");
311 assert!(!config.keybindings.bindings.is_empty());
313 }
314}