Skip to main content

runtimo_core/
config.rs

1//! Persistent configuration for Runtimo.
2//!
3//! Reads/writes a TOML config file at `~/.config/runtimo/config.toml`.
4//! Allowed path prefixes are merged from three sources (lowest to highest priority):
5//! 1. Built-in defaults (`/tmp`, `/var/tmp`, `/home`)
6//! 2. `RUNTIMO_ALLOWED_PATHS` env var (colon-separated)
7//! 3. Config file `allowed_paths` array
8//! 4. Context-specific prefixes (programmatic override)
9
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12
13/// Built-in default allowed prefixes.
14const DEFAULT_PREFIXES: &[&str] = &["/tmp", "/var/tmp", "/home"];
15
16/// Runtimo persistent configuration.
17#[derive(Debug, Clone, Serialize, Deserialize, Default)]
18#[allow(clippy::exhaustive_structs)]
19pub struct RuntimoConfig {
20    /// Additional allowed path prefixes (merged with defaults + env var).
21    #[serde(default)]
22    pub allowed_paths: Vec<String>,
23}
24
25impl RuntimoConfig {
26    /// Returns the config file path following XDG spec.
27    ///
28    /// Uses `XDG_CONFIG_HOME` if set, otherwise `~/.config/runtimo/config.toml`.
29    ///
30    /// Falls back to `/tmp/runtimo/config.toml` with a stderr warning when
31    /// neither `XDG_CONFIG_HOME` nor `HOME` is set. Configuration in `/tmp`
32    /// is not persistent across reboots.
33    pub fn config_path() -> PathBuf {
34        let base = std::env::var("XDG_CONFIG_HOME")
35            .ok()
36            .map(PathBuf::from)
37            .or_else(|| {
38                std::env::var("HOME")
39                    .ok()
40                    .map(|h| PathBuf::from(h).join(".config"))
41            });
42        if let Some(dir) = base {
43            dir.join("runtimo/config.toml")
44        } else {
45            eprintln!(
46                "[runtimo] Warning: XDG_CONFIG_HOME and HOME unset — using /tmp/runtimo \
47                 (config will not survive reboot)"
48            );
49            PathBuf::from("/tmp/runtimo/config.toml")
50        }
51    }
52
53    /// Loads config from disk, returning defaults if the file doesn't exist or is invalid.
54    ///
55    /// Logs a warning to stderr when the file exists but cannot be read or parsed.
56    /// Prefer [`load_result`] for new code — it propagates errors so callers can
57    /// distinguish "file doesn't exist" from "file is corrupt."
58    #[must_use]
59    pub fn load() -> Self {
60        match Self::load_result() {
61            Ok(config) => config,
62            Err(e) => {
63                eprintln!("[runtimo] Config load failed (using defaults): {}", e);
64                Self::default()
65            }
66        }
67    }
68
69    /// Loads config from disk, propagating read and parse errors.
70    ///
71    /// # Input
72    ///
73    /// Reads from the path returned by [`config_path`] if it exists.
74    ///
75    /// # Output
76    ///
77    /// `Ok(RuntimoConfig)` — Successfully deserialized config, or default if file doesn't exist.
78    ///
79    /// # Errors
80    ///
81    /// Returns `Err(String)` when the config file:
82    /// - Exists but cannot be opened (permission denied, filesystem error)
83    /// - Can be opened but contains invalid TOML syntax
84    /// - Contains TOML that deserializes to a different type (schema mismatch)
85    ///
86    /// Returns `Ok(Self::default())` when:
87    /// - The config file does not exist (first run / clean install)
88    /// - The config file is empty (no config needed)
89    pub fn load_result() -> Result<Self, String> {
90        let path = Self::config_path();
91        if path.exists() {
92            let content = std::fs::read_to_string(&path)
93                .map_err(|e| format!("Cannot read config file '{}': {}", path.display(), e))?;
94            toml::from_str(&content)
95                .map_err(|e| format!("Cannot parse config file '{}': {}", path.display(), e))
96        } else {
97            Ok(Self::default())
98        }
99    }
100
101    /// Saves config to disk, creating parent directories as needed.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if parent directories cannot be created or if the config
106    /// file cannot be serialized/written to disk.
107    pub fn save(&self) -> Result<(), String> {
108        let path = Self::config_path();
109        if let Some(parent) = path.parent() {
110            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
111        }
112        let content = toml::to_string_pretty(self).map_err(|e| e.to_string())?;
113        std::fs::write(&path, content).map_err(|e| e.to_string())?;
114        Ok(())
115    }
116
117    /// Returns merged prefixes: defaults + env var + config file.
118    ///
119    /// Priority (lowest to highest):
120    /// 1. Built-in defaults
121    /// 2. `RUNTIMO_ALLOWED_PATHS` env var
122    /// 3. Config file `allowed_paths`
123    #[must_use]
124    pub fn get_allowed_prefixes() -> Vec<String> {
125        let mut prefixes: Vec<String> = DEFAULT_PREFIXES.iter().map(|s| s.to_string()).collect();
126
127        // Env var (colon-separated)
128        if let Ok(env_paths) = std::env::var("RUNTIMO_ALLOWED_PATHS") {
129            for p in env_paths.split(':').filter(|s| !s.is_empty()) {
130                let trimmed = p.trim().to_string();
131                if !prefixes.contains(&trimmed) {
132                    prefixes.push(trimmed);
133                }
134            }
135        }
136
137        // Config file
138        let config = Self::load();
139        for p in &config.allowed_paths {
140            let trimmed = p.trim().to_string();
141            if !prefixes.contains(&trimmed) {
142                prefixes.push(trimmed);
143            }
144        }
145
146        prefixes
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use std::sync::Mutex;
154
155    /// Mutex to serialize config tests that set XDG_CONFIG_HOME.
156    /// Without this, concurrent tests fight over the process-global env var.
157    static CONFIG_TEST_MUTEX: Mutex<()> = Mutex::new(());
158
159    #[test]
160    fn config_path_is_absolute() {
161        let path = RuntimoConfig::config_path();
162        assert!(path.is_absolute());
163    }
164
165    #[test]
166    fn load_returns_defaults_when_no_file() {
167        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
168        let tmp = std::env::temp_dir().join("runtimo_test_config_defaults");
169        let _ = std::fs::remove_dir_all(&tmp);
170        std::env::set_var("XDG_CONFIG_HOME", &tmp);
171
172        let config = RuntimoConfig::load();
173        assert!(config.allowed_paths.is_empty());
174
175        let _ = std::fs::remove_dir_all(&tmp);
176        std::env::remove_var("XDG_CONFIG_HOME");
177    }
178
179    #[test]
180    fn get_allowed_prefixes_includes_defaults() {
181        let prefixes = RuntimoConfig::get_allowed_prefixes();
182        assert!(prefixes.iter().any(|p| p == "/tmp"));
183        assert!(prefixes.iter().any(|p| p == "/var/tmp"));
184        assert!(prefixes.iter().any(|p| p == "/home"));
185    }
186
187    #[test]
188    fn save_and_load_roundtrip() {
189        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
190        // Use a temp config path for this test
191        let tmp = std::env::temp_dir().join("runtimo_test_config");
192        std::env::set_var("XDG_CONFIG_HOME", &tmp);
193
194        let mut config = RuntimoConfig::default();
195        config.allowed_paths.push("/srv".to_string());
196        config.allowed_paths.push("/opt".to_string());
197        config.save().expect("save failed");
198
199        let loaded = RuntimoConfig::load();
200        assert_eq!(loaded.allowed_paths, vec!["/srv", "/opt"]);
201
202        let prefixes = RuntimoConfig::get_allowed_prefixes();
203        assert!(prefixes.contains(&"/srv".to_string()));
204        assert!(prefixes.contains(&"/opt".to_string()));
205
206        // Cleanup
207        let _ = std::fs::remove_dir_all(&tmp);
208        std::env::remove_var("XDG_CONFIG_HOME");
209    }
210
211    #[test]
212    fn test_toml_parse_failure_returns_defaults() {
213        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
214        // GAP 12: Corrupt TOML file returns defaults, not panic
215        let tmp = std::env::temp_dir().join("runtimo_test_config_corrupt");
216        let config_dir = tmp.join("runtimo");
217        let _ = std::fs::remove_dir_all(&tmp);
218        std::fs::create_dir_all(&config_dir).unwrap();
219        let config_path = config_dir.join("config.toml");
220
221        // Write corrupt TOML
222        std::fs::write(&config_path, "this is {{{ not valid toml at all!!!").unwrap();
223        std::env::set_var("XDG_CONFIG_HOME", &tmp);
224
225        let config = RuntimoConfig::load();
226        // Must return defaults, not panic
227        assert!(
228            config.allowed_paths.is_empty(),
229            "Corrupt TOML should return defaults"
230        );
231
232        let _ = std::fs::remove_dir_all(&tmp);
233        std::env::remove_var("XDG_CONFIG_HOME");
234    }
235
236    #[test]
237    fn test_empty_config_file_returns_defaults() {
238        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
239        // GAP 12: Empty config file returns defaults
240        let tmp = std::env::temp_dir().join("runtimo_test_config_empty");
241        let config_dir = tmp.join("runtimo");
242        let _ = std::fs::remove_dir_all(&tmp);
243        std::fs::create_dir_all(&config_dir).unwrap();
244        let config_path = config_dir.join("config.toml");
245
246        // Write empty file
247        std::fs::write(&config_path, "").unwrap();
248        std::env::set_var("XDG_CONFIG_HOME", &tmp);
249
250        let config = RuntimoConfig::load();
251        assert!(
252            config.allowed_paths.is_empty(),
253            "Empty config should return defaults"
254        );
255
256        let _ = std::fs::remove_dir_all(&tmp);
257        std::env::remove_var("XDG_CONFIG_HOME");
258    }
259
260    #[test]
261    fn test_toml_missing_section_returns_defaults() {
262        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
263        // GAP 12: Valid TOML but missing expected section
264        let tmp = std::env::temp_dir().join("runtimo_test_config_missing");
265        let config_dir = tmp.join("runtimo");
266        let _ = std::fs::remove_dir_all(&tmp);
267        std::fs::create_dir_all(&config_dir).unwrap();
268        let config_path = config_dir.join("config.toml");
269
270        // Valid TOML but no allowed_paths array
271        std::fs::write(&config_path, "[other_section]\nfoo = \"bar\"\n").unwrap();
272        std::env::set_var("XDG_CONFIG_HOME", &tmp);
273
274        let config = RuntimoConfig::load();
275        assert!(
276            config.allowed_paths.is_empty(),
277            "Missing section should return defaults"
278        );
279
280        let _ = std::fs::remove_dir_all(&tmp);
281        std::env::remove_var("XDG_CONFIG_HOME");
282    }
283}