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 c_build_context_changes_require_a_full_rescan() {
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 for path in [
152 "/repo/Makefile",
153 "/repo/compile_commands.json",
154 "/repo/src/main.c",
155 "/repo/include/api.h",
156 "/repo/generated/model.cpp",
157 "/repo/generated/wrapper.hpp",
158 ] {
159 assert_eq!(
160 classifier.classify_paths_with_git_signals(&[PathBuf::from(path)], true),
161 Some(WorkspaceLiveEvent::RescanRequired),
162 "{path} must rebuild C build provenance"
163 );
164 }
165
166 assert_eq!(
167 classifier.classify_paths_with_git_signals(&[PathBuf::from("/repo/src/lib.rs")], true,),
168 Some(WorkspaceLiveEvent::SourcesChanged(vec![PathBuf::from(
169 "/repo/src/lib.rs"
170 )]))
171 );
172 }
173
174 #[test]
175 fn tsconfig_changes_require_a_full_rescan() {
176 let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
177 path: PathBuf::from("/repo"),
178 git_root: None,
179 ignored_paths: Vec::new(),
180 notes_path: None,
181 }]);
182
183 for path in [
184 "/repo/tsconfig.json",
185 "/repo/tsconfig.app.json",
186 "/repo/packages/api/tsconfig.node.json",
187 ] {
188 assert_eq!(
189 classifier.classify_paths_with_git_signals(&[PathBuf::from(path)], true),
190 Some(WorkspaceLiveEvent::RescanRequired),
191 "{path} must rebuild TypeScript SDK profiles",
192 );
193 }
194 }
195
196 #[test]
197 fn source_group_config_changes_require_a_full_rescan() {
198 let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
199 path: PathBuf::from("/repo"),
200 git_root: None,
201 ignored_paths: Vec::new(),
202 notes_path: None,
203 }]);
204
205 assert_eq!(
206 classifier.classify_paths_with_git_signals(
207 &[PathBuf::from("/repo/.code-moniker.toml")],
208 true,
209 ),
210 Some(WorkspaceLiveEvent::RescanRequired)
211 );
212 }
213
214 #[test]
215 fn classifies_source_create_remove_as_incremental_source_changes() {
216 let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
217 path: PathBuf::from("/repo"),
218 git_root: None,
219 ignored_paths: Vec::new(),
220 notes_path: None,
221 }]);
222
223 assert_eq!(
224 classifier.classify_event(
225 ¬ify::Event::new(notify::EventKind::Create(notify::event::CreateKind::File))
226 .add_path(PathBuf::from("/repo/src/new.rs"))
227 ),
228 Some(WorkspaceLiveEvent::SourcesChanged(vec![PathBuf::from(
229 "/repo/src/new.rs"
230 )]))
231 );
232 assert_eq!(
233 classifier.classify_event(
234 ¬ify::Event::new(notify::EventKind::Remove(notify::event::RemoveKind::File))
235 .add_path(PathBuf::from("/repo/src/old.rs"))
236 ),
237 Some(WorkspaceLiveEvent::SourcesChanged(vec![PathBuf::from(
238 "/repo/src/old.rs"
239 )]))
240 );
241 }
242
243 #[test]
244 fn classifies_source_rename_as_incremental_source_changes() {
245 let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
246 path: PathBuf::from("/repo"),
247 git_root: None,
248 ignored_paths: Vec::new(),
249 notes_path: None,
250 }]);
251
252 assert_eq!(
253 classifier.classify_event(
254 ¬ify::Event::new(notify::EventKind::Modify(notify::event::ModifyKind::Name(
255 notify::event::RenameMode::Both,
256 )))
257 .add_path(PathBuf::from("/repo/src/old.rs"))
258 .add_path(PathBuf::from("/repo/src/new.rs"))
259 ),
260 Some(WorkspaceLiveEvent::SourcesChanged(vec![
261 PathBuf::from("/repo/src/old.rs"),
262 PathBuf::from("/repo/src/new.rs"),
263 ]))
264 );
265 }
266
267 #[test]
268 fn classifies_missing_source_modify_as_incremental_source_changes() {
269 let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
270 let missing = temp.path().join("src").join("deleted.rs");
271 let classifier = WorkspaceEventClassifier::new(watch_roots_for_paths(
272 &[temp.path().to_path_buf()],
273 None,
274 ));
275
276 assert_eq!(
277 classifier.classify_event(
278 ¬ify::Event::new(notify::EventKind::Modify(notify::event::ModifyKind::Data(
279 notify::event::DataChange::Content,
280 )))
281 .add_path(missing.clone())
282 ),
283 Some(WorkspaceLiveEvent::SourcesChanged(vec![missing]))
284 );
285 }
286
287 #[test]
288 fn classifies_source_directory_changes_as_rescan_required() {
289 let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
290 let src = temp.path().join("src");
291 std::fs::create_dir_all(&src).expect("src dir");
292 let classifier = WorkspaceEventClassifier::new(watch_roots_for_paths(
293 &[temp.path().to_path_buf()],
294 None,
295 ));
296
297 assert_eq!(
298 classifier.classify_paths_with_git_signals(&[src], true),
299 Some(WorkspaceLiveEvent::RescanRequired)
300 );
301 }
302
303 #[test]
304 fn coalesces_source_with_notes_and_git_base_without_dropping_signals() {
305 assert_eq!(
306 WorkspaceLiveEvent::SourcesChanged(vec![PathBuf::from("/repo/src/lib.rs")])
307 .coalesce(WorkspaceLiveEvent::Notes),
308 WorkspaceLiveEvent::SourcesAndNotes(vec![PathBuf::from("/repo/src/lib.rs")])
309 );
310 assert_eq!(
311 WorkspaceLiveEvent::SourcesAndNotes(vec![PathBuf::from("/repo/src/lib.rs")])
312 .coalesce(WorkspaceLiveEvent::GitBaseChanged),
313 WorkspaceLiveEvent::SourcesGitBaseAndNotes(vec![PathBuf::from("/repo/src/lib.rs")])
314 );
315 }
316
317 #[test]
318 fn classifies_atomic_notes_writes_as_notes_refresh() {
319 let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
320 path: PathBuf::from("/repo"),
321 git_root: None,
322 ignored_paths: Vec::new(),
323 notes_path: Some(PathBuf::from("/repo/.code-moniker/notes.toml")),
324 }]);
325
326 assert_eq!(
327 classifier.classify_paths_with_git_signals(
328 &[PathBuf::from("/repo/.code-moniker/notes.toml.tmp")],
329 true,
330 ),
331 Some(WorkspaceLiveEvent::Notes)
332 );
333 assert_eq!(
334 classifier.classify_paths_with_git_signals(
335 &[PathBuf::from("/repo/.code-moniker/notes.toml")],
336 false,
337 ),
338 Some(WorkspaceLiveEvent::Notes)
339 );
340 }
341
342 #[test]
343 fn classifies_git_refs_as_git_base_changes() {
344 let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
345 path: PathBuf::from("/repo"),
346 git_root: Some(PathBuf::from("/repo")),
347 ignored_paths: Vec::new(),
348 notes_path: None,
349 }]);
350
351 assert_eq!(
352 classifier.classify_paths_with_git_signals(&[PathBuf::from("/repo/.git/HEAD")], true),
353 Some(WorkspaceLiveEvent::GitBaseChanged)
354 );
355 assert_eq!(
356 classifier.classify_paths_with_git_signals(
357 &[PathBuf::from("/repo/.git/refs/heads/main")],
358 true,
359 ),
360 Some(WorkspaceLiveEvent::GitBaseChanged)
361 );
362 assert_eq!(
363 classifier.classify_paths_with_git_signals(&[PathBuf::from("/repo/.git/index")], true),
364 None
365 );
366 }
367
368 #[test]
369 fn coalesces_notes_and_git_base_without_dropping_either() {
370 assert_eq!(
371 WorkspaceLiveEvent::GitBaseChanged.coalesce(WorkspaceLiveEvent::Notes),
372 WorkspaceLiveEvent::GitBaseAndNotes
373 );
374 assert_eq!(
375 WorkspaceLiveEvent::GitBaseAndNotes.coalesce(WorkspaceLiveEvent::RescanRequired),
376 WorkspaceLiveEvent::RescanGitBaseAndNotes
377 );
378 }
379
380 #[test]
381 fn respects_gitignore_in_live_classifier() {
382 let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
383 let root_path = temp.path().to_path_buf();
384
385 std::fs::write(root_path.join(".gitignore"), ".metals/\n*.log\n").expect("write gitignore");
386
387 let classifier = WorkspaceEventClassifier::new(watch_roots_for_paths(
388 std::slice::from_ref(&root_path),
389 None,
390 ));
391
392 assert_eq!(
393 classifier
394 .classify_paths_with_git_signals(&[root_path.join(".metals/metals.log")], true),
395 None
396 );
397 assert_eq!(
398 classifier.classify_paths_with_git_signals(&[root_path.join("build.log")], true),
399 None
400 );
401
402 assert_eq!(
403 classifier.classify_paths_with_git_signals(&[root_path.join("src/lib.rs")], true),
404 Some(WorkspaceLiveEvent::SourcesChanged(vec![
405 root_path.join("src/lib.rs")
406 ]))
407 );
408 }
409
410 #[test]
411 fn anchors_nested_gitignore_patterns_to_their_directory() {
412 let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
413 let root_path = temp.path().to_path_buf();
414
415 std::fs::write(root_path.join(".gitignore"), "*.log\n").expect("write root gitignore");
416 std::fs::create_dir_all(root_path.join("nested")).expect("nested dir");
417 std::fs::write(root_path.join("nested/.gitignore"), "/keep.rs\n")
418 .expect("write nested gitignore");
419
420 let classifier = WorkspaceEventClassifier::new(watch_roots_for_paths(
421 std::slice::from_ref(&root_path),
422 None,
423 ));
424
425 assert_eq!(
426 classifier.classify_paths_with_git_signals(&[root_path.join("nested/keep.rs")], true),
427 None
428 );
429
430 assert_eq!(
431 classifier.classify_paths_with_git_signals(&[root_path.join("keep.rs")], true),
432 Some(WorkspaceLiveEvent::SourcesChanged(vec![
433 root_path.join("keep.rs")
434 ]))
435 );
436 }
437}