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