Skip to main content

code_moniker_workspace/registry/
runtime.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use crate::live::{WorkspaceLiveRefreshPlan, WorkspaceWatchRoot};
5use crate::snapshot::{
6	ResourceGeneration, WorkspaceFailure, WorkspaceRequest, WorkspaceResource, WorkspaceResult,
7	WorkspaceSnapshot, WorkspaceTransition, WorkspaceView,
8};
9
10use super::build::{
11	LivePlanBuild, RefreshPorts, build_catalog_snapshot, build_change_overlay_snapshot,
12	build_complete_snapshot, build_incremental_paths_snapshot, build_index_only_snapshot,
13	build_linkage_snapshot,
14};
15use super::command::{
16	WorkspaceCommand, WorkspaceCommandId, WorkspaceCommandKind, WorkspaceCommandSpec,
17	WorkspaceScopeUri, WorkspaceSnapshotPublication,
18};
19use super::event::{
20	WorkspaceEvent, WorkspaceEventContext, WorkspaceEventCursor, WorkspaceEventKind,
21	WorkspaceEventLog,
22};
23use super::ports::{WorkspaceCommandPort, WorkspaceEventPort, WorkspacePorts, WorkspaceQueryPort};
24use super::staleness::WorkspaceStaleness;
25use super::state::WorkspaceState;
26
27pub struct WorkspaceLivePlanTransition {
28	transition: WorkspaceTransition,
29	replace_watcher: bool,
30}
31
32impl WorkspaceLivePlanTransition {
33	pub fn transition(self) -> WorkspaceTransition {
34		self.transition
35	}
36
37	pub fn replace_watcher(&self) -> bool {
38		self.replace_watcher
39	}
40}
41
42pub struct WorkspaceRegistry {
43	runtime: WorkspaceRuntime,
44	events: WorkspaceEventLog,
45	next_command_id: u64,
46}
47
48pub struct WorkspaceCommands<'a> {
49	runtime: &'a mut WorkspaceRuntime,
50	events: &'a mut WorkspaceEventLog,
51	next_command_id: &'a mut u64,
52}
53
54pub struct WorkspaceLiveCommands<'a> {
55	runtime: &'a mut WorkspaceRuntime,
56	events: &'a mut WorkspaceEventLog,
57	next_command_id: &'a mut u64,
58}
59
60pub struct WorkspaceQueries<'a> {
61	runtime: &'a WorkspaceRuntime,
62}
63
64pub struct WorkspaceEvents<'a> {
65	events: &'a WorkspaceEventLog,
66}
67
68pub struct WorkspaceRuntime {
69	ports: WorkspacePorts,
70	state: WorkspaceState,
71}
72
73impl WorkspaceRegistry {
74	pub fn new(ports: WorkspacePorts) -> Self {
75		Self {
76			runtime: WorkspaceRuntime::new(ports),
77			events: WorkspaceEventLog::default(),
78			next_command_id: 1,
79		}
80	}
81
82	pub fn commands(&mut self) -> WorkspaceCommands<'_> {
83		WorkspaceCommands {
84			runtime: &mut self.runtime,
85			events: &mut self.events,
86			next_command_id: &mut self.next_command_id,
87		}
88	}
89
90	pub fn live_commands(&mut self) -> WorkspaceLiveCommands<'_> {
91		WorkspaceLiveCommands {
92			runtime: &mut self.runtime,
93			events: &mut self.events,
94			next_command_id: &mut self.next_command_id,
95		}
96	}
97
98	pub fn queries(&self) -> WorkspaceQueries<'_> {
99		WorkspaceQueries {
100			runtime: &self.runtime,
101		}
102	}
103
104	pub fn events(&self) -> WorkspaceEvents<'_> {
105		WorkspaceEvents {
106			events: &self.events,
107		}
108	}
109
110	pub fn watch_roots(&self) -> Vec<WorkspaceWatchRoot> {
111		self.runtime
112			.ports
113			.live_watch_roots(self.runtime.state.snapshot())
114	}
115}
116
117impl WorkspaceRuntime {
118	fn new(ports: WorkspacePorts) -> Self {
119		Self {
120			ports,
121			state: WorkspaceState::new(),
122		}
123	}
124}
125
126impl<'a> WorkspaceQueries<'a> {
127	pub fn snapshot(&self) -> Option<&'a WorkspaceSnapshot> {
128		self.runtime.state.snapshot()
129	}
130
131	pub fn snapshot_arc(&self) -> Option<Arc<WorkspaceSnapshot>> {
132		self.runtime.state.snapshot_arc()
133	}
134
135	pub fn view(&self) -> Option<WorkspaceView<'a>> {
136		self.snapshot().map(WorkspaceView::new)
137	}
138
139	pub fn last_failure(&self) -> Option<&'a WorkspaceFailure> {
140		self.runtime.state.last_failure()
141	}
142
143	pub fn staleness(&self) -> WorkspaceStaleness {
144		WorkspaceStaleness::from_plan(&self.runtime.state.pending)
145	}
146}
147
148impl WorkspaceEvents<'_> {
149	pub fn event_cursor(&self) -> WorkspaceEventCursor {
150		self.events.cursor()
151	}
152
153	pub fn events_since(&self, cursor: WorkspaceEventCursor) -> &[WorkspaceEvent] {
154		self.events.since(cursor)
155	}
156}
157
158impl WorkspaceCommands<'_> {
159	pub fn execute(&mut self, spec: WorkspaceCommandSpec) -> WorkspaceTransition {
160		let command = WorkspaceCommand::new(
161			self.allocate_command_id(),
162			spec.scope_uri,
163			spec.kind,
164			spec.request,
165		);
166		self.run_command(command)
167	}
168
169	fn allocate_command_id(&mut self) -> WorkspaceCommandId {
170		let id = WorkspaceCommandId::new(*self.next_command_id);
171		*self.next_command_id += 1;
172		id
173	}
174
175	fn run_command(&mut self, command: WorkspaceCommand) -> WorkspaceTransition {
176		let generation = self.runtime.state.allocate_generation();
177		let context = WorkspaceEventContext::new(command.scope_uri, generation, command.id);
178		publish_command_started(self.events, &context);
179		let result = run_workspace_command(self.runtime, command.kind, command.request, generation);
180		publish_command_finished(self.runtime, self.events, &context, result)
181	}
182
183	pub fn refresh(&mut self, request: WorkspaceRequest) -> WorkspaceTransition {
184		self.execute(WorkspaceCommandSpec::new(
185			WorkspaceCommandKind::Refresh,
186			WorkspaceScopeUri::workspace(),
187			request,
188		))
189	}
190
191	pub fn load_catalog(&mut self, request: WorkspaceRequest) -> WorkspaceTransition {
192		self.execute(WorkspaceCommandSpec::new(
193			WorkspaceCommandKind::LoadSources,
194			WorkspaceScopeUri::workspace(),
195			request,
196		))
197	}
198
199	pub fn load_index(&mut self, request: WorkspaceRequest) -> WorkspaceTransition {
200		self.execute(WorkspaceCommandSpec::new(
201			WorkspaceCommandKind::BuildIndex,
202			WorkspaceScopeUri::workspace(),
203			request,
204		))
205	}
206
207	pub fn resolve_linkage(&mut self, request: WorkspaceRequest) -> WorkspaceTransition {
208		self.execute(WorkspaceCommandSpec::new(
209			WorkspaceCommandKind::ResolveLinkage,
210			WorkspaceScopeUri::workspace(),
211			request,
212		))
213	}
214
215	pub fn refresh_paths(
216		&mut self,
217		request: WorkspaceRequest,
218		paths: Vec<PathBuf>,
219	) -> WorkspaceTransition {
220		let command = WorkspaceCommand::new(
221			self.allocate_command_id(),
222			WorkspaceScopeUri::workspace(),
223			WorkspaceCommandKind::RefreshPaths,
224			request,
225		);
226		let generation = self.runtime.state.allocate_generation();
227		let context = WorkspaceEventContext::new(command.scope_uri, generation, command.id);
228		publish_command_started(self.events, &context);
229		let result = build_incremental_paths_snapshot(
230			self.runtime.state.snapshot(),
231			RefreshPorts {
232				source_catalog: &mut *self.runtime.ports.source_catalog,
233				code_index: &mut *self.runtime.ports.code_index,
234				linkage: &mut *self.runtime.ports.linkage,
235			},
236			command.request,
237			&paths,
238			generation,
239		);
240		publish_command_finished(self.runtime, self.events, &context, result)
241	}
242
243	pub fn refresh_changes(&mut self, request: WorkspaceRequest) -> WorkspaceTransition {
244		let command = WorkspaceCommand::new(
245			self.allocate_command_id(),
246			WorkspaceScopeUri::workspace(),
247			WorkspaceCommandKind::RefreshChanges,
248			request,
249		);
250		let generation = self.runtime.state.allocate_generation();
251		let context = WorkspaceEventContext::new(command.scope_uri, generation, command.id);
252		publish_command_started(self.events, &context);
253		let result = build_change_overlay_snapshot(
254			self.runtime.state.snapshot(),
255			&mut *self.runtime.ports.change_overlay,
256			command.request,
257			generation,
258		);
259		publish_command_finished(self.runtime, self.events, &context, result)
260	}
261
262	pub fn publish_snapshot(
263		&mut self,
264		publication: WorkspaceSnapshotPublication,
265	) -> WorkspaceTransition {
266		let command = WorkspaceCommand::new(
267			self.allocate_command_id(),
268			publication.scope_uri,
269			WorkspaceCommandKind::PublishSnapshot,
270			publication.request,
271		);
272		let context = WorkspaceEventContext::new(
273			command.scope_uri,
274			publication.snapshot.generation,
275			command.id,
276		);
277		publish_command_started(self.events, &context);
278		let transition = self.runtime.state.adopt_snapshot_arc(publication.snapshot);
279		events_for_ready_transition(self.events, &context, &transition);
280		transition
281	}
282}
283
284impl WorkspaceLiveCommands<'_> {
285	pub fn apply_plan(
286		&mut self,
287		request: WorkspaceRequest,
288		plan: WorkspaceLiveRefreshPlan,
289	) -> WorkspaceLivePlanTransition {
290		let command = WorkspaceCommand::new(
291			self.allocate_command_id(),
292			WorkspaceScopeUri::workspace(),
293			WorkspaceCommandKind::RefreshLivePlan,
294			request,
295		);
296		run_live_plan_retaining_failure(self.runtime, self.events, command, plan)
297	}
298
299	pub fn refresh_stale(&mut self, request: WorkspaceRequest) -> WorkspaceLivePlanTransition {
300		let plan = std::mem::take(&mut self.runtime.state.pending);
301		if plan.is_empty()
302			&& let Some(snapshot) = self.runtime.state.snapshot()
303		{
304			return WorkspaceLivePlanTransition {
305				transition: WorkspaceTransition::Ready {
306					generation: snapshot.generation,
307				},
308				replace_watcher: false,
309			};
310		}
311		let command = WorkspaceCommand::new(
312			self.allocate_command_id(),
313			WorkspaceScopeUri::workspace(),
314			WorkspaceCommandKind::RefreshStale,
315			request,
316		);
317		run_live_plan_retaining_failure(self.runtime, self.events, command, plan)
318	}
319
320	pub fn mark_stale(&mut self, plan: WorkspaceLiveRefreshPlan) -> WorkspaceStaleness {
321		let plan = plan.without_notes();
322		if plan.is_empty() {
323			return WorkspaceStaleness::from_plan(&self.runtime.state.pending);
324		}
325		let command_id = self.allocate_command_id();
326		let staleness = coalesce_pending(self.runtime, plan);
327		let generation = self
328			.runtime
329			.state
330			.snapshot()
331			.map(|snapshot| snapshot.generation)
332			.unwrap_or_else(|| ResourceGeneration::new(0));
333		let context =
334			WorkspaceEventContext::new(WorkspaceScopeUri::workspace(), generation, command_id);
335		self.events
336			.publish(context.event(WorkspaceEventKind::StaleMarked));
337		staleness
338	}
339
340	fn allocate_command_id(&mut self) -> WorkspaceCommandId {
341		let id = WorkspaceCommandId::new(*self.next_command_id);
342		*self.next_command_id += 1;
343		id
344	}
345}
346
347fn run_live_plan(
348	runtime: &mut WorkspaceRuntime,
349	events: &mut WorkspaceEventLog,
350	command: WorkspaceCommand,
351	plan: &WorkspaceLiveRefreshPlan,
352) -> WorkspaceLivePlanTransition {
353	let generation = runtime.state.allocate_generation();
354	let context = WorkspaceEventContext::new(command.scope_uri, generation, command.id);
355	publish_command_started(events, &context);
356	let result = LivePlanBuild {
357		current: runtime.state.snapshot(),
358		source_catalog: &mut *runtime.ports.source_catalog,
359		code_index: &mut *runtime.ports.code_index,
360		linkage: &mut *runtime.ports.linkage,
361		change_overlay: &mut *runtime.ports.change_overlay,
362	}
363	.build(command.request, plan, generation);
364	let replace_watcher = result
365		.as_ref()
366		.map(|result| result.replace_watcher)
367		.unwrap_or_else(|_| plan.requires_rescan());
368	let transition = publish_command_finished(
369		runtime,
370		events,
371		&context,
372		result.map(|result| result.snapshot),
373	);
374	WorkspaceLivePlanTransition {
375		transition,
376		replace_watcher,
377	}
378}
379
380fn run_live_plan_retaining_failure(
381	runtime: &mut WorkspaceRuntime,
382	events: &mut WorkspaceEventLog,
383	command: WorkspaceCommand,
384	plan: WorkspaceLiveRefreshPlan,
385) -> WorkspaceLivePlanTransition {
386	let live = run_live_plan(runtime, events, command, &plan);
387	if !plan.is_empty() && matches!(live.transition, WorkspaceTransition::Failed { .. }) {
388		coalesce_pending(runtime, plan);
389	}
390	live
391}
392
393fn coalesce_pending(
394	runtime: &mut WorkspaceRuntime,
395	plan: WorkspaceLiveRefreshPlan,
396) -> WorkspaceStaleness {
397	let pending = std::mem::take(&mut runtime.state.pending);
398	runtime.state.pending = pending.coalesce(plan);
399	WorkspaceStaleness::from_plan(&runtime.state.pending)
400}
401
402fn publish_command_started(events: &mut WorkspaceEventLog, context: &WorkspaceEventContext) {
403	events.publish(context.event(WorkspaceEventKind::CommandAccepted));
404	events.publish(context.event(WorkspaceEventKind::WorkStarted));
405}
406
407fn publish_command_finished(
408	runtime: &mut WorkspaceRuntime,
409	events: &mut WorkspaceEventLog,
410	context: &WorkspaceEventContext,
411	result: WorkspaceResult<WorkspaceSnapshot>,
412) -> WorkspaceTransition {
413	match runtime.state.publish(result) {
414		WorkspaceTransition::Ready { generation } => {
415			events.publish(context.event(WorkspaceEventKind::SnapshotPublished));
416			events.publish(context.event(WorkspaceEventKind::WorkCompleted));
417			WorkspaceTransition::Ready { generation }
418		}
419		WorkspaceTransition::Failed {
420			failure,
421			preserved_generation,
422		} => {
423			events.publish(context.event(WorkspaceEventKind::WorkFailed));
424			WorkspaceTransition::Failed {
425				failure,
426				preserved_generation,
427			}
428		}
429	}
430}
431
432fn events_for_ready_transition(
433	events: &mut WorkspaceEventLog,
434	context: &WorkspaceEventContext,
435	transition: &WorkspaceTransition,
436) {
437	match transition {
438		WorkspaceTransition::Ready { .. } => {
439			events.publish(context.event(WorkspaceEventKind::SnapshotPublished));
440			events.publish(context.event(WorkspaceEventKind::WorkCompleted));
441		}
442		WorkspaceTransition::Failed { .. } => {
443			events.publish(context.event(WorkspaceEventKind::WorkFailed));
444		}
445	}
446}
447
448impl WorkspaceQueryPort for WorkspaceQueries<'_> {
449	fn snapshot(&self) -> Option<&WorkspaceSnapshot> {
450		self.runtime.state.snapshot()
451	}
452
453	fn snapshot_arc(&self) -> Option<Arc<WorkspaceSnapshot>> {
454		self.runtime.state.snapshot_arc()
455	}
456
457	fn view(&self) -> Option<WorkspaceView<'_>> {
458		self.snapshot().map(WorkspaceView::new)
459	}
460
461	fn last_failure(&self) -> Option<&WorkspaceFailure> {
462		self.runtime.state.last_failure()
463	}
464}
465
466impl WorkspaceEventPort for WorkspaceEvents<'_> {
467	fn event_cursor(&self) -> WorkspaceEventCursor {
468		self.events.cursor()
469	}
470
471	fn events_since(&self, cursor: WorkspaceEventCursor) -> &[WorkspaceEvent] {
472		self.events.since(cursor)
473	}
474}
475
476impl WorkspaceCommandPort for WorkspaceCommands<'_> {
477	fn execute_command(
478		&mut self,
479		kind: WorkspaceCommandKind,
480		scope_uri: WorkspaceScopeUri,
481		request: WorkspaceRequest,
482	) -> WorkspaceTransition {
483		self.execute(WorkspaceCommandSpec::new(kind, scope_uri, request))
484	}
485
486	fn publish_snapshot(
487		&mut self,
488		publication: WorkspaceSnapshotPublication,
489	) -> WorkspaceTransition {
490		self.publish_snapshot(publication)
491	}
492}
493
494fn run_workspace_command(
495	runtime: &mut WorkspaceRuntime,
496	kind: WorkspaceCommandKind,
497	request: WorkspaceRequest,
498	generation: ResourceGeneration,
499) -> WorkspaceResult<WorkspaceSnapshot> {
500	match kind {
501		WorkspaceCommandKind::Refresh => build_complete_snapshot(
502			&mut *runtime.ports.source_catalog,
503			&mut *runtime.ports.code_index,
504			&mut *runtime.ports.linkage,
505			&mut *runtime.ports.change_overlay,
506			request,
507			generation,
508		),
509		WorkspaceCommandKind::LoadSources => {
510			build_catalog_snapshot(&mut *runtime.ports.source_catalog, request, generation)
511		}
512		WorkspaceCommandKind::BuildIndex => run_build_index_command(runtime, request, generation),
513		WorkspaceCommandKind::ResolveLinkage => build_linkage_snapshot(
514			runtime.state.snapshot(),
515			&mut *runtime.ports.linkage,
516			&mut *runtime.ports.change_overlay,
517			request,
518			generation,
519		),
520		WorkspaceCommandKind::RefreshPaths => Err(WorkspaceFailure::new(
521			WorkspaceResource::CodeIndex,
522			"RefreshPaths commands require changed paths",
523		)),
524		WorkspaceCommandKind::RefreshChanges => build_change_overlay_snapshot(
525			runtime.state.snapshot(),
526			&mut *runtime.ports.change_overlay,
527			request,
528			generation,
529		),
530		WorkspaceCommandKind::RefreshLivePlan => Err(WorkspaceFailure::new(
531			WorkspaceResource::CodeIndex,
532			"RefreshLivePlan commands require a live refresh plan",
533		)),
534		WorkspaceCommandKind::PublishSnapshot => Err(WorkspaceFailure::new(
535			WorkspaceResource::CodeIndex,
536			"PublishSnapshot commands require a snapshot payload",
537		)),
538		WorkspaceCommandKind::RefreshStale => Err(WorkspaceFailure::new(
539			WorkspaceResource::CodeIndex,
540			"RefreshStale commands run through live refresh_stale",
541		)),
542	}
543}
544
545fn run_build_index_command(
546	runtime: &mut WorkspaceRuntime,
547	request: WorkspaceRequest,
548	generation: ResourceGeneration,
549) -> WorkspaceResult<WorkspaceSnapshot> {
550	let catalog_source = request
551		.should_reuse_current_catalog()
552		.then_some(runtime.state.snapshot())
553		.flatten();
554	build_index_only_snapshot(
555		catalog_source,
556		&mut *runtime.ports.source_catalog,
557		&mut *runtime.ports.code_index,
558		request,
559		generation,
560	)
561}
562
563#[cfg(test)]
564mod tests {
565	use std::fs;
566
567	use crate::LocalWorkspaceOptions;
568
569	use super::*;
570
571	#[test]
572	fn refresh_paths_publishes_symbols_from_modified_source() {
573		let temp = tempfile::tempdir().expect("tempdir");
574		let cache_dir = temp.path().join(".cache");
575		let source = temp.path().join("lib.rs");
576		fs::write(&source, "pub fn before_live_refresh() {}\n").expect("write source");
577		let mut registry = crate::LocalWorkspaceRegistry::local(
578			LocalWorkspaceOptions::new(vec![temp.path().to_path_buf()], None)
579				.with_cache_dir(Some(cache_dir)),
580		);
581
582		assert!(matches!(
583			registry
584				.commands()
585				.load_index(WorkspaceRequest::new("acceptance-index")),
586			WorkspaceTransition::Ready { .. }
587		));
588		assert!(snapshot_has_symbol(&registry, "before_live_refresh"));
589
590		fs::write(&source, "pub fn after_live_refresh() {}\n").expect("rewrite source");
591
592		assert!(matches!(
593			registry.commands().refresh_paths(
594				WorkspaceRequest::new("acceptance-live-refresh"),
595				vec![source]
596			),
597			WorkspaceTransition::Ready { .. }
598		));
599
600		assert!(snapshot_has_symbol(&registry, "after_live_refresh"));
601		assert!(!snapshot_has_symbol(&registry, "before_live_refresh"));
602	}
603
604	#[test]
605	fn refresh_paths_classifies_new_files_with_declared_srcset() {
606		let temp = tempfile::tempdir().expect("tempdir");
607		let production = temp.path().join("src/java/com/acme/Production.java");
608		fs::create_dir_all(production.parent().expect("production parent"))
609			.expect("production dirs");
610		fs::write(
611			temp.path().join(".code-moniker.toml"),
612			r#"
613[[workspace.source_group]]
614roots = [
615  { path = "src/java", srcset = "main" },
616  { path = "test", srcset = "test" },
617]
618"#,
619		)
620		.expect("source group config");
621		fs::write(
622			&production,
623			"package com.acme; public class Production {}\n",
624		)
625		.expect("production source");
626		let mut registry = crate::LocalWorkspaceRegistry::local(LocalWorkspaceOptions::new(
627			vec![temp.path().to_path_buf()],
628			None,
629		));
630		assert!(matches!(
631			registry
632				.commands()
633				.load_index(WorkspaceRequest::new("source-group-initial")),
634			WorkspaceTransition::Ready { .. }
635		));
636
637		let test = temp.path().join("test/unit/com/acme/AddedTest.java");
638		fs::create_dir_all(test.parent().expect("test parent")).expect("test dirs");
639		fs::write(&test, "package com.acme; public class AddedTest {}\n").expect("test source");
640		assert!(matches!(
641			registry
642				.commands()
643				.refresh_paths(WorkspaceRequest::new("source-group-new-file"), vec![test],),
644			WorkspaceTransition::Ready { .. }
645		));
646
647		assert!(snapshot_has_identity(
648			&registry,
649			"srcset:test/lang:java/package:com/package:acme/module:AddedTest/class:AddedTest"
650		));
651	}
652
653	#[test]
654	fn full_rescan_reclassifies_cached_files_after_source_group_config_change() {
655		let temp = tempfile::tempdir().expect("tempdir");
656		let source = temp.path().join("custom/com/acme/Reclassified.java");
657		fs::create_dir_all(source.parent().expect("source parent")).expect("source dirs");
658		let config = temp.path().join(".code-moniker.toml");
659		fs::write(
660			&config,
661			r#"
662[[workspace.source_group]]
663roots = [{ path = "custom", srcset = "main" }]
664"#,
665		)
666		.expect("initial config");
667		fs::write(&source, "package com.acme; public class Reclassified {}\n").expect("source");
668		let cache_dir = temp.path().join(".cache");
669		let mut registry = crate::LocalWorkspaceRegistry::local(
670			LocalWorkspaceOptions::new(vec![temp.path().to_path_buf()], None)
671				.with_cache_dir(Some(cache_dir)),
672		);
673		assert!(matches!(
674			registry
675				.commands()
676				.load_index(WorkspaceRequest::new("source-group-main")),
677			WorkspaceTransition::Ready { .. }
678		));
679		assert!(snapshot_has_identity(&registry, "srcset:main/lang:java"));
680
681		fs::write(
682			&config,
683			r#"
684[[workspace.source_group]]
685roots = [{ path = "custom", srcset = "test" }]
686"#,
687		)
688		.expect("updated config");
689		registry
690			.live_commands()
691			.mark_stale(WorkspaceLiveRefreshPlan::from_event(
692				crate::live::WorkspaceLiveEvent::RescanRequired,
693			));
694		let live = registry
695			.live_commands()
696			.refresh_stale(WorkspaceRequest::new("source-group-test"));
697		assert!(matches!(
698			live.transition(),
699			WorkspaceTransition::Ready { .. }
700		));
701
702		assert!(snapshot_has_identity(&registry, "srcset:test/lang:java"));
703		assert!(!snapshot_has_identity(&registry, "srcset:main/lang:java"));
704	}
705
706	#[test]
707	fn invalid_source_group_config_fails_with_the_mapping_diagnostic() {
708		let temp = tempfile::tempdir().expect("tempdir");
709		fs::write(temp.path().join("lib.rs"), "pub fn source() {}\n").expect("source");
710		fs::write(
711			temp.path().join(".code-moniker.toml"),
712			r#"
713[[workspace.source_group]]
714roots = ["src"]
715
716[[workspace.source_group]]
717roots = ["src/generated"]
718"#,
719		)
720		.expect("invalid config");
721		let mut registry = crate::LocalWorkspaceRegistry::local(LocalWorkspaceOptions::new(
722			vec![temp.path().to_path_buf()],
723			None,
724		));
725
726		let transition = registry
727			.commands()
728			.load_index(WorkspaceRequest::new("invalid-source-group"));
729
730		assert!(matches!(
731			transition,
732			WorkspaceTransition::Failed { failure, .. }
733				if failure.message.contains("invalid source groups")
734					&& failure.message.contains("overlap")
735		));
736	}
737
738	#[test]
739	fn mark_stale_records_staleness_without_touching_snapshot() {
740		let (temp, source, mut registry) = indexed_registry("pub fn before_stale() {}\n");
741		let _ = &temp;
742		let cursor = registry.events().event_cursor();
743
744		fs::write(&source, "pub fn after_stale() {}\n").expect("rewrite source");
745		let staleness = registry
746			.live_commands()
747			.mark_stale(WorkspaceLiveRefreshPlan::from_event(
748				crate::live::WorkspaceLiveEvent::SourcesChanged(vec![source.clone()]),
749			));
750
751		assert!(staleness.is_stale());
752		assert_eq!(staleness.stale_paths, vec![source]);
753		assert!(snapshot_has_symbol(&registry, "before_stale"));
754		assert_eq!(
755			registry
756				.events()
757				.events_since(cursor)
758				.iter()
759				.map(|event| event.kind)
760				.collect::<Vec<_>>(),
761			vec![WorkspaceEventKind::StaleMarked]
762		);
763	}
764
765	#[test]
766	fn refresh_stale_applies_coalesced_pending_plan() {
767		let (temp, source, mut registry) = indexed_registry("pub fn before_stale() {}\n");
768		let _ = &temp;
769
770		fs::write(&source, "pub fn after_stale() {}\n").expect("rewrite source");
771		registry
772			.live_commands()
773			.mark_stale(WorkspaceLiveRefreshPlan::from_event(
774				crate::live::WorkspaceLiveEvent::SourcesChanged(vec![source.clone()]),
775			));
776		registry
777			.live_commands()
778			.mark_stale(WorkspaceLiveRefreshPlan::from_event(
779				crate::live::WorkspaceLiveEvent::SourcesChanged(vec![source]),
780			));
781
782		let live = registry
783			.live_commands()
784			.refresh_stale(WorkspaceRequest::new("acceptance-refresh-stale"));
785		assert!(!live.replace_watcher());
786		assert!(matches!(
787			live.transition(),
788			WorkspaceTransition::Ready { .. }
789		));
790		assert!(snapshot_has_symbol(&registry, "after_stale"));
791		assert!(!snapshot_has_symbol(&registry, "before_stale"));
792		assert!(!registry.queries().staleness().is_stale());
793	}
794
795	#[test]
796	fn failed_live_plan_is_retained_for_a_later_refresh() {
797		let (temp, source, mut registry) = indexed_registry("pub fn before_retry() {}\n");
798		let _ = &temp;
799		fs::write(&source, "pub fn after_retry() {}\n").expect("rewrite source");
800		let plan =
801			WorkspaceLiveRefreshPlan::from_event(crate::live::WorkspaceLiveEvent::RescanRequired);
802		let cancellation = crate::snapshot::WorkspaceCancellation::default();
803		cancellation.cancel();
804
805		let failed = registry.live_commands().apply_plan(
806			WorkspaceRequest::new("cancelled-live-plan").with_cancellation(cancellation),
807			plan,
808		);
809		assert!(matches!(
810			failed.transition(),
811			WorkspaceTransition::Failed { .. }
812		));
813		assert!(registry.queries().staleness().is_stale());
814		assert!(snapshot_has_symbol(&registry, "before_retry"));
815
816		let retried = registry
817			.live_commands()
818			.refresh_stale(WorkspaceRequest::new("retry-live-plan"));
819		assert!(matches!(
820			retried.transition(),
821			WorkspaceTransition::Ready { .. }
822		));
823		assert!(snapshot_has_symbol(&registry, "after_retry"));
824		assert!(!registry.queries().staleness().is_stale());
825	}
826
827	#[test]
828	fn refresh_stale_without_pending_is_a_noop() {
829		let (temp, _source, mut registry) = indexed_registry("pub fn untouched() {}\n");
830		let _ = &temp;
831		let generation = registry.queries().snapshot().expect("snapshot").generation;
832		let cursor = registry.events().event_cursor();
833
834		let live = registry
835			.live_commands()
836			.refresh_stale(WorkspaceRequest::new("acceptance-noop"));
837
838		assert!(matches!(
839			live.transition(),
840			WorkspaceTransition::Ready { generation: ready } if ready == generation
841		));
842		assert!(registry.events().events_since(cursor).is_empty());
843	}
844
845	#[test]
846	fn refresh_stale_with_rescan_runs_complete_build() {
847		let (temp, source, mut registry) = indexed_registry("pub fn before_stale() {}\n");
848		let _ = &temp;
849
850		fs::write(&source, "pub fn after_stale() {}\n").expect("rewrite source");
851		registry
852			.live_commands()
853			.mark_stale(WorkspaceLiveRefreshPlan::from_event(
854				crate::live::WorkspaceLiveEvent::RescanRequired,
855			));
856
857		let live = registry
858			.live_commands()
859			.refresh_stale(WorkspaceRequest::new("acceptance-rescan"));
860
861		assert!(live.replace_watcher());
862		assert!(matches!(
863			live.transition(),
864			WorkspaceTransition::Ready { .. }
865		));
866		assert!(snapshot_has_symbol(&registry, "after_stale"));
867		assert!(!registry.queries().staleness().is_stale());
868	}
869
870	#[test]
871	fn cancelled_refresh_stops_before_publishing_a_snapshot() {
872		let temp = tempfile::tempdir().expect("tempdir");
873		fs::write(temp.path().join("lib.rs"), "pub fn never_indexed() {}\n").expect("write source");
874		let mut registry = crate::LocalWorkspaceRegistry::local(LocalWorkspaceOptions::new(
875			vec![temp.path().to_path_buf()],
876			None,
877		));
878		let cancellation = crate::snapshot::WorkspaceCancellation::default();
879		cancellation.cancel();
880
881		let transition = registry
882			.commands()
883			.refresh(WorkspaceRequest::new("cancelled-refresh").with_cancellation(cancellation));
884
885		assert!(matches!(
886			transition,
887			WorkspaceTransition::Failed { failure, .. }
888				if failure.message == "workspace build cancelled"
889		));
890		assert!(registry.queries().snapshot().is_none());
891	}
892
893	#[test]
894	fn cancelled_incremental_refresh_keeps_the_previous_snapshot() {
895		let (temp, source, mut registry) = indexed_registry("pub fn before_cancel() {}\n");
896		let _ = &temp;
897		let generation = registry.queries().snapshot().expect("snapshot").generation;
898		fs::write(&source, "pub fn after_cancel() {}\n").expect("rewrite source");
899		let cancellation = crate::snapshot::WorkspaceCancellation::default();
900		cancellation.cancel();
901
902		let transition = registry.commands().refresh_paths(
903			WorkspaceRequest::new("cancelled-incremental").with_cancellation(cancellation),
904			vec![source],
905		);
906
907		assert!(matches!(
908			transition,
909			WorkspaceTransition::Failed { failure, .. }
910				if failure.message == "workspace build cancelled"
911		));
912		assert_eq!(
913			registry
914				.queries()
915				.snapshot()
916				.expect("previous snapshot")
917				.generation,
918			generation
919		);
920		assert!(snapshot_has_symbol(&registry, "before_cancel"));
921		assert!(!snapshot_has_symbol(&registry, "after_cancel"));
922	}
923
924	fn indexed_registry(
925		body: &str,
926	) -> (
927		tempfile::TempDir,
928		std::path::PathBuf,
929		crate::LocalWorkspaceRegistry,
930	) {
931		let temp = tempfile::tempdir().expect("tempdir");
932		let cache_dir = temp.path().join(".cache");
933		let source = temp.path().join("lib.rs");
934		fs::write(&source, body).expect("write source");
935		let mut registry = crate::LocalWorkspaceRegistry::local(
936			LocalWorkspaceOptions::new(vec![temp.path().to_path_buf()], None)
937				.with_cache_dir(Some(cache_dir)),
938		);
939		assert!(matches!(
940			registry
941				.commands()
942				.load_index(WorkspaceRequest::new("acceptance-index")),
943			WorkspaceTransition::Ready { .. }
944		));
945		(temp, source, registry)
946	}
947
948	fn snapshot_has_symbol(registry: &crate::LocalWorkspaceRegistry, name: &str) -> bool {
949		registry.queries().snapshot().is_some_and(|snapshot| {
950			snapshot
951				.index
952				.symbols
953				.iter()
954				.any(|symbol| symbol.name.contains(name))
955		})
956	}
957
958	fn snapshot_has_identity(registry: &crate::LocalWorkspaceRegistry, identity: &str) -> bool {
959		registry.queries().snapshot().is_some_and(|snapshot| {
960			snapshot
961				.index
962				.symbols
963				.iter()
964				.any(|symbol| symbol.identity.contains(identity))
965		})
966	}
967}