Skip to main content

mvm_cli/
config_watcher.rs

1use 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
9/// Events sent from the background watcher thread.
10pub enum ConfigReloadEvent {
11    Reloaded(MvmConfig),
12    ParseError(String),
13}
14
15/// Watches a config file for changes and sends reload events on a channel.
16///
17/// Changes are debounced by 500 ms (via `notify-debouncer-mini`) to avoid
18/// reacting to partial writes or rapid saves.  Drop this struct to stop
19/// watching — the background thread exits when it detects the receiver
20/// has been dropped.
21pub struct ConfigWatcher {
22    /// Receive `ConfigReloadEvent`s from the background thread.
23    pub receiver: mpsc::Receiver<ConfigReloadEvent>,
24}
25
26impl ConfigWatcher {
27    /// Start watching `path`.  Returns immediately; the debouncer runs on a
28    /// background thread managed by `notify`.
29    pub fn start(path: &Path) -> Result<Self> {
30        Self::start_with_debounce(path, Duration::from_millis(500))
31    }
32
33    /// Like [`start`] but with a configurable debounce duration.  Useful in
34    /// tests where a shorter debounce keeps suites fast.
35    pub fn start_with_debounce(path: &Path, debounce: Duration) -> Result<Self> {
36        // Canonicalize so that event.path comparisons work reliably.
37        let watch_file = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
38        // Watch the parent directory — notify is most reliable when watching dirs.
39        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        // Spawn a thread that translates raw events → ConfigReloadEvent.
53        // The debouncer is moved into the thread to keep the OS watch alive.
54        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                            // Filter: only react to changes on the config file itself.
64                            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                                // Receiver was dropped — stop watching.
80                                return;
81                            }
82                        }
83                    }
84                    Ok(Err(e)) => {
85                        tracing::warn!("config watcher error: {e}");
86                    }
87                    Err(_) => {
88                        // raw_rx channel closed — stop the thread.
89                        return;
90                    }
91                }
92            }
93        });
94
95        Ok(ConfigWatcher { receiver: event_rx })
96    }
97}
98
99/// Drain any pending reload events and apply them to `cfg`.
100///
101/// Logs each reload or parse error via tracing.  Returns the (possibly
102/// updated) config.
103pub 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        // Give the watcher time to register before writing.
139        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        // Wait up to 2 s for the reload event.
148        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        // Overwrite with invalid TOML.
183        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                    // Race with earlier write — keep waiting.
195                }
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        // Config unchanged after parse error.
235        assert_eq!(cfg.lima_cpus, 6);
236    }
237}