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 classifies_source_create_remove_as_incremental_source_changes() {
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		assert_eq!(
184			classifier.classify_event(
185				&notify::Event::new(notify::EventKind::Create(notify::event::CreateKind::File))
186					.add_path(PathBuf::from("/repo/src/new.rs"))
187			),
188			Some(WorkspaceLiveEvent::SourcesChanged(vec![PathBuf::from(
189				"/repo/src/new.rs"
190			)]))
191		);
192		assert_eq!(
193			classifier.classify_event(
194				&notify::Event::new(notify::EventKind::Remove(notify::event::RemoveKind::File))
195					.add_path(PathBuf::from("/repo/src/old.rs"))
196			),
197			Some(WorkspaceLiveEvent::SourcesChanged(vec![PathBuf::from(
198				"/repo/src/old.rs"
199			)]))
200		);
201	}
202
203	#[test]
204	fn classifies_source_rename_as_incremental_source_changes() {
205		let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
206			path: PathBuf::from("/repo"),
207			git_root: None,
208			ignored_paths: Vec::new(),
209			notes_path: None,
210		}]);
211
212		assert_eq!(
213			classifier.classify_event(
214				&notify::Event::new(notify::EventKind::Modify(notify::event::ModifyKind::Name(
215					notify::event::RenameMode::Both,
216				)))
217				.add_path(PathBuf::from("/repo/src/old.rs"))
218				.add_path(PathBuf::from("/repo/src/new.rs"))
219			),
220			Some(WorkspaceLiveEvent::SourcesChanged(vec![
221				PathBuf::from("/repo/src/old.rs"),
222				PathBuf::from("/repo/src/new.rs"),
223			]))
224		);
225	}
226
227	#[test]
228	fn classifies_missing_source_modify_as_incremental_source_changes() {
229		let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
230		let missing = temp.path().join("src").join("deleted.rs");
231		let classifier = WorkspaceEventClassifier::new(watch_roots_for_paths(
232			&[temp.path().to_path_buf()],
233			None,
234		));
235
236		assert_eq!(
237			classifier.classify_event(
238				&notify::Event::new(notify::EventKind::Modify(notify::event::ModifyKind::Data(
239					notify::event::DataChange::Content,
240				)))
241				.add_path(missing.clone())
242			),
243			Some(WorkspaceLiveEvent::SourcesChanged(vec![missing]))
244		);
245	}
246
247	#[test]
248	fn classifies_source_directory_changes_as_rescan_required() {
249		let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
250		let src = temp.path().join("src");
251		std::fs::create_dir_all(&src).expect("src dir");
252		let classifier = WorkspaceEventClassifier::new(watch_roots_for_paths(
253			&[temp.path().to_path_buf()],
254			None,
255		));
256
257		assert_eq!(
258			classifier.classify_paths_with_git_signals(&[src], true),
259			Some(WorkspaceLiveEvent::RescanRequired)
260		);
261	}
262
263	#[test]
264	fn coalesces_source_with_notes_and_git_base_without_dropping_signals() {
265		assert_eq!(
266			WorkspaceLiveEvent::SourcesChanged(vec![PathBuf::from("/repo/src/lib.rs")])
267				.coalesce(WorkspaceLiveEvent::Notes),
268			WorkspaceLiveEvent::SourcesAndNotes(vec![PathBuf::from("/repo/src/lib.rs")])
269		);
270		assert_eq!(
271			WorkspaceLiveEvent::SourcesAndNotes(vec![PathBuf::from("/repo/src/lib.rs")])
272				.coalesce(WorkspaceLiveEvent::GitBaseChanged),
273			WorkspaceLiveEvent::SourcesGitBaseAndNotes(vec![PathBuf::from("/repo/src/lib.rs")])
274		);
275	}
276
277	#[test]
278	fn classifies_atomic_notes_writes_as_notes_refresh() {
279		let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
280			path: PathBuf::from("/repo"),
281			git_root: None,
282			ignored_paths: Vec::new(),
283			notes_path: Some(PathBuf::from("/repo/.code-moniker/notes.toml")),
284		}]);
285
286		assert_eq!(
287			classifier.classify_paths_with_git_signals(
288				&[PathBuf::from("/repo/.code-moniker/notes.toml.tmp")],
289				true,
290			),
291			Some(WorkspaceLiveEvent::Notes)
292		);
293		assert_eq!(
294			classifier.classify_paths_with_git_signals(
295				&[PathBuf::from("/repo/.code-moniker/notes.toml")],
296				false,
297			),
298			Some(WorkspaceLiveEvent::Notes)
299		);
300	}
301
302	#[test]
303	fn classifies_git_refs_as_git_base_changes() {
304		let classifier = WorkspaceEventClassifier::new(vec![WorkspaceWatchRoot {
305			path: PathBuf::from("/repo"),
306			git_root: Some(PathBuf::from("/repo")),
307			ignored_paths: Vec::new(),
308			notes_path: None,
309		}]);
310
311		assert_eq!(
312			classifier.classify_paths_with_git_signals(&[PathBuf::from("/repo/.git/HEAD")], true),
313			Some(WorkspaceLiveEvent::GitBaseChanged)
314		);
315		assert_eq!(
316			classifier.classify_paths_with_git_signals(
317				&[PathBuf::from("/repo/.git/refs/heads/main")],
318				true,
319			),
320			Some(WorkspaceLiveEvent::GitBaseChanged)
321		);
322		assert_eq!(
323			classifier.classify_paths_with_git_signals(&[PathBuf::from("/repo/.git/index")], true),
324			None
325		);
326	}
327
328	#[test]
329	fn coalesces_notes_and_git_base_without_dropping_either() {
330		assert_eq!(
331			WorkspaceLiveEvent::GitBaseChanged.coalesce(WorkspaceLiveEvent::Notes),
332			WorkspaceLiveEvent::GitBaseAndNotes
333		);
334		assert_eq!(
335			WorkspaceLiveEvent::GitBaseAndNotes.coalesce(WorkspaceLiveEvent::RescanRequired),
336			WorkspaceLiveEvent::RescanGitBaseAndNotes
337		);
338	}
339
340	#[test]
341	fn respects_gitignore_in_live_classifier() {
342		let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
343		let root_path = temp.path().to_path_buf();
344
345		std::fs::write(root_path.join(".gitignore"), ".metals/\n*.log\n").expect("write gitignore");
346
347		let classifier = WorkspaceEventClassifier::new(watch_roots_for_paths(
348			std::slice::from_ref(&root_path),
349			None,
350		));
351
352		assert_eq!(
353			classifier
354				.classify_paths_with_git_signals(&[root_path.join(".metals/metals.log")], true),
355			None
356		);
357		assert_eq!(
358			classifier.classify_paths_with_git_signals(&[root_path.join("build.log")], true),
359			None
360		);
361
362		assert_eq!(
363			classifier.classify_paths_with_git_signals(&[root_path.join("src/lib.rs")], true),
364			Some(WorkspaceLiveEvent::SourcesChanged(vec![
365				root_path.join("src/lib.rs")
366			]))
367		);
368	}
369
370	#[test]
371	fn anchors_nested_gitignore_patterns_to_their_directory() {
372		let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("temp workspace");
373		let root_path = temp.path().to_path_buf();
374
375		std::fs::write(root_path.join(".gitignore"), "*.log\n").expect("write root gitignore");
376		std::fs::create_dir_all(root_path.join("nested")).expect("nested dir");
377		std::fs::write(root_path.join("nested/.gitignore"), "/keep.rs\n")
378			.expect("write nested gitignore");
379
380		let classifier = WorkspaceEventClassifier::new(watch_roots_for_paths(
381			std::slice::from_ref(&root_path),
382			None,
383		));
384
385		assert_eq!(
386			classifier.classify_paths_with_git_signals(&[root_path.join("nested/keep.rs")], true),
387			None
388		);
389
390		assert_eq!(
391			classifier.classify_paths_with_git_signals(&[root_path.join("keep.rs")], true),
392			Some(WorkspaceLiveEvent::SourcesChanged(vec![
393				root_path.join("keep.rs")
394			]))
395		);
396	}
397}