Skip to main content

code_moniker_workspace/live/
mod.rs

1mod 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				&notify::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				&notify::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				&notify::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				&notify::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				&notify::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}