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 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 ¬ify::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 ¬ify::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 ¬ify::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 ¬ify::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}