1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(default)]
11pub struct MvmConfig {
12 pub lima_cpus: u32,
14 pub lima_mem_gib: u32,
16 pub default_cpus: u32,
18 pub default_memory_mib: u32,
20 pub log_format: Option<String>,
22 pub metrics_port: Option<u16>,
24 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
42fn config_dir(override_dir: Option<&Path>) -> PathBuf {
47 if let Some(d) = override_dir {
48 return d.to_path_buf();
49 }
50
51 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 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 xdg_dir
66}
67
68fn config_path(dir: &Path) -> PathBuf {
69 dir.join("config.toml")
70}
71
72pub 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
103pub 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
114pub 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 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}