Skip to main content

mvm_cli/
watch.rs

1use std::path::{Path, PathBuf};
2use std::sync::mpsc;
3use std::time::Duration;
4
5use anyhow::Result;
6use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};
7
8/// Wait for filesystem changes in the given flake directory.
9///
10/// Watches `flake.nix`, `flake.lock`, and all `.nix` files recursively.
11/// Returns the path of the file that triggered the change, or None on error/timeout.
12///
13/// Uses the `notify` crate for native filesystem events (FSEvents on macOS,
14/// inotify on Linux) instead of polling. Changes are debounced by 500ms to
15/// avoid redundant rebuilds from rapid file saves.
16pub fn wait_for_changes(flake_dir: &str) -> Result<PathBuf> {
17    let flake_path = Path::new(flake_dir).canonicalize()?;
18
19    let (tx, rx) = mpsc::channel();
20
21    let mut debouncer = new_debouncer(Duration::from_millis(500), tx)?;
22
23    // Watch the flake directory recursively for .nix and .lock changes
24    debouncer
25        .watcher()
26        .watch(&flake_path, notify::RecursiveMode::Recursive)?;
27
28    // Wait for a relevant change
29    loop {
30        match rx.recv() {
31            Ok(Ok(events)) => {
32                for event in &events {
33                    if event.kind == DebouncedEventKind::Any && is_nix_file(&event.path) {
34                        return Ok(event.path.clone());
35                    }
36                }
37            }
38            Ok(Err(e)) => {
39                tracing::warn!("watch error: {e}");
40            }
41            Err(e) => {
42                anyhow::bail!("watch channel closed: {e}");
43            }
44        }
45    }
46}
47
48/// Check if a path is a Nix-related file we care about.
49fn is_nix_file(path: &Path) -> bool {
50    let Some(ext) = path.extension() else {
51        // flake.lock has no extension — check by filename
52        return path
53            .file_name()
54            .is_some_and(|n| n == "flake.lock" || n == "flake.nix");
55    };
56
57    ext == "nix" || ext == "lock"
58}
59
60/// Format a trigger path for display, relative to the flake directory if possible.
61pub fn display_trigger(trigger: &Path, flake_dir: &str) -> String {
62    let flake_path = Path::new(flake_dir).canonicalize().ok();
63    if let Some(base) = flake_path
64        && let Ok(rel) = trigger.strip_prefix(&base)
65    {
66        return rel.display().to_string();
67    }
68    trigger
69        .file_name()
70        .map(|n| n.to_string_lossy().to_string())
71        .unwrap_or_else(|| trigger.display().to_string())
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn is_nix_file_flake_nix() {
80        assert!(is_nix_file(Path::new("/foo/flake.nix")));
81    }
82
83    #[test]
84    fn is_nix_file_flake_lock() {
85        assert!(is_nix_file(Path::new("/foo/flake.lock")));
86    }
87
88    #[test]
89    fn is_nix_file_module() {
90        assert!(is_nix_file(Path::new("/foo/bar/minimal-init.nix")));
91    }
92
93    #[test]
94    fn is_nix_file_rejects_rust() {
95        assert!(!is_nix_file(Path::new("/foo/main.rs")));
96    }
97
98    #[test]
99    fn is_nix_file_rejects_random() {
100        assert!(!is_nix_file(Path::new("/foo/README.md")));
101    }
102
103    #[test]
104    fn is_nix_file_lock_extension() {
105        assert!(is_nix_file(Path::new("/foo/something.lock")));
106    }
107
108    #[test]
109    fn display_trigger_relative() {
110        // Create a temp dir to get a real canonicalized path
111        let dir = tempfile::tempdir().expect("temp dir");
112        let nix_file = dir.path().join("flake.nix");
113        std::fs::write(&nix_file, "").expect("write");
114
115        let result = display_trigger(&nix_file, dir.path().to_str().expect("utf8"));
116        assert_eq!(result, "flake.nix");
117    }
118
119    #[test]
120    fn display_trigger_fallback() {
121        let result = display_trigger(Path::new("/nonexistent/dir/foo.nix"), "/nonexistent/other");
122        assert_eq!(result, "foo.nix");
123    }
124}