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 [`Self::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 [`Self::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    ///
124    /// Empty strings are filtered out to prevent matching everything
125    /// via `format!("{}/", "")` which produces `"/"` (N-014).
126    #[must_use]
127    pub fn get_allowed_prefixes() -> Vec<String> {
128        let mut prefixes: Vec<String> = DEFAULT_PREFIXES.iter().map(|s| s.to_string()).collect();
129
130        // Env var (colon-separated)
131        if let Ok(env_paths) = std::env::var("RUNTIMO_ALLOWED_PATHS") {
132            for p in env_paths.split(':').filter(|s| !s.is_empty()) {
133                let trimmed = p.trim().to_string();
134                if trimmed.is_empty() {
135                    continue;
136                }
137                if !prefixes.contains(&trimmed) {
138                    prefixes.push(trimmed);
139                }
140            }
141        }
142
143        // Config file
144        let config = Self::load();
145        for p in &config.allowed_paths {
146            let trimmed = p.trim().to_string();
147            if trimmed.is_empty() {
148                continue;
149            }
150            if !prefixes.contains(&trimmed) {
151                prefixes.push(trimmed);
152            }
153        }
154
155        prefixes
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use std::sync::Mutex;
163
164    /// Mutex to serialize config tests that set XDG_CONFIG_HOME.
165    /// Without this, concurrent tests fight over the process-global env var.
166    static CONFIG_TEST_MUTEX: Mutex<()> = Mutex::new(());
167
168    #[test]
169    fn config_path_is_absolute() {
170        let path = RuntimoConfig::config_path();
171        assert!(path.is_absolute());
172    }
173
174    #[test]
175    fn load_returns_defaults_when_no_file() {
176        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
177        let tmp = std::env::temp_dir().join("runtimo_test_config_defaults");
178        let _ = std::fs::remove_dir_all(&tmp);
179        std::env::set_var("XDG_CONFIG_HOME", &tmp);
180
181        let config = RuntimoConfig::load();
182        assert!(config.allowed_paths.is_empty());
183
184        let _ = std::fs::remove_dir_all(&tmp);
185        std::env::remove_var("XDG_CONFIG_HOME");
186    }
187
188    #[test]
189    fn get_allowed_prefixes_includes_defaults() {
190        let prefixes = RuntimoConfig::get_allowed_prefixes();
191        assert!(prefixes.iter().any(|p| p == "/tmp"));
192        assert!(prefixes.iter().any(|p| p == "/var/tmp"));
193        assert!(prefixes.iter().any(|p| p == "/home"));
194    }
195
196    #[test]
197    fn save_and_load_roundtrip() {
198        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
199        // Use a temp config path for this test
200        let tmp = std::env::temp_dir().join("runtimo_test_config");
201        std::env::set_var("XDG_CONFIG_HOME", &tmp);
202
203        let mut config = RuntimoConfig::default();
204        config.allowed_paths.push("/srv".to_string());
205        config.allowed_paths.push("/opt".to_string());
206        config.save().expect("save failed");
207
208        let loaded = RuntimoConfig::load();
209        assert_eq!(loaded.allowed_paths, vec!["/srv", "/opt"]);
210
211        let prefixes = RuntimoConfig::get_allowed_prefixes();
212        assert!(prefixes.contains(&"/srv".to_string()));
213        assert!(prefixes.contains(&"/opt".to_string()));
214
215        // Cleanup
216        let _ = std::fs::remove_dir_all(&tmp);
217        std::env::remove_var("XDG_CONFIG_HOME");
218    }
219
220    #[test]
221    fn test_toml_parse_failure_returns_defaults() {
222        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
223        // GAP 12: Corrupt TOML file returns defaults, not panic
224        let tmp = std::env::temp_dir().join("runtimo_test_config_corrupt");
225        let config_dir = tmp.join("runtimo");
226        let _ = std::fs::remove_dir_all(&tmp);
227        std::fs::create_dir_all(&config_dir).unwrap();
228        let config_path = config_dir.join("config.toml");
229
230        // Write corrupt TOML
231        std::fs::write(&config_path, "this is {{{ not valid toml at all!!!").unwrap();
232        std::env::set_var("XDG_CONFIG_HOME", &tmp);
233
234        let config = RuntimoConfig::load();
235        // Must return defaults, not panic
236        assert!(
237            config.allowed_paths.is_empty(),
238            "Corrupt TOML should return defaults"
239        );
240
241        let _ = std::fs::remove_dir_all(&tmp);
242        std::env::remove_var("XDG_CONFIG_HOME");
243    }
244
245    #[test]
246    fn test_empty_config_file_returns_defaults() {
247        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
248        // GAP 12: Empty config file returns defaults
249        let tmp = std::env::temp_dir().join("runtimo_test_config_empty");
250        let config_dir = tmp.join("runtimo");
251        let _ = std::fs::remove_dir_all(&tmp);
252        std::fs::create_dir_all(&config_dir).unwrap();
253        let config_path = config_dir.join("config.toml");
254
255        // Write empty file
256        std::fs::write(&config_path, "").unwrap();
257        std::env::set_var("XDG_CONFIG_HOME", &tmp);
258
259        let config = RuntimoConfig::load();
260        assert!(
261            config.allowed_paths.is_empty(),
262            "Empty config should return defaults"
263        );
264
265        let _ = std::fs::remove_dir_all(&tmp);
266        std::env::remove_var("XDG_CONFIG_HOME");
267    }
268
269    #[test]
270    fn test_toml_missing_section_returns_defaults() {
271        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
272        // GAP 12: Valid TOML but missing expected section
273        let tmp = std::env::temp_dir().join("runtimo_test_config_missing");
274        let config_dir = tmp.join("runtimo");
275        let _ = std::fs::remove_dir_all(&tmp);
276        std::fs::create_dir_all(&config_dir).unwrap();
277        let config_path = config_dir.join("config.toml");
278
279        // Valid TOML but no allowed_paths array
280        std::fs::write(&config_path, "[other_section]\nfoo = \"bar\"\n").unwrap();
281        std::env::set_var("XDG_CONFIG_HOME", &tmp);
282
283        let config = RuntimoConfig::load();
284        assert!(
285            config.allowed_paths.is_empty(),
286            "Missing section should return defaults"
287        );
288
289        let _ = std::fs::remove_dir_all(&tmp);
290        std::env::remove_var("XDG_CONFIG_HOME");
291    }
292}