Skip to main content

remendo/config/
mod.rs

1//! Application configuration subsystem.
2//!
3//! Loads user preferences from a TOML file at XDG-compliant paths,
4//! provides sensible compiled-in defaults when no file exists, and
5//! exposes typed configuration to all consumer subsystems.
6
7pub 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/// Top-level application configuration.
24///
25/// All fields use `#[serde(default)]` so partial TOML files work —
26/// users only override what they want.
27///
28/// # Examples
29///
30/// ```
31/// use remendo::config::Config;
32///
33/// // Default config has no remotes, default cache, default keybindings
34/// let config = Config::default();
35/// assert!(config.remotes.is_empty());
36/// assert_eq!(config.cache.ttl_seconds, 300);
37/// ```
38#[derive(Debug, Clone, Default, Deserialize)]
39#[serde(default)]
40pub struct Config {
41    /// Editor command for viewing raw review logs.
42    /// Falls back to `$VISUAL`, then `$EDITOR`, then `"vi"`.
43    pub editor: Option<String>,
44    /// Cache behaviour settings.
45    pub cache: CacheConfig,
46    /// Configured remote Sashiko instances.
47    pub remotes: Vec<RemoteConfig>,
48    /// Keybinding mappings.
49    pub keybindings: KeybindingsConfig,
50    /// Color theme.
51    pub theme: ThemeConfig,
52}
53
54impl Config {
55    /// Load configuration from the XDG config path.
56    ///
57    /// Returns the loaded config and any non-fatal warnings.
58    /// If no config file exists, returns compiled-in defaults
59    /// with a [`ConfigWarning::NoFile`] warning.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`ConfigError`] on I/O or TOML parse failures.
64    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        // Validate remote URLs
77        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    /// Load configuration from a TOML string (for testing).
92    ///
93    /// # Errors
94    ///
95    /// Returns [`ConfigError::Parse`] on deserialization failure.
96    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    /// Resolve the editor command using the priority chain:
102    /// config file > `$VISUAL` > `$EDITOR` > `"vi"`.
103    #[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        // This test assumes no config file exists at the XDG path
129        // during CI. If it does, the test still passes (it just
130        // exercises the load-from-file path instead).
131        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        // Defaults fill the rest
147        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        // In CI, $VISUAL and $EDITOR may or may not be set,
206        // so we just verify the function doesn't panic.
207        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        // Config::load() should never return Err — it falls back to
251        // defaults when no file is found, and parses when one exists.
252        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 a config file exists at the XDG path, the NoFile warning
262        // must not be emitted.
263        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        // If no config file exists, we should get a NoFile warning.
275        // This test is environment-dependent — if a config file does
276        // exist, it validates the other path instead.
277        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        // Regardless of whether a file exists, keybindings should
293        // have the default set (17 bindings).
294        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        // Keybindings should still have defaults even when not in TOML
312        assert!(!config.keybindings.bindings.is_empty());
313    }
314}