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    /// # Panics
31    ///
32    /// Panics if neither `XDG_CONFIG_HOME` nor `HOME` is set.
33    #[allow(clippy::expect_used)]
34    pub fn config_path() -> PathBuf {
35        std::env::var("XDG_CONFIG_HOME")
36            .ok()
37            .map(PathBuf::from)
38            .or_else(|| {
39                std::env::var("HOME")
40                    .ok()
41                    .map(|h| PathBuf::from(h).join(".config"))
42            })
43            .expect("Cannot determine config path: set XDG_CONFIG_HOME or HOME")
44            .join("runtimo/config.toml")
45    }
46
47    /// Loads config from disk, returning defaults if the file doesn't exist or is invalid.
48    #[must_use]
49    pub fn load() -> Self {
50        let path = Self::config_path();
51        if path.exists() {
52            let content = std::fs::read_to_string(&path).unwrap_or_default();
53            toml::from_str(&content).unwrap_or_default()
54        } else {
55            Self::default()
56        }
57    }
58
59    /// Saves config to disk, creating parent directories as needed.
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if parent directories cannot be created or if the config
64    /// file cannot be serialized/written to disk.
65    pub fn save(&self) -> Result<(), String> {
66        let path = Self::config_path();
67        if let Some(parent) = path.parent() {
68            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
69        }
70        let content = toml::to_string_pretty(self).map_err(|e| e.to_string())?;
71        std::fs::write(&path, content).map_err(|e| e.to_string())?;
72        Ok(())
73    }
74
75    /// Returns merged prefixes: defaults + env var + config file.
76    ///
77    /// Priority (lowest to highest):
78    /// 1. Built-in defaults
79    /// 2. `RUNTIMO_ALLOWED_PATHS` env var
80    /// 3. Config file `allowed_paths`
81    #[must_use]
82    pub fn get_allowed_prefixes() -> Vec<String> {
83        let mut prefixes: Vec<String> = DEFAULT_PREFIXES.iter().map(|s| s.to_string()).collect();
84
85        // Env var (colon-separated)
86        if let Ok(env_paths) = std::env::var("RUNTIMO_ALLOWED_PATHS") {
87            for p in env_paths.split(':').filter(|s| !s.is_empty()) {
88                let trimmed = p.trim().to_string();
89                if !prefixes.contains(&trimmed) {
90                    prefixes.push(trimmed);
91                }
92            }
93        }
94
95        // Config file
96        let config = Self::load();
97        for p in &config.allowed_paths {
98            let trimmed = p.trim().to_string();
99            if !prefixes.contains(&trimmed) {
100                prefixes.push(trimmed);
101            }
102        }
103
104        prefixes
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn config_path_is_absolute() {
114        let path = RuntimoConfig::config_path();
115        assert!(path.is_absolute());
116    }
117
118    #[test]
119    fn load_returns_defaults_when_no_file() {
120        let tmp = std::env::temp_dir().join("runtimo_test_config_defaults");
121        let _ = std::fs::remove_dir_all(&tmp);
122        std::env::set_var("XDG_CONFIG_HOME", &tmp);
123
124        let config = RuntimoConfig::load();
125        assert!(config.allowed_paths.is_empty());
126
127        let _ = std::fs::remove_dir_all(&tmp);
128        std::env::remove_var("XDG_CONFIG_HOME");
129    }
130
131    #[test]
132    fn get_allowed_prefixes_includes_defaults() {
133        let prefixes = RuntimoConfig::get_allowed_prefixes();
134        assert!(prefixes.iter().any(|p| p == "/tmp"));
135        assert!(prefixes.iter().any(|p| p == "/var/tmp"));
136        assert!(prefixes.iter().any(|p| p == "/home"));
137    }
138
139    #[test]
140    fn save_and_load_roundtrip() {
141        // Use a temp config path for this test
142        let tmp = std::env::temp_dir().join("runtimo_test_config");
143        std::env::set_var("XDG_CONFIG_HOME", &tmp);
144
145        let mut config = RuntimoConfig::default();
146        config.allowed_paths.push("/srv".to_string());
147        config.allowed_paths.push("/opt".to_string());
148        config.save().expect("save failed");
149
150        let loaded = RuntimoConfig::load();
151        assert_eq!(loaded.allowed_paths, vec!["/srv", "/opt"]);
152
153        let prefixes = RuntimoConfig::get_allowed_prefixes();
154        assert!(prefixes.contains(&"/srv".to_string()));
155        assert!(prefixes.contains(&"/opt".to_string()));
156
157        // Cleanup
158        let _ = std::fs::remove_dir_all(&tmp);
159        std::env::remove_var("XDG_CONFIG_HOME");
160    }
161}