1use std::collections::{BTreeMap, HashMap};
20use std::future::Future;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use std::time::Duration;
24
25use anyhow::Context;
26use serde::{Deserialize, Serialize};
27use tokio::sync::Semaphore;
28use tokio::task::JoinSet;
29
30use crate::config::Config;
31
32pub const ALL_SELECTOR: &str = "all";
34
35pub const DEFAULT_SEARCH_DEADLINE_MS: u64 = 5000;
37pub const FAN_OUT_CONCURRENCY: usize = 4;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum ServerMode {
45 Compat,
47 Multi,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ResolutionMode {
55 Compat,
57 RootMerge,
59 PinnedConfig,
61}
62
63impl ResolutionMode {
64 pub fn as_str(self) -> &'static str {
66 match self {
67 ResolutionMode::Compat => "compat",
68 ResolutionMode::RootMerge => "root-merge",
69 ResolutionMode::PinnedConfig => "pinned-config",
70 }
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum WorkspaceHealth {
78 Ok,
80 Unavailable(String),
82}
83
84impl WorkspaceHealth {
85 pub fn is_ok(&self) -> bool {
87 matches!(self, WorkspaceHealth::Ok)
88 }
89}
90
91#[derive(Debug)]
93pub struct WorkspaceRuntime {
94 pub id: String,
96 pub root: Option<PathBuf>,
98 pub enabled: bool,
101 pub config: Arc<Config>,
103 pub health: WorkspaceHealth,
105 pub resolution: ResolutionMode,
107}
108
109impl WorkspaceRuntime {
110 fn compat(config: Arc<Config>) -> Self {
112 Self {
113 id: "default".to_string(),
114 root: None,
115 enabled: true,
116 config,
117 health: WorkspaceHealth::Ok,
118 resolution: ResolutionMode::Compat,
119 }
120 }
121}
122
123#[derive(Debug, Clone)]
126pub enum RouterError {
127 WorkspaceRequired { enabled: Vec<String> },
129 UnknownWorkspace(String),
131 WorkspaceDisabled(String),
133 WorkspaceUnavailable { id: String, reason: String },
135 WorkspaceIdConflict { field: String, qualified: String },
137 UnsupportedWorkspaceSelector(String),
139 WorkspaceTimeout { id: String, deadline_ms: u64 },
141}
142
143impl RouterError {
144 pub fn code(&self) -> &'static str {
146 match self {
147 RouterError::WorkspaceRequired { .. } => "workspace_required",
148 RouterError::UnknownWorkspace(_) => "unknown_workspace",
149 RouterError::WorkspaceDisabled(_) => "workspace_disabled",
150 RouterError::WorkspaceUnavailable { .. } => "workspace_unavailable",
151 RouterError::WorkspaceIdConflict { .. } => "workspace_id_conflict",
152 RouterError::UnsupportedWorkspaceSelector(_) => "unsupported_workspace_selector",
153 RouterError::WorkspaceTimeout { .. } => "workspace_timeout",
154 }
155 }
156}
157
158impl std::fmt::Display for RouterError {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 match self {
161 RouterError::WorkspaceRequired { enabled } if enabled.is_empty() => write!(
162 f,
163 "no enabled workspaces are registered; add one with `ctx workspace add`"
164 ),
165 RouterError::WorkspaceRequired { enabled } => write!(
166 f,
167 "more than one workspace is enabled and no default is set; pass `workspace` \
168 (one of: {}) or set [defaults].workspace",
169 enabled.join(", ")
170 ),
171 RouterError::UnknownWorkspace(id) => {
172 write!(f, "unknown workspace id: {id}")
173 }
174 RouterError::WorkspaceDisabled(id) => {
175 write!(f, "workspace is disabled: {id}")
176 }
177 RouterError::WorkspaceUnavailable { id, reason } => {
178 write!(f, "workspace unavailable: {id} ({reason})")
179 }
180 RouterError::WorkspaceIdConflict { field, qualified } => write!(
181 f,
182 "workspace field '{field}' conflicts with qualified id prefix '{qualified}'"
183 ),
184 RouterError::UnsupportedWorkspaceSelector(sel) => {
185 write!(f, "selector '{sel}' is not valid for this operation")
186 }
187 RouterError::WorkspaceTimeout { id, deadline_ms } => {
188 write!(f, "workspace timed out after {deadline_ms}ms: {id}")
189 }
190 }
191 }
192}
193
194impl std::error::Error for RouterError {}
195
196pub struct WorkspaceRouter {
198 default_workspace: Option<String>,
199 workspaces: HashMap<String, Arc<WorkspaceRuntime>>,
200 order: Vec<String>,
202 mode: ServerMode,
203 search_deadline: Duration,
206}
207
208impl WorkspaceRouter {
209 pub fn single(config: Arc<Config>) -> Self {
211 let runtime = Arc::new(WorkspaceRuntime::compat(config));
212 let id = runtime.id.clone();
213 let mut workspaces = HashMap::new();
214 workspaces.insert(id.clone(), runtime);
215 Self {
216 default_workspace: Some(id.clone()),
217 workspaces,
218 order: vec![id],
219 mode: ServerMode::Compat,
220 search_deadline: Duration::from_millis(DEFAULT_SEARCH_DEADLINE_MS),
221 }
222 }
223
224 pub fn multi(runtimes: Vec<WorkspaceRuntime>, default_workspace: Option<String>) -> Self {
229 let mut workspaces = HashMap::new();
230 let mut order = Vec::with_capacity(runtimes.len());
231 for rt in runtimes {
232 order.push(rt.id.clone());
233 workspaces.insert(rt.id.clone(), Arc::new(rt));
234 }
235 Self {
236 default_workspace,
237 workspaces,
238 order,
239 mode: ServerMode::Multi,
240 search_deadline: Duration::from_millis(DEFAULT_SEARCH_DEADLINE_MS),
241 }
242 }
243
244 pub fn mode(&self) -> ServerMode {
246 self.mode
247 }
248
249 pub fn default_id(&self) -> Option<&str> {
251 self.default_workspace.as_deref()
252 }
253
254 pub fn is_registered(&self, id: &str) -> bool {
256 self.workspaces.contains_key(id)
257 }
258
259 pub fn list(&self) -> Vec<Arc<WorkspaceRuntime>> {
261 self.order
262 .iter()
263 .filter_map(|id| self.workspaces.get(id).cloned())
264 .collect()
265 }
266
267 pub fn default_config(&self) -> Arc<Config> {
271 if let Some(rt) = self
272 .default_workspace
273 .as_ref()
274 .and_then(|d| self.workspaces.get(d))
275 {
276 return rt.config.clone();
277 }
278 self.order
279 .iter()
280 .filter_map(|id| self.workspaces.get(id))
281 .find(|rt| rt.enabled)
282 .or_else(|| self.order.first().and_then(|id| self.workspaces.get(id)))
283 .map(|rt| rt.config.clone())
284 .expect("WorkspaceRouter must contain at least one workspace")
285 }
286
287 fn enabled_ids(&self) -> Vec<String> {
289 self.order
290 .iter()
291 .filter(|id| {
292 self.workspaces
293 .get(*id)
294 .map(|rt| rt.enabled)
295 .unwrap_or(false)
296 })
297 .cloned()
298 .collect()
299 }
300
301 pub fn resolve(&self, selector: Option<&str>) -> Result<Arc<WorkspaceRuntime>, RouterError> {
307 match selector {
308 Some(sel) if sel == ALL_SELECTOR => {
309 Err(RouterError::UnsupportedWorkspaceSelector(sel.to_string()))
310 }
311 Some(id) => self.resolve_id(id),
312 None => {
313 if let Some(default) = self.default_workspace.clone() {
314 return self.resolve_id(&default);
315 }
316 let enabled = self.enabled_ids();
317 match enabled.as_slice() {
318 [only] => self.resolve_id(only),
319 _ => Err(RouterError::WorkspaceRequired { enabled }),
320 }
321 }
322 }
323 }
324
325 fn resolve_id(&self, id: &str) -> Result<Arc<WorkspaceRuntime>, RouterError> {
327 let runtime = self
328 .workspaces
329 .get(id)
330 .ok_or_else(|| RouterError::UnknownWorkspace(id.to_string()))?;
331 if !runtime.enabled {
332 return Err(RouterError::WorkspaceDisabled(id.to_string()));
333 }
334 match &runtime.health {
335 WorkspaceHealth::Ok => Ok(runtime.clone()),
336 WorkspaceHealth::Unavailable(reason) => Err(RouterError::WorkspaceUnavailable {
337 id: id.to_string(),
338 reason: reason.clone(),
339 }),
340 }
341 }
342
343 pub fn search_deadline(&self) -> Duration {
345 self.search_deadline
346 }
347
348 pub fn with_search_deadline(mut self, ms: Option<u64>) -> Self {
352 if let Some(ms) = ms {
353 self.search_deadline = Duration::from_millis(ms);
354 }
355 self
356 }
357
358 pub fn resolve_all(&self) -> (Vec<Arc<WorkspaceRuntime>>, Vec<(String, RouterError)>) {
364 let mut healthy = Vec::new();
365 let mut errors = Vec::new();
366 for id in &self.order {
367 let Some(rt) = self.workspaces.get(id) else {
368 continue;
369 };
370 if !rt.enabled {
371 continue;
372 }
373 match &rt.health {
374 WorkspaceHealth::Ok => healthy.push(rt.clone()),
375 WorkspaceHealth::Unavailable(reason) => errors.push((
376 id.clone(),
377 RouterError::WorkspaceUnavailable {
378 id: id.clone(),
379 reason: reason.clone(),
380 },
381 )),
382 }
383 }
384 (healthy, errors)
385 }
386
387 pub fn split_qualified_id<'a>(&self, id: &'a str) -> (Option<&'a str>, &'a str) {
393 if let Some((prefix, rest)) = id.split_once(':') {
394 if self.is_registered(prefix) {
395 return (Some(prefix), rest);
396 }
397 }
398 (None, id)
399 }
400}
401
402#[derive(Debug, Clone, Default, Deserialize, Serialize)]
412pub struct WorkspaceRegistry {
413 #[serde(default, skip_serializing_if = "RegistryDefaults::is_empty")]
414 pub defaults: RegistryDefaults,
415 #[serde(default)]
416 pub workspaces: BTreeMap<String, WorkspaceEntry>,
417}
418
419#[derive(Debug, Clone, Default, Deserialize, Serialize)]
421pub struct RegistryDefaults {
422 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub workspace: Option<String>,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub bind: Option<String>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub search_deadline_ms: Option<u64>,
432}
433
434impl RegistryDefaults {
435 fn is_empty(&self) -> bool {
437 self.workspace.is_none() && self.bind.is_none() && self.search_deadline_ms.is_none()
438 }
439}
440
441#[derive(Debug, Clone, Deserialize, Serialize)]
443pub struct WorkspaceEntry {
444 pub root: PathBuf,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub config: Option<PathBuf>,
451 #[serde(default = "default_enabled")]
453 pub enabled: bool,
454}
455
456fn default_enabled() -> bool {
457 true
458}
459
460pub fn is_valid_workspace_id(id: &str) -> bool {
462 let mut chars = id.chars();
463 match chars.next() {
464 Some(c) if c.is_ascii_alphanumeric() => {}
465 _ => return false,
466 }
467 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
468}
469
470pub fn default_registry_path() -> PathBuf {
472 crate::ctx_dirs::config_dir().join("workspaces.toml")
473}
474
475impl WorkspaceRegistry {
476 pub fn load(path: &Path) -> anyhow::Result<Self> {
480 let content = std::fs::read_to_string(path)
481 .with_context(|| format!("failed to read workspace registry: {}", path.display()))?;
482 toml::from_str(&content)
483 .with_context(|| format!("failed to parse workspace registry: {}", path.display()))
484 }
485
486 pub fn to_toml(&self) -> anyhow::Result<String> {
488 toml::to_string_pretty(self).context("failed to serialize workspace registry")
489 }
490}
491
492pub fn build_multi_router(registry: &WorkspaceRegistry) -> anyhow::Result<WorkspaceRouter> {
499 if registry.workspaces.is_empty() {
500 anyhow::bail!("workspace registry has no [workspaces.*] entries");
501 }
502 if let Some(default) = ®istry.defaults.workspace {
503 if !registry.workspaces.contains_key(default) {
504 anyhow::bail!("[defaults].workspace = \"{default}\" is not a registered workspace");
505 }
506 }
507 let runtimes = registry
508 .workspaces
509 .iter()
510 .map(|(id, entry)| build_runtime(id, entry))
511 .collect();
512 Ok(
513 WorkspaceRouter::multi(runtimes, registry.defaults.workspace.clone())
514 .with_search_deadline(registry.defaults.search_deadline_ms),
515 )
516}
517
518fn build_runtime(id: &str, entry: &WorkspaceEntry) -> WorkspaceRuntime {
521 let resolution = if entry.config.is_some() {
522 ResolutionMode::PinnedConfig
523 } else {
524 ResolutionMode::RootMerge
525 };
526
527 let mut problems: Vec<String> = Vec::new();
528 if !is_valid_workspace_id(id) {
529 problems.push(format!("invalid workspace id '{id}'"));
530 }
531 if entry.root.is_relative() {
532 problems.push(format!("root must be absolute: {}", entry.root.display()));
533 }
534 if let Some(cfg) = &entry.config {
535 if cfg.is_relative() {
536 problems.push(format!("config must be absolute: {}", cfg.display()));
537 }
538 }
539
540 let unavailable = |reason: String| WorkspaceRuntime {
541 id: id.to_string(),
542 root: Some(entry.root.clone()),
543 enabled: entry.enabled,
544 config: Arc::new(Config::minimal()),
545 health: WorkspaceHealth::Unavailable(reason),
546 resolution,
547 };
548
549 if !problems.is_empty() {
550 return unavailable(problems.join("; "));
551 }
552
553 let resolved = match &entry.config {
554 Some(cfg) => crate::config::load_config_pinned(cfg, &entry.root),
555 None => crate::config::load_config_for_root(&entry.root),
556 };
557
558 match resolved {
559 Ok(rc) => WorkspaceRuntime {
560 id: id.to_string(),
561 root: Some(entry.root.clone()),
562 enabled: entry.enabled,
563 config: Arc::new(rc.config),
564 health: WorkspaceHealth::Ok,
565 resolution,
566 },
567 Err(e) => unavailable(format!("config error: {e}")),
568 }
569}
570
571fn write_registry(path: &Path, registry: &WorkspaceRegistry) -> anyhow::Result<()> {
577 if let Some(parent) = path.parent() {
578 std::fs::create_dir_all(parent)
579 .with_context(|| format!("failed to create config dir: {}", parent.display()))?;
580 }
581 std::fs::write(path, registry.to_toml()?)
582 .with_context(|| format!("failed to write workspace registry: {}", path.display()))?;
583 Ok(())
584}
585
586fn load_or_default(path: &Path) -> anyhow::Result<WorkspaceRegistry> {
588 if path.exists() {
589 WorkspaceRegistry::load(path)
590 } else {
591 Ok(WorkspaceRegistry::default())
592 }
593}
594
595pub fn cmd_add(
597 path: &Path,
598 id: &str,
599 root: &Path,
600 config: Option<&Path>,
601 enabled: bool,
602) -> anyhow::Result<()> {
603 if !is_valid_workspace_id(id) {
604 anyhow::bail!("invalid workspace id '{id}': must match [A-Za-z0-9][A-Za-z0-9_-]*");
605 }
606 let root_abs = std::fs::canonicalize(root)
610 .with_context(|| format!("workspace root does not exist: {}", root.display()))?;
611 let config_abs = match config {
612 Some(c) => Some(
613 std::fs::canonicalize(c)
614 .with_context(|| format!("config file does not exist: {}", c.display()))?,
615 ),
616 None => None,
617 };
618
619 let mut registry = load_or_default(path)?;
620 if registry.workspaces.contains_key(id) {
621 anyhow::bail!("workspace '{id}' is already registered; remove it first");
622 }
623 registry.workspaces.insert(
624 id.to_string(),
625 WorkspaceEntry {
626 root: root_abs,
627 config: config_abs,
628 enabled,
629 },
630 );
631 write_registry(path, ®istry)?;
632 println!(
633 "Added workspace '{id}'{}",
634 if enabled { "" } else { " (disabled)" }
635 );
636 Ok(())
637}
638
639pub fn cmd_list(path: &Path) -> anyhow::Result<()> {
641 if !path.exists() {
642 println!("No workspaces registered ({} not found)", path.display());
643 return Ok(());
644 }
645 let registry = WorkspaceRegistry::load(path)?;
646 let default = registry.defaults.workspace.as_deref();
647 println!("{:<20} {:<8} {:<8} ROOT", "ID", "ENABLED", "DEFAULT");
648 for (id, entry) in ®istry.workspaces {
649 println!(
650 "{:<20} {:<8} {:<8} {}",
651 id,
652 entry.enabled,
653 Some(id.as_str()) == default,
654 entry.root.display()
655 );
656 }
657 Ok(())
658}
659
660pub fn cmd_remove(path: &Path, id: &str) -> anyhow::Result<()> {
662 if !path.exists() {
663 anyhow::bail!("no workspace registry at {}", path.display());
664 }
665 let mut registry = WorkspaceRegistry::load(path)?;
666 if registry.workspaces.remove(id).is_none() {
667 anyhow::bail!("workspace '{id}' is not registered");
668 }
669 if registry.defaults.workspace.as_deref() == Some(id) {
671 registry.defaults.workspace = None;
672 }
673 write_registry(path, ®istry)?;
674 println!("Removed workspace '{id}'");
675 Ok(())
676}
677
678#[derive(Debug)]
680pub(crate) enum FanOut<T> {
681 Ok(T),
683 Failed(String),
685 TimedOut,
687}
688
689pub(crate) async fn fan_out<I, T, F, Fut>(
699 items: Vec<(String, I)>,
700 deadline: Duration,
701 max_concurrency: usize,
702 op: F,
703) -> Vec<(String, FanOut<T>)>
704where
705 I: Send + 'static,
706 T: Send + 'static,
707 F: Fn(I) -> Fut + Clone + Send + 'static,
708 Fut: Future<Output = anyhow::Result<T>> + Send + 'static,
709{
710 let order: Vec<String> = items.iter().map(|(id, _)| id.clone()).collect();
711 let sem = Arc::new(Semaphore::new(max_concurrency.max(1)));
712 let mut set: JoinSet<(String, FanOut<T>)> = JoinSet::new();
713 for (id, input) in items {
714 let sem = sem.clone();
715 let op = op.clone();
716 set.spawn(async move {
717 let _permit = sem
718 .acquire_owned()
719 .await
720 .expect("fan-out semaphore is never closed");
721 let outcome = match tokio::time::timeout(deadline, op(input)).await {
722 Ok(Ok(value)) => FanOut::Ok(value),
723 Ok(Err(e)) => FanOut::Failed(format!("{e:#}")),
724 Err(_) => FanOut::TimedOut,
725 };
726 (id, outcome)
727 });
728 }
729
730 let mut by_id: HashMap<String, FanOut<T>> = HashMap::new();
731 while let Some(joined) = set.join_next().await {
732 if let Ok((id, outcome)) = joined {
733 by_id.insert(id, outcome);
734 }
735 }
738
739 order
740 .into_iter()
741 .map(|id| {
742 let outcome = by_id
743 .remove(&id)
744 .unwrap_or_else(|| FanOut::Failed("workspace task did not complete".to_string()));
745 (id, outcome)
746 })
747 .collect()
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753
754 fn cfg() -> Arc<Config> {
755 Arc::new(crate::config::Config::minimal())
757 }
758
759 fn runtime(id: &str, enabled: bool, health: WorkspaceHealth) -> WorkspaceRuntime {
760 WorkspaceRuntime {
761 id: id.to_string(),
762 root: Some(PathBuf::from(format!("/abs/{id}"))),
763 enabled,
764 config: cfg(),
765 health,
766 resolution: ResolutionMode::RootMerge,
767 }
768 }
769
770 #[test]
771 fn single_router_resolves_default() {
772 let r = WorkspaceRouter::single(cfg());
773 assert_eq!(r.mode(), ServerMode::Compat);
774 let rt = r.resolve(None).unwrap();
775 assert_eq!(rt.id, "default");
776 }
777
778 #[test]
779 fn multi_requires_selector_when_ambiguous() {
780 let r = WorkspaceRouter::multi(
781 vec![
782 runtime("a", true, WorkspaceHealth::Ok),
783 runtime("b", true, WorkspaceHealth::Ok),
784 ],
785 None,
786 );
787 let err = r.resolve(None).unwrap_err();
788 assert_eq!(err.code(), "workspace_required");
789 }
790
791 #[test]
792 fn multi_single_enabled_used_without_default() {
793 let r = WorkspaceRouter::multi(
794 vec![
795 runtime("a", true, WorkspaceHealth::Ok),
796 runtime("b", false, WorkspaceHealth::Ok),
797 ],
798 None,
799 );
800 assert_eq!(r.resolve(None).unwrap().id, "a");
801 }
802
803 #[test]
804 fn multi_default_is_used() {
805 let r = WorkspaceRouter::multi(
806 vec![
807 runtime("a", true, WorkspaceHealth::Ok),
808 runtime("b", true, WorkspaceHealth::Ok),
809 ],
810 Some("b".to_string()),
811 );
812 assert_eq!(r.resolve(None).unwrap().id, "b");
813 }
814
815 #[test]
816 fn unknown_disabled_unavailable_errors() {
817 let r = WorkspaceRouter::multi(
818 vec![
819 runtime("a", true, WorkspaceHealth::Ok),
820 runtime("b", false, WorkspaceHealth::Ok),
821 runtime("c", true, WorkspaceHealth::Unavailable("bad config".into())),
822 ],
823 None,
824 );
825 assert_eq!(
826 r.resolve(Some("nope")).unwrap_err().code(),
827 "unknown_workspace"
828 );
829 assert_eq!(
830 r.resolve(Some("b")).unwrap_err().code(),
831 "workspace_disabled"
832 );
833 assert_eq!(
834 r.resolve(Some("c")).unwrap_err().code(),
835 "workspace_unavailable"
836 );
837 }
838
839 #[test]
840 fn all_selector_rejected_in_phase1() {
841 let r = WorkspaceRouter::multi(vec![runtime("a", true, WorkspaceHealth::Ok)], None);
842 assert_eq!(
843 r.resolve(Some("all")).unwrap_err().code(),
844 "unsupported_workspace_selector"
845 );
846 }
847
848 #[test]
849 fn qualified_id_only_for_registered_prefix() {
850 let r = WorkspaceRouter::multi(
851 vec![runtime("context_harness", true, WorkspaceHealth::Ok)],
852 None,
853 );
854 assert_eq!(
855 r.split_qualified_id("context_harness:01J"),
856 (Some("context_harness"), "01J")
857 );
858 assert_eq!(r.split_qualified_id("foo:bar"), (None, "foo:bar"));
860 assert_eq!(r.split_qualified_id("01Jabc"), (None, "01Jabc"));
861 }
862
863 #[test]
864 fn workspace_id_validation() {
865 assert!(is_valid_workspace_id("context_harness"));
866 assert!(is_valid_workspace_id("stack-app"));
867 assert!(is_valid_workspace_id("a"));
868 assert!(is_valid_workspace_id("ws1"));
869 assert!(!is_valid_workspace_id(""));
870 assert!(!is_valid_workspace_id("_leading"));
871 assert!(!is_valid_workspace_id("-leading"));
872 assert!(!is_valid_workspace_id("has space"));
873 assert!(!is_valid_workspace_id("has:colon"));
874 assert!(!is_valid_workspace_id("has/slash"));
875 }
876
877 fn seed_workspace(root: &Path) {
880 let ctx = root.join(".ctx");
881 std::fs::create_dir_all(ctx.join("data")).unwrap();
882 std::fs::write(
883 ctx.join("config.toml"),
884 "[db]\npath = \".ctx/data/ctx.sqlite\"\n\n\
885 [chunking]\nmax_tokens = 700\noverlap_tokens = 0\n\n\
886 [retrieval]\nfinal_limit = 12\n\n\
887 [server]\nbind = \"127.0.0.1:7331\"\n",
888 )
889 .unwrap();
890 }
891
892 #[test]
893 fn two_workspaces_resolve_to_distinct_absolute_db_paths() {
894 let tmp_a = tempfile::TempDir::new().unwrap();
897 let tmp_b = tempfile::TempDir::new().unwrap();
898 seed_workspace(tmp_a.path());
899 seed_workspace(tmp_b.path());
900
901 let mut workspaces = BTreeMap::new();
902 workspaces.insert(
903 "a".to_string(),
904 WorkspaceEntry {
905 root: tmp_a.path().to_path_buf(),
906 config: None,
907 enabled: true,
908 },
909 );
910 workspaces.insert(
911 "b".to_string(),
912 WorkspaceEntry {
913 root: tmp_b.path().to_path_buf(),
914 config: None,
915 enabled: true,
916 },
917 );
918 let registry = WorkspaceRegistry {
919 defaults: RegistryDefaults::default(),
920 workspaces,
921 };
922
923 let router = build_multi_router(®istry).unwrap();
924 let a = router.resolve(Some("a")).unwrap();
925 let b = router.resolve(Some("b")).unwrap();
926
927 assert!(a.config.db.path.is_absolute(), "a db path must be absolute");
928 assert!(b.config.db.path.is_absolute(), "b db path must be absolute");
929 assert_ne!(
930 a.config.db.path, b.config.db.path,
931 "stores must be distinct"
932 );
933 assert!(a.config.db.path.starts_with(tmp_a.path()));
934 assert!(b.config.db.path.starts_with(tmp_b.path()));
935 assert_eq!(a.resolution, ResolutionMode::RootMerge);
936 }
937
938 #[test]
939 fn relative_root_makes_workspace_unavailable() {
940 let mut workspaces = BTreeMap::new();
941 workspaces.insert(
942 "rel".to_string(),
943 WorkspaceEntry {
944 root: PathBuf::from("relative/path"),
945 config: None,
946 enabled: true,
947 },
948 );
949 let registry = WorkspaceRegistry {
950 defaults: RegistryDefaults::default(),
951 workspaces,
952 };
953 let router = build_multi_router(®istry).unwrap();
954 assert_eq!(router.list().len(), 1);
956 assert_eq!(
957 router.resolve(Some("rel")).unwrap_err().code(),
958 "workspace_unavailable"
959 );
960 }
961
962 #[test]
963 fn cmd_add_list_remove_roundtrip() {
964 let cfg_tmp = tempfile::TempDir::new().unwrap();
965 let reg_path = cfg_tmp.path().join("workspaces.toml");
966 let ws_a = tempfile::TempDir::new().unwrap();
967 let ws_b = tempfile::TempDir::new().unwrap();
968
969 cmd_add(®_path, "alpha", ws_a.path(), None, true).unwrap();
970 cmd_add(®_path, "beta", ws_b.path(), None, false).unwrap();
971
972 let reg = WorkspaceRegistry::load(®_path).unwrap();
973 assert_eq!(reg.workspaces.len(), 2);
974 assert!(reg.workspaces["alpha"].enabled);
975 assert!(!reg.workspaces["beta"].enabled);
976 assert!(reg.workspaces["alpha"].root.is_absolute());
977
978 assert!(cmd_add(®_path, "alpha", ws_a.path(), None, true).is_err());
980 assert!(cmd_add(®_path, "bad id", ws_a.path(), None, true).is_err());
981 assert!(cmd_add(
982 ®_path,
983 "gamma",
984 Path::new("/no/such/path/xyz123"),
985 None,
986 true
987 )
988 .is_err());
989
990 cmd_remove(®_path, "beta").unwrap();
991 let reg = WorkspaceRegistry::load(®_path).unwrap();
992 assert_eq!(reg.workspaces.len(), 1);
993 assert!(cmd_remove(®_path, "beta").is_err());
994 }
995
996 #[test]
997 fn registry_roundtrips_through_toml() {
998 let toml_src = "[defaults]\nworkspace = \"a\"\n\n\
999 [workspaces.a]\nroot = \"/abs/a\"\nenabled = true\n";
1000 let reg: WorkspaceRegistry = toml::from_str(toml_src).unwrap();
1001 assert_eq!(reg.defaults.workspace.as_deref(), Some("a"));
1002 assert_eq!(reg.workspaces["a"].root, PathBuf::from("/abs/a"));
1003 assert!(reg.workspaces["a"].enabled);
1004 let out = reg.to_toml().unwrap();
1006 let reparsed: WorkspaceRegistry = toml::from_str(&out).unwrap();
1007 assert_eq!(reparsed.workspaces["a"].root, PathBuf::from("/abs/a"));
1008 }
1009
1010 #[test]
1011 fn registry_parses_search_deadline_ms() {
1012 let toml = "[defaults]\nworkspace = \"a\"\nsearch_deadline_ms = 1234\n\n\
1013 [workspaces.a]\nroot = \"/abs/a\"\nenabled = true\n";
1014 let reg: WorkspaceRegistry = toml::from_str(toml).unwrap();
1015 assert_eq!(reg.defaults.search_deadline_ms, Some(1234));
1016 let reg2 = WorkspaceRegistry {
1018 defaults: RegistryDefaults::default(),
1019 workspaces: reg.workspaces.clone(),
1020 };
1021 let out = toml::to_string(®2).unwrap();
1022 assert!(
1023 !out.contains("search_deadline_ms"),
1024 "unset field is omitted"
1025 );
1026 }
1027
1028 #[test]
1029 fn workspace_timeout_error_code() {
1030 let e = RouterError::WorkspaceTimeout {
1031 id: "beta".to_string(),
1032 deadline_ms: 5000,
1033 };
1034 assert_eq!(e.code(), "workspace_timeout");
1035 assert!(e.to_string().contains("beta"));
1036 assert!(e.to_string().contains("5000"));
1037 }
1038
1039 #[test]
1040 fn resolve_all_partitions_by_enabled_and_health() {
1041 let r = WorkspaceRouter::multi(
1042 vec![
1043 runtime("a", true, WorkspaceHealth::Ok),
1044 runtime("b", false, WorkspaceHealth::Ok), runtime("c", true, WorkspaceHealth::Unavailable("boom".to_string())),
1046 runtime("d", true, WorkspaceHealth::Ok),
1047 ],
1048 None,
1049 );
1050 let (healthy, errors) = r.resolve_all();
1051 assert_eq!(
1053 healthy.iter().map(|rt| rt.id.clone()).collect::<Vec<_>>(),
1054 vec!["a".to_string(), "d".to_string()]
1055 );
1056 assert_eq!(errors.len(), 1);
1058 assert_eq!(errors[0].0, "c");
1059 assert_eq!(errors[0].1.code(), "workspace_unavailable");
1060 }
1061
1062 #[test]
1063 fn with_search_deadline_overrides_default() {
1064 let r = WorkspaceRouter::multi(vec![runtime("a", true, WorkspaceHealth::Ok)], None);
1065 assert_eq!(
1066 r.search_deadline().as_millis(),
1067 DEFAULT_SEARCH_DEADLINE_MS as u128
1068 );
1069 let r = r.with_search_deadline(Some(250));
1070 assert_eq!(r.search_deadline().as_millis(), 250);
1071 let r = r.with_search_deadline(None);
1073 assert_eq!(r.search_deadline().as_millis(), 250);
1074 }
1075
1076 #[tokio::test]
1077 async fn fan_out_classifies_and_preserves_order() {
1078 let items = vec![
1079 ("ok".to_string(), 1u32),
1080 ("fail".to_string(), 2u32),
1081 ("slow".to_string(), 3u32),
1082 ];
1083 let outcomes = fan_out(items, Duration::from_millis(50), 4, |n: u32| async move {
1084 match n {
1085 1 => Ok("done".to_string()),
1086 2 => Err(anyhow::anyhow!("boom")),
1087 _ => {
1088 tokio::time::sleep(Duration::from_millis(500)).await;
1089 Ok("late".to_string())
1090 }
1091 }
1092 })
1093 .await;
1094
1095 assert_eq!(
1096 outcomes
1097 .iter()
1098 .map(|(id, _)| id.clone())
1099 .collect::<Vec<_>>(),
1100 vec!["ok".to_string(), "fail".to_string(), "slow".to_string()],
1101 "order follows input order"
1102 );
1103 assert!(matches!(&outcomes[0].1, FanOut::Ok(s) if s == "done"));
1104 assert!(matches!(&outcomes[1].1, FanOut::Failed(m) if m.contains("boom")));
1105 assert!(matches!(&outcomes[2].1, FanOut::TimedOut));
1106 }
1107
1108 #[tokio::test]
1109 async fn fan_out_respects_concurrency_cap() {
1110 use std::sync::atomic::{AtomicUsize, Ordering};
1111 let inflight = std::sync::Arc::new(AtomicUsize::new(0));
1112 let max_seen = std::sync::Arc::new(AtomicUsize::new(0));
1113 let items: Vec<(String, ())> = (0..8).map(|i| (i.to_string(), ())).collect();
1114 let inflight_op = inflight.clone();
1115 let max_op = max_seen.clone();
1116 let outcomes = fan_out(items, Duration::from_millis(1000), 2, move |_: ()| {
1117 let inflight = inflight_op.clone();
1118 let max_seen = max_op.clone();
1119 async move {
1120 let cur = inflight.fetch_add(1, Ordering::SeqCst) + 1;
1121 max_seen.fetch_max(cur, Ordering::SeqCst);
1122 tokio::time::sleep(Duration::from_millis(20)).await;
1123 inflight.fetch_sub(1, Ordering::SeqCst);
1124 Ok::<_, anyhow::Error>(())
1125 }
1126 })
1127 .await;
1128 assert_eq!(outcomes.len(), 8);
1129 assert!(
1130 max_seen.load(Ordering::SeqCst) <= 2,
1131 "observed {} concurrent tasks, cap was 2",
1132 max_seen.load(Ordering::SeqCst)
1133 );
1134 }
1135}