code_moniker_workspace/live/
mod.rs1mod model;
2mod roots;
3mod watcher;
4
5pub use model::{WorkspaceLiveEvent, WorkspaceLiveRefreshPlan, WorkspaceWatchRoot};
6#[cfg(test)]
7pub(crate) use roots::WorkspaceEventClassifier;
8pub(crate) use roots::watch_roots_for_paths;
9pub use watcher::LiveWorkspaceWatcher;
10
11#[cfg(test)]
12mod tests {
13 use std::path::PathBuf;
14 use std::sync::mpsc;
15 use std::time::Duration;
16
17 use super::{
18 LiveWorkspaceWatcher, WorkspaceEventClassifier, WorkspaceLiveEvent, WorkspaceWatchRoot,
19 watch_roots_for_paths,
20 };
21
22 #[test]
23 fn watcher_publishes_source_changes() {
24 let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
25 let source = temp.path().join("src").join("lib.rs");
26 std::fs::create_dir_all(source.parent().expect("source parent")).expect("src dir");
27 std::fs::write(&source, "pub fn before() {}\n").expect("seed source");
28 let (tx, rx) = mpsc::channel();
29 let _watcher = LiveWorkspaceWatcher::start_polling(
30 watch_roots_for_paths(&[temp.path().to_path_buf()], None),
31 move |event| {
32 let _ = tx.send(event);
33 },
34 )
35 .expect("watcher starts");
36 std::thread::sleep(Duration::from_millis(200));
37
38 std::fs::write(&source, "pub fn before() {}\npub fn after() {}\n").expect("modify source");
39
40 let event = rx
41 .recv_timeout(Duration::from_secs(3))
42 .expect("source change event");
43 assert!(
44 matches!(
45 event,
46 WorkspaceLiveEvent::SourcesChanged(_) | WorkspaceLiveEvent::RescanRequired
47 ),
48 "unexpected event: {event:?}"
49 );
50 }
51
52 #[test]
53 fn watcher_publishes_atomic_source_replaces() {
54 let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
55 let source = temp.path().join("src").join("lib.rs");
56 std::fs::create_dir_all(source.parent().expect("source parent")).expect("src dir");
57 std::fs::write(&source, "pub fn before() {}\n").expect("seed source");
58 let (tx, rx) = mpsc::channel();
59 let _watcher = LiveWorkspaceWatcher::start_polling(
60 watch_roots_for_paths(&[temp.path().to_path_buf()], None),
61 move |event| {
62 let _ = tx.send(event);
63 },
64 )
65 .expect("watcher starts");
66 std::thread::sleep(Duration::from_millis(200));
67
68 let replacement = source.with_extension("rs.tmp");
69 std::fs::write(&replacement, "pub fn before() {}\npub fn after() {}\n")
70 .expect("write replacement");
71 std::fs::rename(&replacement, &source).expect("replace source");
72
73 let event = rx
74 .recv_timeout(Duration::from_secs(3))
75 .expect("source replace event");
76 assert!(
77 matches!(
78 event,
79 WorkspaceLiveEvent::SourcesChanged(_) | WorkspaceLiveEvent::RescanRequired
80 ),
81 "unexpected event: {event:?}"
82 );
83 }
84
85 #[test]
86 fn classifies_source_changes_with_changed_paths() {
87 let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
88 path: PathBuf::from("/repo"),
89 git_root: None,
90 ignored_paths: Vec::new(),
91 notes_path: Some(PathBuf::from("/repo/.code-moniker/notes.toml")),
92 }]);
93
94 assert_eq!(
95 classifier.classify_paths_with_git_signals(&[PathBuf::from("/repo/src/lib.rs")], true),
96 Some(WorkspaceLiveEvent::SourcesChanged(vec![PathBuf::from(
97 "/repo/src/lib.rs"
98 )]))
99 );
100 }
101
102 #[test]
103 fn ignores_non_language_files_under_source_root() {
104 let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
105 path: PathBuf::from("/repo"),
106 git_root: None,
107 ignored_paths: Vec::new(),
108 notes_path: None,
109 }]);
110
111 assert_eq!(
112 classifier.classify_paths_with_git_signals(&[PathBuf::from("/repo/README.md")], true),
113 None
114 );
115 assert_eq!(
116 classifier.classify_event(
117 ¬ify::Event::new(notify::EventKind::Create(notify::event::CreateKind::File))
118 .add_path(PathBuf::from("/repo/README.md"))
119 ),
120 None
121 );
122 }
123
124 #[test]
125 fn classifies_manifest_changes_as_live_path_refresh() {
126 let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
127 path: PathBuf::from("/repo"),
128 git_root: None,
129 ignored_paths: Vec::new(),
130 notes_path: None,
131 }]);
132
133 assert_eq!(
134 classifier
135 .classify_paths_with_git_signals(&[PathBuf::from("/repo/package.json")], true),
136 Some(WorkspaceLiveEvent::SourcesChanged(vec![PathBuf::from(
137 "/repo/package.json"
138 )]))
139 );
140 }
141
142 #[test]
143 fn classifies_source_create_remove_as_incremental_source_changes() {
144 let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
145 path: PathBuf::from("/repo"),
146 git_root: None,
147 ignored_paths: Vec::new(),
148 notes_path: None,
149 }]);
150
151 assert_eq!(
152 classifier.classify_event(
153 ¬ify::Event::new(notify::EventKind::Create(notify::event::CreateKind::File))
154 .add_path(PathBuf::from("/repo/src/new.rs"))
155 ),
156 Some(WorkspaceLiveEvent::SourcesChanged(vec![PathBuf::from(
157 "/repo/src/new.rs"
158 )]))
159 );
160 assert_eq!(
161 classifier.classify_event(
162 ¬ify::Event::new(notify::EventKind::Remove(notify::event::RemoveKind::File))
163 .add_path(PathBuf::from("/repo/src/old.rs"))
164 ),
165 Some(WorkspaceLiveEvent::SourcesChanged(vec![PathBuf::from(
166 "/repo/src/old.rs"
167 )]))
168 );
169 }
170
171 #[test]
172 fn classifies_source_rename_as_incremental_source_changes() {
173 let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
174 path: PathBuf::from("/repo"),
175 git_root: None,
176 ignored_paths: Vec::new(),
177 notes_path: None,
178 }]);
179
180 assert_eq!(
181 classifier.classify_event(
182 ¬ify::Event::new(notify::EventKind::Modify(notify::event::ModifyKind::Name(
183 notify::event::RenameMode::Both,
184 )))
185 .add_path(PathBuf::from("/repo/src/old.rs"))
186 .add_path(PathBuf::from("/repo/src/new.rs"))
187 ),
188 Some(WorkspaceLiveEvent::SourcesChanged(vec![
189 PathBuf::from("/repo/src/old.rs"),
190 PathBuf::from("/repo/src/new.rs"),
191 ]))
192 );
193 }
194
195 #[test]
196 fn classifies_missing_source_modify_as_incremental_source_changes() {
197 let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
198 let missing = temp.path().join("src").join("deleted.rs");
199 let classifier = WorkspaceEventClassifier::new(watch_roots_for_paths(
200 &[temp.path().to_path_buf()],
201 None,
202 ));
203
204 assert_eq!(
205 classifier.classify_event(
206 ¬ify::Event::new(notify::EventKind::Modify(notify::event::ModifyKind::Data(
207 notify::event::DataChange::Content,
208 )))
209 .add_path(missing.clone())
210 ),
211 Some(WorkspaceLiveEvent::SourcesChanged(vec![missing]))
212 );
213 }
214
215 #[test]
216 fn classifies_source_directory_changes_as_rescan_required() {
217 let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
218 let src = temp.path().join("src");
219 std::fs::create_dir_all(&src).expect("src dir");
220 let classifier = WorkspaceEventClassifier::new(watch_roots_for_paths(
221 &[temp.path().to_path_buf()],
222 None,
223 ));
224
225 assert_eq!(
226 classifier.classify_paths_with_git_signals(&[src], true),
227 Some(WorkspaceLiveEvent::RescanRequired)
228 );
229 }
230
231 #[test]
232 fn coalesces_source_with_notes_and_git_base_without_dropping_signals() {
233 assert_eq!(
234 WorkspaceLiveEvent::SourcesChanged(vec![PathBuf::from("/repo/src/lib.rs")])
235 .coalesce(WorkspaceLiveEvent::Notes),
236 WorkspaceLiveEvent::SourcesAndNotes(vec![PathBuf::from("/repo/src/lib.rs")])
237 );
238 assert_eq!(
239 WorkspaceLiveEvent::SourcesAndNotes(vec![PathBuf::from("/repo/src/lib.rs")])
240 .coalesce(WorkspaceLiveEvent::GitBaseChanged),
241 WorkspaceLiveEvent::SourcesGitBaseAndNotes(vec![PathBuf::from("/repo/src/lib.rs")])
242 );
243 }
244
245 #[test]
246 fn classifies_atomic_notes_writes_as_notes_refresh() {
247 let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
248 path: PathBuf::from("/repo"),
249 git_root: None,
250 ignored_paths: Vec::new(),
251 notes_path: Some(PathBuf::from("/repo/.code-moniker/notes.toml")),
252 }]);
253
254 assert_eq!(
255 classifier.classify_paths_with_git_signals(
256 &[PathBuf::from("/repo/.code-moniker/notes.toml.tmp")],
257 true,
258 ),
259 Some(WorkspaceLiveEvent::Notes)
260 );
261 assert_eq!(
262 classifier.classify_paths_with_git_signals(
263 &[PathBuf::from("/repo/.code-moniker/notes.toml")],
264 false,
265 ),
266 Some(WorkspaceLiveEvent::Notes)
267 );
268 }
269
270 #[test]
271 fn classifies_git_refs_as_git_base_changes() {
272 let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
273 path: PathBuf::from("/repo"),
274 git_root: Some(PathBuf::from("/repo")),
275 ignored_paths: Vec::new(),
276 notes_path: None,
277 }]);
278
279 assert_eq!(
280 classifier.classify_paths_with_git_signals(&[PathBuf::from("/repo/.git/HEAD")], true),
281 Some(WorkspaceLiveEvent::GitBaseChanged)
282 );
283 assert_eq!(
284 classifier.classify_paths_with_git_signals(
285 &[PathBuf::from("/repo/.git/refs/heads/main")],
286 true,
287 ),
288 Some(WorkspaceLiveEvent::GitBaseChanged)
289 );
290 assert_eq!(
291 classifier.classify_paths_with_git_signals(&[PathBuf::from("/repo/.git/index")], true),
292 None
293 );
294 }
295
296 #[test]
297 fn coalesces_notes_and_git_base_without_dropping_either() {
298 assert_eq!(
299 WorkspaceLiveEvent::GitBaseChanged.coalesce(WorkspaceLiveEvent::Notes),
300 WorkspaceLiveEvent::GitBaseAndNotes
301 );
302 assert_eq!(
303 WorkspaceLiveEvent::GitBaseAndNotes.coalesce(WorkspaceLiveEvent::RescanRequired),
304 WorkspaceLiveEvent::RescanGitBaseAndNotes
305 );
306 }
307
308 #[test]
309 fn respects_gitignore_in_live_classifier() {
310 let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
311 let root_path = temp.path().to_path_buf();
312
313 std::fs::write(root_path.join(".gitignore"), ".metals/\n*.log\n").expect("write gitignore");
314
315 let classifier = WorkspaceEventClassifier::new(watch_roots_for_paths(
316 std::slice::from_ref(&root_path),
317 None,
318 ));
319
320 assert_eq!(
321 classifier
322 .classify_paths_with_git_signals(&[root_path.join(".metals/metals.log")], true),
323 None
324 );
325 assert_eq!(
326 classifier.classify_paths_with_git_signals(&[root_path.join("build.log")], true),
327 None
328 );
329
330 assert_eq!(
331 classifier.classify_paths_with_git_signals(&[root_path.join("src/lib.rs")], true),
332 Some(WorkspaceLiveEvent::SourcesChanged(vec![
333 root_path.join("src/lib.rs")
334 ]))
335 );
336 }
337
338 #[test]
339 fn anchors_nested_gitignore_patterns_to_their_directory() {
340 let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
341 let root_path = temp.path().to_path_buf();
342
343 std::fs::write(root_path.join(".gitignore"), "*.log\n").expect("write root gitignore");
344 std::fs::create_dir_all(root_path.join("nested")).expect("nested dir");
345 std::fs::write(root_path.join("nested/.gitignore"), "/keep.rs\n")
346 .expect("write nested gitignore");
347
348 let classifier = WorkspaceEventClassifier::new(watch_roots_for_paths(
349 std::slice::from_ref(&root_path),
350 None,
351 ));
352
353 assert_eq!(
354 classifier.classify_paths_with_git_signals(&[root_path.join("nested/keep.rs")], true),
355 None
356 );
357
358 assert_eq!(
359 classifier.classify_paths_with_git_signals(&[root_path.join("keep.rs")], true),
360 Some(WorkspaceLiveEvent::SourcesChanged(vec![
361 root_path.join("keep.rs")
362 ]))
363 );
364 }
365}