Skip to main content

mermaid_cli/utils/
file_watcher.rs

1use anyhow::Result;
2use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
3use std::path::{Path, PathBuf};
4use std::sync::mpsc::{self, Receiver};
5
6/// Events that we care about for the file system
7#[derive(Debug, Clone)]
8pub enum FileEvent {
9    Created(Vec<PathBuf>),
10    Modified(Vec<PathBuf>),
11    Deleted(Vec<PathBuf>),
12}
13
14/// A file system watcher that monitors changes in a directory
15pub struct FileSystemWatcher {
16    _watcher: RecommendedWatcher,
17    rx: Receiver<Result<Event, notify::Error>>,
18}
19
20impl FileSystemWatcher {
21    /// Create a new file system watcher for the given path
22    pub fn new(path: &Path) -> Result<Self> {
23        let (tx, rx) = mpsc::channel();
24
25        let mut watcher = notify::recommended_watcher(move |event| {
26            let _ = tx.send(event);
27        })?;
28
29        // Watch the path recursively
30        watcher.watch(path, RecursiveMode::Recursive)?;
31
32        Ok(Self {
33            _watcher: watcher,
34            rx,
35        })
36    }
37
38    /// Check for any file system events (non-blocking)
39    pub fn check_events(&self) -> Vec<FileEvent> {
40        let mut events = Vec::new();
41
42        // Process all available events
43        while let Ok(Ok(event)) = self.rx.try_recv() {
44            match event.kind {
45                EventKind::Create(_) => {
46                    if !event.paths.is_empty() {
47                        events.push(FileEvent::Created(event.paths));
48                    }
49                },
50                EventKind::Modify(modify_kind) => {
51                    // Filter out metadata-only changes
52                    use notify::event::ModifyKind;
53                    match modify_kind {
54                        ModifyKind::Data(_) | ModifyKind::Any => {
55                            if !event.paths.is_empty() {
56                                events.push(FileEvent::Modified(event.paths));
57                            }
58                        },
59                        _ => {}, // Ignore metadata changes
60                    }
61                },
62                EventKind::Remove(_) => {
63                    if !event.paths.is_empty() {
64                        events.push(FileEvent::Deleted(event.paths));
65                    }
66                },
67                _ => {}, // Ignore other events
68            }
69        }
70
71        events
72    }
73
74    /// Check if a path should be ignored (e.g., hidden files, git files, etc.)
75    pub fn should_ignore_path(path: &Path) -> bool {
76        // Ignore hidden files and directories
77        if let Some(name) = path.file_name() {
78            if let Some(name_str) = name.to_str() {
79                if name_str.starts_with('.') {
80                    return true;
81                }
82            }
83        }
84
85        // Ignore common build/cache directories
86        if let Some(parent) = path.parent() {
87            if let Some(parent_name) = parent.file_name() {
88                if let Some(parent_str) = parent_name.to_str() {
89                    match parent_str {
90                        "target" | "node_modules" | "__pycache__" | ".git" | "dist" | "build"
91                        | ".venv" | "venv" => return true,
92                        _ => {},
93                    }
94                }
95            }
96        }
97
98        // Only watch text files and common code files
99        if let Some(ext) = path.extension() {
100            if let Some(ext_str) = ext.to_str() {
101                match ext_str {
102                    // Allow common text and code files
103                    "txt" | "md" | "rs" | "toml" | "yaml" | "yml" | "json" | "js" | "ts"
104                    | "jsx" | "tsx" | "py" | "go" | "java" | "c" | "cpp" | "h" | "hpp" | "sh"
105                    | "bash" | "zsh" | "fish" | "html" | "css" | "scss" | "xml" | "vue"
106                    | "svelte" => false,
107                    // Ignore everything else (binaries, images, etc.)
108                    _ => true,
109                }
110            } else {
111                true // Ignore files without valid UTF-8 extensions
112            }
113        } else {
114            false // Allow files without extensions (like LICENSE, README)
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use std::fs;
123    use tempfile::TempDir;
124
125    #[test]
126    fn test_should_ignore_path() {
127        assert!(FileSystemWatcher::should_ignore_path(Path::new(
128            ".gitignore"
129        )));
130        assert!(FileSystemWatcher::should_ignore_path(Path::new(
131            "node_modules/package.json"
132        )));
133        assert!(FileSystemWatcher::should_ignore_path(Path::new(
134            "image.png"
135        )));
136
137        assert!(!FileSystemWatcher::should_ignore_path(Path::new("main.rs")));
138        assert!(!FileSystemWatcher::should_ignore_path(Path::new(
139            "README.md"
140        )));
141        assert!(!FileSystemWatcher::should_ignore_path(Path::new(
142            "config.toml"
143        )));
144    }
145
146    #[tokio::test]
147    async fn test_file_watcher_events() {
148        let temp_dir = TempDir::new().unwrap();
149        let watcher = FileSystemWatcher::new(temp_dir.path()).unwrap();
150
151        // Create a file
152        let test_file = temp_dir.path().join("test.txt");
153        fs::write(&test_file, "hello").unwrap();
154
155        // Give the watcher time to detect the change
156        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
157
158        let events = watcher.check_events();
159        assert!(!events.is_empty(), "Should have detected file creation");
160    }
161}