Skip to main content

mvm_core/
user_config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5/// Persistent operator configuration stored at `~/.mvm/config.toml`.
6///
7/// CLI flags always take precedence over these values. This config is
8/// `mvmctl`-specific; `mvmd` maintains its own separate config.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(default)]
11pub struct MvmConfig {
12    /// vCPUs allocated to the Lima VM (default: 8)
13    pub lima_cpus: u32,
14    /// Memory in GiB allocated to the Lima VM (default: 16)
15    pub lima_mem_gib: u32,
16    /// Default vCPU count for `mvmctl run` (default: 2)
17    pub default_cpus: u32,
18    /// Default memory in MiB for `mvmctl run` (default: 512)
19    pub default_memory_mib: u32,
20    /// Log format: "human" or "json". None means human.
21    pub log_format: Option<String>,
22    /// Port for the Prometheus metrics endpoint. None means disabled.
23    pub metrics_port: Option<u16>,
24    /// URL for remote image catalog. None means use bundled catalog only.
25    pub catalog_url: Option<String>,
26}
27
28impl Default for MvmConfig {
29    fn default() -> Self {
30        Self {
31            lima_cpus: 8,
32            lima_mem_gib: 16,
33            default_cpus: 2,
34            default_memory_mib: 512,
35            log_format: None,
36            metrics_port: None,
37            catalog_url: None,
38        }
39    }
40}
41
42/// Resolve the config directory.
43///
44/// Uses `mvm_config_dir()` (XDG-compliant) by default, or `override_dir` for tests.
45/// Falls back to `~/.mvm/` if an existing config lives there (migration compat).
46fn config_dir(override_dir: Option<&Path>) -> PathBuf {
47    if let Some(d) = override_dir {
48        return d.to_path_buf();
49    }
50
51    // Check XDG location first
52    let xdg_dir = PathBuf::from(crate::config::mvm_config_dir());
53    if xdg_dir.join("config.toml").exists() {
54        return xdg_dir;
55    }
56
57    // Fall back to legacy ~/.mvm/ if config exists there
58    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
59    let legacy_dir = PathBuf::from(&home).join(".mvm");
60    if legacy_dir.join("config.toml").exists() {
61        return legacy_dir;
62    }
63
64    // New installs use XDG
65    xdg_dir
66}
67
68fn config_path(dir: &Path) -> PathBuf {
69    dir.join("config.toml")
70}
71
72/// Load `MvmConfig` from `~/.mvm/config.toml` (or `override_dir/config.toml` in tests).
73///
74/// If the file does not exist, it is created with defaults. If it cannot be
75/// parsed, defaults are returned with a warning.
76pub fn load(override_dir: Option<&Path>) -> MvmConfig {
77    let dir = config_dir(override_dir);
78    let path = config_path(&dir);
79
80    if !path.exists() {
81        let cfg = MvmConfig::default();
82        if let Err(e) = save(&cfg, override_dir) {
83            tracing::warn!("could not write default config to {}: {e}", path.display());
84        }
85        return cfg;
86    }
87
88    match std::fs::read_to_string(&path) {
89        Ok(text) => match toml::from_str::<MvmConfig>(&text) {
90            Ok(cfg) => cfg,
91            Err(e) => {
92                tracing::warn!("Failed to parse {}: {e}. Using defaults.", path.display());
93                MvmConfig::default()
94            }
95        },
96        Err(e) => {
97            tracing::warn!("Failed to read {}: {e}. Using defaults.", path.display());
98            MvmConfig::default()
99        }
100    }
101}
102
103/// Save `MvmConfig` to `~/.mvm/config.toml` (or `override_dir/config.toml` in tests).
104pub fn save(cfg: &MvmConfig, override_dir: Option<&Path>) -> Result<()> {
105    let dir = config_dir(override_dir);
106    std::fs::create_dir_all(&dir)
107        .with_context(|| format!("Failed to create config directory: {}", dir.display()))?;
108    let path = config_path(&dir);
109    let text = toml::to_string_pretty(cfg).context("Failed to serialize config")?;
110    std::fs::write(&path, text)
111        .with_context(|| format!("Failed to write config to {}", path.display()))
112}
113
114/// Update a single named field in `cfg` from a string value.
115///
116/// Returns `Err` for unknown keys or unparseable values.
117pub fn set_key(cfg: &mut MvmConfig, key: &str, value: &str) -> Result<()> {
118    match key {
119        "lima_cpus" => {
120            cfg.lima_cpus = value.parse().with_context(|| {
121                format!("lima_cpus must be a positive integer, got {:?}", value)
122            })?;
123        }
124        "lima_mem_gib" => {
125            cfg.lima_mem_gib = value.parse().with_context(|| {
126                format!("lima_mem_gib must be a positive integer, got {:?}", value)
127            })?;
128        }
129        "default_cpus" => {
130            cfg.default_cpus = value.parse().with_context(|| {
131                format!("default_cpus must be a positive integer, got {:?}", value)
132            })?;
133        }
134        "default_memory_mib" => {
135            cfg.default_memory_mib = value.parse().with_context(|| {
136                format!(
137                    "default_memory_mib must be a positive integer, got {:?}",
138                    value
139                )
140            })?;
141        }
142        "log_format" => {
143            cfg.log_format = if value == "none" || value.is_empty() {
144                None
145            } else {
146                Some(value.to_string())
147            };
148        }
149        "metrics_port" => {
150            cfg.metrics_port = if value == "none" || value == "0" || value.is_empty() {
151                None
152            } else {
153                Some(value.parse().with_context(|| {
154                    format!(
155                        "metrics_port must be a port number (0-65535), got {:?}",
156                        value
157                    )
158                })?)
159            };
160        }
161        "catalog_url" => {
162            cfg.catalog_url = if value == "none" || value.is_empty() {
163                None
164            } else {
165                Some(value.to_string())
166            };
167        }
168        other => {
169            anyhow::bail!(
170                "Unknown config key {:?}. Valid keys: lima_cpus, lima_mem_gib, \
171                 default_cpus, default_memory_mib, log_format, metrics_port, catalog_url",
172                other
173            );
174        }
175    }
176    Ok(())
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_default_values() {
185        let cfg = MvmConfig::default();
186        assert_eq!(cfg.lima_cpus, 8);
187        assert_eq!(cfg.lima_mem_gib, 16);
188        assert_eq!(cfg.default_cpus, 2);
189        assert_eq!(cfg.default_memory_mib, 512);
190        assert!(cfg.log_format.is_none());
191        assert!(cfg.metrics_port.is_none());
192    }
193
194    #[test]
195    fn test_toml_roundtrip() {
196        let cfg = MvmConfig {
197            lima_cpus: 4,
198            metrics_port: Some(9091),
199            ..MvmConfig::default()
200        };
201
202        let text = toml::to_string_pretty(&cfg).unwrap();
203        let parsed: MvmConfig = toml::from_str(&text).unwrap();
204        assert_eq!(parsed.lima_cpus, 4);
205        assert_eq!(parsed.metrics_port, Some(9091));
206        assert_eq!(parsed.lima_mem_gib, 16);
207    }
208
209    #[test]
210    fn test_load_from_empty_dir_returns_defaults_and_creates_file() {
211        let tmp = tempfile::tempdir().unwrap();
212        let cfg = load(Some(tmp.path()));
213        assert_eq!(cfg.lima_cpus, 8);
214        // File should have been created
215        assert!(tmp.path().join("config.toml").exists());
216    }
217
218    #[test]
219    fn test_save_and_load_roundtrip() {
220        let tmp = tempfile::tempdir().unwrap();
221        let cfg = MvmConfig {
222            lima_cpus: 6,
223            default_memory_mib: 1024,
224            ..MvmConfig::default()
225        };
226        save(&cfg, Some(tmp.path())).unwrap();
227
228        let loaded = load(Some(tmp.path()));
229        assert_eq!(loaded.lima_cpus, 6);
230        assert_eq!(loaded.default_memory_mib, 1024);
231    }
232
233    #[test]
234    fn test_set_key_known_key() {
235        let mut cfg = MvmConfig::default();
236        set_key(&mut cfg, "lima_cpus", "4").unwrap();
237        assert_eq!(cfg.lima_cpus, 4);
238    }
239
240    #[test]
241    fn test_set_key_unknown_key_error() {
242        let mut cfg = MvmConfig::default();
243        let err = set_key(&mut cfg, "not_a_key", "5").unwrap_err();
244        assert!(err.to_string().contains("Unknown config key"));
245        assert!(err.to_string().contains("lima_cpus"));
246    }
247
248    #[test]
249    fn test_set_key_catalog_url() {
250        let mut cfg = MvmConfig::default();
251        set_key(&mut cfg, "catalog_url", "https://example.com/catalog.json").unwrap();
252        assert_eq!(
253            cfg.catalog_url.as_deref(),
254            Some("https://example.com/catalog.json")
255        );
256    }
257
258    #[test]
259    fn test_set_key_catalog_url_none() {
260        let mut cfg = MvmConfig {
261            catalog_url: Some("https://example.com".to_string()),
262            ..MvmConfig::default()
263        };
264        set_key(&mut cfg, "catalog_url", "none").unwrap();
265        assert!(cfg.catalog_url.is_none());
266    }
267
268    #[test]
269    fn test_catalog_url_default_none() {
270        let cfg = MvmConfig::default();
271        assert!(cfg.catalog_url.is_none());
272    }
273
274    #[test]
275    fn test_set_key_invalid_value_error() {
276        let mut cfg = MvmConfig::default();
277        let err = set_key(&mut cfg, "lima_cpus", "not-a-number").unwrap_err();
278        assert!(err.to_string().contains("integer"));
279    }
280}