mvm_cli/
config_watcher.rs1use std::path::Path;
2use std::sync::mpsc;
3use std::time::Duration;
4
5use anyhow::Result;
6use mvm_core::user_config::MvmConfig;
7use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};
8
9pub enum ConfigReloadEvent {
11 Reloaded(MvmConfig),
12 ParseError(String),
13}
14
15pub struct ConfigWatcher {
22 pub receiver: mpsc::Receiver<ConfigReloadEvent>,
24}
25
26impl ConfigWatcher {
27 pub fn start(path: &Path) -> Result<Self> {
30 Self::start_with_debounce(path, Duration::from_millis(500))
31 }
32
33 pub fn start_with_debounce(path: &Path, debounce: Duration) -> Result<Self> {
36 let watch_file = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
38 let watch_dir = watch_file
40 .parent()
41 .ok_or_else(|| anyhow::anyhow!("config path has no parent directory"))?
42 .to_path_buf();
43
44 let (event_tx, event_rx) = mpsc::channel::<ConfigReloadEvent>();
45
46 let (raw_tx, raw_rx) = mpsc::channel();
47 let mut debouncer = new_debouncer(debounce, raw_tx)?;
48 debouncer
49 .watcher()
50 .watch(&watch_dir, notify::RecursiveMode::NonRecursive)?;
51
52 std::thread::spawn(move || {
55 let _debouncer = debouncer;
56 loop {
57 match raw_rx.recv() {
58 Ok(Ok(events)) => {
59 for event in &events {
60 if event.kind != DebouncedEventKind::Any {
61 continue;
62 }
63 let event_file = event
65 .path
66 .canonicalize()
67 .unwrap_or_else(|_| event.path.clone());
68 if event_file != watch_file {
69 continue;
70 }
71 let reload = match std::fs::read_to_string(&watch_file) {
72 Ok(text) => match toml::from_str::<MvmConfig>(&text) {
73 Ok(cfg) => ConfigReloadEvent::Reloaded(cfg),
74 Err(e) => ConfigReloadEvent::ParseError(e.to_string()),
75 },
76 Err(e) => ConfigReloadEvent::ParseError(e.to_string()),
77 };
78 if event_tx.send(reload).is_err() {
79 return;
81 }
82 }
83 }
84 Ok(Err(e)) => {
85 tracing::warn!("config watcher error: {e}");
86 }
87 Err(_) => {
88 return;
90 }
91 }
92 }
93 });
94
95 Ok(ConfigWatcher { receiver: event_rx })
96 }
97}
98
99pub fn apply_pending_reloads(cfg: MvmConfig, rx: &mpsc::Receiver<ConfigReloadEvent>) -> MvmConfig {
104 let mut current = cfg;
105 while let Ok(event) = rx.try_recv() {
106 match event {
107 ConfigReloadEvent::Reloaded(new_cfg) => {
108 tracing::info!("Config reloaded from ~/.mvm/config.toml");
109 current = new_cfg;
110 }
111 ConfigReloadEvent::ParseError(msg) => {
112 tracing::warn!("Config reload failed: {msg}; keeping previous config");
113 }
114 }
115 }
116 current
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use mvm_core::user_config::MvmConfig;
123
124 fn write_config(path: &Path, cfg: &MvmConfig) {
125 let text = toml::to_string_pretty(cfg).unwrap();
126 std::fs::write(path, text).unwrap();
127 }
128
129 #[test]
130 fn test_config_watcher_detects_change() {
131 let dir = tempfile::tempdir().unwrap();
132 let config_path = dir.path().join("config.toml");
133
134 write_config(&config_path, &MvmConfig::default());
135 let watcher =
136 ConfigWatcher::start_with_debounce(&config_path, Duration::from_millis(50)).unwrap();
137
138 std::thread::sleep(Duration::from_millis(50));
140
141 let updated = MvmConfig {
142 lima_cpus: 4,
143 ..MvmConfig::default()
144 };
145 write_config(&config_path, &updated);
146
147 let deadline = std::time::Instant::now() + Duration::from_secs(2);
149 let mut received = false;
150 while std::time::Instant::now() < deadline {
151 match watcher.receiver.try_recv() {
152 Ok(ConfigReloadEvent::Reloaded(cfg)) => {
153 assert_eq!(cfg.lima_cpus, 4);
154 received = true;
155 break;
156 }
157 Ok(ConfigReloadEvent::ParseError(e)) => {
158 panic!("Unexpected parse error: {e}");
159 }
160 Err(_) => {
161 std::thread::sleep(Duration::from_millis(50));
162 }
163 }
164 }
165 assert!(
166 received,
167 "No ConfigReloadEvent::Reloaded received within 2 s"
168 );
169 }
170
171 #[test]
172 fn test_config_watcher_invalid_toml_sends_parse_error() {
173 let dir = tempfile::tempdir().unwrap();
174 let config_path = dir.path().join("config.toml");
175
176 write_config(&config_path, &MvmConfig::default());
177 let watcher =
178 ConfigWatcher::start_with_debounce(&config_path, Duration::from_millis(50)).unwrap();
179
180 std::thread::sleep(Duration::from_millis(50));
181
182 std::fs::write(&config_path, b"this is [[ not valid toml").unwrap();
184
185 let deadline = std::time::Instant::now() + Duration::from_secs(2);
186 let mut received = false;
187 while std::time::Instant::now() < deadline {
188 match watcher.receiver.try_recv() {
189 Ok(ConfigReloadEvent::ParseError(_)) => {
190 received = true;
191 break;
192 }
193 Ok(ConfigReloadEvent::Reloaded(_)) => {
194 }
196 Err(_) => {
197 std::thread::sleep(Duration::from_millis(50));
198 }
199 }
200 }
201 assert!(
202 received,
203 "No ConfigReloadEvent::ParseError received within 2 s"
204 );
205 }
206
207 #[test]
208 fn test_apply_pending_reloads_updates_cfg() {
209 let (tx, rx) = mpsc::channel();
210 let mut cfg = MvmConfig::default();
211
212 let new_cfg = MvmConfig {
213 lima_cpus: 12,
214 ..MvmConfig::default()
215 };
216 tx.send(ConfigReloadEvent::Reloaded(new_cfg)).unwrap();
217
218 cfg = apply_pending_reloads(cfg, &rx);
219 assert_eq!(cfg.lima_cpus, 12);
220 }
221
222 #[test]
223 fn test_apply_pending_reloads_keeps_cfg_on_error() {
224 let (tx, rx) = mpsc::channel();
225 let mut cfg = MvmConfig {
226 lima_cpus: 6,
227 ..MvmConfig::default()
228 };
229
230 tx.send(ConfigReloadEvent::ParseError("bad toml".to_string()))
231 .unwrap();
232
233 cfg = apply_pending_reloads(cfg, &rx);
234 assert_eq!(cfg.lima_cpus, 6);
236 }
237}