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)]
18pub struct RuntimoConfig {
19    /// Additional allowed path prefixes (merged with defaults + env var).
20    #[serde(default)]
21    pub allowed_paths: Vec<String>,
22}
23
24impl RuntimoConfig {
25    /// Returns the config file path following XDG spec.
26    ///
27    /// Uses `XDG_CONFIG_HOME` if set, otherwise `~/.config/runtimo/config.toml`.
28    pub fn config_path() -> PathBuf {
29        std::env::var("XDG_CONFIG_HOME")
30            .ok()
31            .map(PathBuf::from)
32            .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".config")))
33            .unwrap_or_else(|| PathBuf::from("/tmp"))
34            .join("runtimo/config.toml")
35    }
36
37    /// Loads config from disk, returning defaults if the file doesn't exist or is invalid.
38    pub fn load() -> Self {
39        let path = Self::config_path();
40        if path.exists() {
41            let content = std::fs::read_to_string(&path).unwrap_or_default();
42            toml::from_str(&content).unwrap_or_default()
43        } else {
44            Self::default()
45        }
46    }
47
48    /// Saves config to disk, creating parent directories as needed.
49    pub fn save(&self) -> Result<(), String> {
50        let path = Self::config_path();
51        if let Some(parent) = path.parent() {
52            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
53        }
54        let content = toml::to_string_pretty(self).map_err(|e| e.to_string())?;
55        std::fs::write(&path, content).map_err(|e| e.to_string())?;
56        Ok(())
57    }
58
59    /// Returns merged prefixes: defaults + env var + config file.
60    ///
61    /// Priority (lowest to highest):
62    /// 1. Built-in defaults
63    /// 2. `RUNTIMO_ALLOWED_PATHS` env var
64    /// 3. Config file `allowed_paths`
65    pub fn get_allowed_prefixes() -> Vec<String> {
66        let mut prefixes: Vec<String> = DEFAULT_PREFIXES.iter().map(|s| s.to_string()).collect();
67
68        // Env var (colon-separated)
69        if let Ok(env_paths) = std::env::var("RUNTIMO_ALLOWED_PATHS") {
70            for p in env_paths.split(':').filter(|s| !s.is_empty()) {
71                let trimmed = p.trim().to_string();
72                if !prefixes.contains(&trimmed) {
73                    prefixes.push(trimmed);
74                }
75            }
76        }
77
78        // Config file
79        let config = Self::load();
80        for p in &config.allowed_paths {
81            let trimmed = p.trim().to_string();
82            if !prefixes.contains(&trimmed) {
83                prefixes.push(trimmed);
84            }
85        }
86
87        prefixes
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn config_path_is_absolute() {
97        let path = RuntimoConfig::config_path();
98        assert!(path.is_absolute() || path.to_string_lossy().starts_with("/tmp"));
99    }
100
101    #[test]
102    fn load_returns_defaults_when_no_file() {
103        // If no config file exists, load returns empty allowed_paths
104        let config = RuntimoConfig::load();
105        // allowed_paths should be empty (defaults are added by get_allowed_prefixes)
106        assert!(config.allowed_paths.is_empty());
107    }
108
109    #[test]
110    fn get_allowed_prefixes_includes_defaults() {
111        let prefixes = RuntimoConfig::get_allowed_prefixes();
112        assert!(prefixes.iter().any(|p| p == "/tmp"));
113        assert!(prefixes.iter().any(|p| p == "/var/tmp"));
114        assert!(prefixes.iter().any(|p| p == "/home"));
115    }
116
117    #[test]
118    fn save_and_load_roundtrip() {
119        // Use a temp config path for this test
120        let tmp = std::env::temp_dir().join("runtimo_test_config");
121        std::env::set_var("XDG_CONFIG_HOME", &tmp);
122
123        let mut config = RuntimoConfig::default();
124        config.allowed_paths.push("/srv".to_string());
125        config.allowed_paths.push("/opt".to_string());
126        config.save().expect("save failed");
127
128        let loaded = RuntimoConfig::load();
129        assert_eq!(loaded.allowed_paths, vec!["/srv", "/opt"]);
130
131        let prefixes = RuntimoConfig::get_allowed_prefixes();
132        assert!(prefixes.contains(&"/srv".to_string()));
133        assert!(prefixes.contains(&"/opt".to_string()));
134
135        // Cleanup
136        let _ = std::fs::remove_dir_all(&tmp);
137        std::env::remove_var("XDG_CONFIG_HOME");
138    }
139}