1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::core::bm25_index::{BM25Index, SearchResult};
7
8const DEFAULT_RRF_K: f64 = 60.0;
10
11const MAX_ROOTS: usize = 16;
13
14#[derive(Debug, Clone)]
16pub struct RepoSearchResult {
17 pub repo_alias: String,
18 pub repo_path: String,
19 pub file_path: String,
20 pub symbol_name: String,
21 pub content: String,
22 pub start_line: usize,
23 pub end_line: usize,
24 pub score: f64,
25}
26
27#[derive(Debug, Clone)]
29pub struct FusedSearchResult {
30 pub repo_alias: String,
31 pub repo_path: String,
32 pub file_path: String,
33 pub symbol_name: String,
34 pub content: String,
35 pub start_line: usize,
36 pub end_line: usize,
37 pub rrf_score: f64,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct RepoRootConfig {
43 pub path: String,
44 #[serde(default)]
45 pub alias: Option<String>,
46}
47
48impl RepoRootConfig {
49 pub fn effective_alias(&self) -> String {
50 self.alias.clone().unwrap_or_else(|| {
51 Path::new(&self.path)
52 .file_name()
53 .and_then(|n| n.to_str())
54 .unwrap_or("unknown")
55 .to_string()
56 })
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62pub struct MultiRepoConfig {
63 #[serde(default)]
64 pub repos: Vec<RepoRootConfig>,
65 #[serde(default)]
66 pub rrf_k: Option<f64>,
67}
68
69impl MultiRepoConfig {
70 pub fn load() -> Self {
71 let config_path = config_file_path();
72 if !config_path.exists() {
73 return Self::default();
74 }
75 match std::fs::read_to_string(&config_path) {
76 Ok(content) => toml::from_str(&content).unwrap_or_default(),
77 Err(_) => Self::default(),
78 }
79 }
80
81 pub fn save(&self) -> Result<(), String> {
82 let config_path = config_file_path();
83 if let Some(parent) = config_path.parent() {
84 std::fs::create_dir_all(parent)
85 .map_err(|e| format!("Failed to create config dir: {e}"))?;
86 }
87 let content =
88 toml::to_string_pretty(self).map_err(|e| format!("Failed to serialize config: {e}"))?;
89 let defaults = toml::to_string_pretty(&Self::default())
90 .map_err(|e| format!("Failed to serialize defaults: {e}"))?;
91 crate::config_io::write_toml_preserving_minimal(&config_path, &content, &defaults)
92 .map_err(|e| format!("Failed to write config: {e}"))?;
93 Ok(())
94 }
95}
96
97pub struct ActiveRepoRoot {
99 pub config: RepoRootConfig,
100 pub path: PathBuf,
101 index: Option<BM25Index>,
102}
103
104impl std::fmt::Debug for ActiveRepoRoot {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 f.debug_struct("ActiveRepoRoot")
107 .field("config", &self.config)
108 .field("path", &self.path)
109 .field("has_index", &self.index.is_some())
110 .finish()
111 }
112}
113
114impl ActiveRepoRoot {
115 fn new(config: RepoRootConfig) -> Result<Self, String> {
116 let path = PathBuf::from(&config.path);
117 if !path.is_dir() {
118 return Err(format!(
119 "Path does not exist or is not a directory: {}",
120 config.path
121 ));
122 }
123 let path = path
124 .canonicalize()
125 .map_err(|e| format!("Cannot canonicalize {}: {e}", config.path))?;
126 Ok(Self {
127 config,
128 path,
129 index: None,
130 })
131 }
132
133 fn ensure_index(&mut self) {
134 if self.index.is_some() {
135 return;
136 }
137 self.index = Some(BM25Index::load_or_build(&self.path));
138 }
139
140 pub fn alias(&self) -> String {
141 self.config.effective_alias()
142 }
143
144 pub fn search(&mut self, query: &str, max_results: usize) -> Vec<RepoSearchResult> {
145 self.ensure_index();
146 let Some(ref index) = self.index else {
147 return Vec::new();
148 };
149
150 let results: Vec<SearchResult> = index.search(query, max_results);
151 let alias = self.alias();
152 let repo_path = self.path.to_string_lossy().to_string();
153
154 results
155 .into_iter()
156 .enumerate()
157 .map(|(rank, sr)| RepoSearchResult {
158 repo_alias: alias.clone(),
159 repo_path: repo_path.clone(),
160 file_path: sr.file_path,
161 symbol_name: sr.symbol_name,
162 content: sr.snippet,
163 start_line: sr.start_line,
164 end_line: sr.end_line,
165 score: 1.0 / (rank as f64 + 1.0),
166 })
167 .collect()
168 }
169}
170
171pub struct MultiRepoManager {
173 roots: Vec<ActiveRepoRoot>,
174 rrf_k: f64,
175}
176
177impl MultiRepoManager {
178 pub fn new() -> Self {
179 Self {
180 roots: Vec::new(),
181 rrf_k: DEFAULT_RRF_K,
182 }
183 }
184
185 pub fn with_rrf_k(mut self, k: f64) -> Self {
186 self.rrf_k = k;
187 self
188 }
189
190 pub fn from_config(config: &MultiRepoConfig) -> Result<Self, String> {
191 let mut manager = Self::new();
192 if let Some(k) = config.rrf_k {
193 manager.rrf_k = k;
194 }
195 for repo_config in &config.repos {
196 manager.add_root_config(repo_config.clone())?;
197 }
198 Ok(manager)
199 }
200
201 pub fn add_root(&mut self, path: &str, alias: Option<&str>) -> Result<(), String> {
202 if self.roots.len() >= MAX_ROOTS {
203 return Err(format!("Maximum number of roots ({MAX_ROOTS}) reached"));
204 }
205 let config = RepoRootConfig {
206 path: path.to_string(),
207 alias: alias.map(String::from),
208 };
209 let root = ActiveRepoRoot::new(config)?;
210 if !authorized(&root.path) {
211 return Err(format!(
212 "{path} is not a root of the launcher-bound Workspace — repo roots cannot widen it"
213 ));
214 }
215 if self.roots.iter().any(|r| r.path == root.path) {
216 return Err(format!("Root already exists: {path}"));
217 }
218 self.roots.push(root);
219 Ok(())
220 }
221
222 fn add_root_config(&mut self, config: RepoRootConfig) -> Result<(), String> {
223 if self.roots.len() >= MAX_ROOTS {
224 return Err(format!("Maximum number of roots ({MAX_ROOTS}) reached"));
225 }
226 let root = ActiveRepoRoot::new(config)?;
227 if self.roots.iter().any(|r| r.path == root.path) {
228 return Err(format!(
229 "Root already exists: {}",
230 root.path.to_string_lossy()
231 ));
232 }
233 self.roots.push(root);
234 Ok(())
235 }
236
237 pub fn remove_root(&mut self, path: &str) -> Result<(), String> {
238 let normalized = PathBuf::from(path)
239 .canonicalize()
240 .unwrap_or_else(|_| PathBuf::from(path));
241 let before = self.roots.len();
242 self.roots
243 .retain(|r| r.path != normalized && r.config.path != path);
244 if self.roots.len() == before {
245 return Err(format!("Root not found: {path}"));
246 }
247 Ok(())
248 }
249
250 pub fn list_roots(&self) -> Vec<RootInfo> {
251 self.roots
252 .iter()
253 .filter(|r| authorized(&r.path))
254 .map(|r| RootInfo {
255 path: r.path.to_string_lossy().to_string(),
256 alias: r.alias(),
257 has_index: r.index.is_some(),
258 })
259 .collect()
260 }
261
262 pub fn root_count(&self) -> usize {
263 self.roots.len()
264 }
265
266 pub fn rrf_k(&self) -> f64 {
269 self.rrf_k
270 }
271
272 pub fn is_active(&self) -> bool {
273 self.roots.len() > 1
274 }
275
276 pub fn resolve_root(&self, repo: &str) -> Option<usize> {
278 self.roots.iter().position(|r| {
279 r.alias() == repo || r.config.path == repo || r.path.to_string_lossy() == repo
280 })
281 }
282
283 pub fn search(
285 &mut self,
286 query: &str,
287 max_results: usize,
288 filter_roots: Option<&[String]>,
289 ) -> Vec<FusedSearchResult> {
290 let per_root_max = (max_results * 2).max(20);
291
292 let mut all_results: HashMap<String, FusedSearchResult> = HashMap::new();
293
294 for root in &mut self.roots {
295 if !authorized(&root.path) {
296 continue;
297 }
298 if let Some(filter) = filter_roots {
299 let alias = root.alias();
300 let path = root.path.to_string_lossy().to_string();
301 if !filter.iter().any(|f| f == &alias || f == &path) {
302 continue;
303 }
304 }
305
306 let results = root.search(query, per_root_max);
307
308 for (rank, result) in results.iter().enumerate() {
309 let rrf_contribution = 1.0 / (self.rrf_k + rank as f64 + 1.0);
310 let key = format!(
311 "{}:{}:{}",
312 result.repo_alias, result.file_path, result.start_line
313 );
314
315 all_results
316 .entry(key)
317 .and_modify(|existing| {
318 existing.rrf_score += rrf_contribution;
319 })
320 .or_insert_with(|| FusedSearchResult {
321 repo_alias: result.repo_alias.clone(),
322 repo_path: result.repo_path.clone(),
323 file_path: result.file_path.clone(),
324 symbol_name: result.symbol_name.clone(),
325 content: result.content.clone(),
326 start_line: result.start_line,
327 end_line: result.end_line,
328 rrf_score: rrf_contribution,
329 });
330 }
331 }
332
333 let mut fused: Vec<FusedSearchResult> = all_results.into_values().collect();
334 fused.sort_by(|a, b| {
335 b.rrf_score
336 .partial_cmp(&a.rrf_score)
337 .unwrap_or(std::cmp::Ordering::Equal)
338 });
339 fused.truncate(max_results);
340 fused
341 }
342
343 pub fn search_single_repo(
345 &mut self,
346 repo: &str,
347 query: &str,
348 max_results: usize,
349 ) -> Result<Vec<RepoSearchResult>, String> {
350 let idx = self
351 .resolve_root(repo)
352 .ok_or_else(|| format!("Unknown repo: {repo}"))?;
353 if !authorized(&self.roots[idx].path) {
354 return Err(format!(
355 "{repo} is not a root of the launcher-bound Workspace"
356 ));
357 }
358 Ok(self.roots[idx].search(query, max_results))
359 }
360}
361
362impl Default for MultiRepoManager {
363 fn default() -> Self {
364 Self::new()
365 }
366}
367
368#[derive(Debug, Clone, Serialize)]
370pub struct RootInfo {
371 pub path: String,
372 pub alias: String,
373 pub has_index: bool,
374}
375
376pub fn config_file_path() -> PathBuf {
382 crate::core::paths::config_dir_member("multi-repo.toml")
383 .unwrap_or_else(|_| PathBuf::from("~/.config/lean-ctx/multi-repo.toml"))
384}
385
386static GLOBAL_MANAGER: std::sync::OnceLock<std::sync::Mutex<MultiRepoManager>> =
388 std::sync::OnceLock::new();
389
390pub fn global_manager() -> &'static std::sync::Mutex<MultiRepoManager> {
391 GLOBAL_MANAGER.get_or_init(|| {
392 let config = MultiRepoConfig::load();
393 let manager = MultiRepoManager::from_config(&config).unwrap_or_default();
394 std::sync::Mutex::new(manager)
395 })
396}
397
398pub fn init_with_roots(
400 roots: &[(String, Option<String>)],
401 rrf_k: Option<f64>,
402) -> Result<(), String> {
403 let mut manager = MultiRepoManager::new();
404 if let Some(k) = rrf_k {
405 manager.rrf_k = k;
406 }
407 for (path, alias) in roots {
408 manager.add_root(path, alias.as_deref())?;
409 }
410 GLOBAL_MANAGER
411 .set(std::sync::Mutex::new(manager))
412 .map_err(|_| "Multi-repo manager already initialized".to_string())
413}
414
415pub fn resolve_repo_root(repo: &str) -> Option<String> {
419 let manager = global_manager();
420 let mgr = manager.lock().ok()?;
421 let idx = mgr.resolve_root(repo)?;
422 let root = &mgr.roots[idx].path;
423 authorized(root).then(|| root.to_string_lossy().to_string())
424}
425
426pub fn known_aliases() -> Vec<String> {
430 let manager = global_manager();
431 let Ok(mgr) = manager.lock() else {
432 return Vec::new();
433 };
434 mgr.roots.iter().map(ActiveRepoRoot::alias).collect()
435}
436
437pub fn is_multi_repo_active() -> bool {
439 let manager = global_manager();
440 manager.lock().is_ok_and(|mgr| mgr.is_active())
441}
442
443pub fn all_root_paths() -> Vec<String> {
445 let manager = global_manager();
446 let Ok(mgr) = manager.lock() else {
447 return Vec::new();
448 };
449 mgr.roots
450 .iter()
451 .filter(|r| authorized(&r.path))
452 .map(|r| r.path.to_string_lossy().to_string())
453 .collect()
454}
455
456fn authorized(root: &Path) -> bool {
461 crate::core::workspace_authority::root_authorized(root)
462}
463
464pub fn format_fused_results(results: &[FusedSearchResult]) -> String {
466 if results.is_empty() {
467 return "No results found across repos.".to_string();
468 }
469
470 let mut out = String::with_capacity(results.len() * 200);
471 out.push_str(&format!(
472 "Cross-repo results ({} matches):\n\n",
473 results.len()
474 ));
475
476 for (i, result) in results.iter().enumerate() {
477 out.push_str(&format!(
478 "{}. [{}] {}:{}-{} ({})\n RRF: {:.4}\n",
479 i + 1,
480 result.repo_alias,
481 result.file_path,
482 result.start_line,
483 result.end_line,
484 result.symbol_name,
485 result.rrf_score,
486 ));
487 let preview: String = result
488 .content
489 .lines()
490 .take(3)
491 .collect::<Vec<_>>()
492 .join("\n");
493 if !preview.is_empty() {
494 out.push_str(&format!(" {}\n", preview.replace('\n', "\n ")));
495 }
496 out.push('\n');
497 }
498 out
499}
500
501#[cfg(test)]
502pub mod tests {
503 use super::*;
504
505 #[test]
506 fn repo_root_config_effective_alias() {
507 let cfg = RepoRootConfig {
508 path: "/home/user/projects/backend".to_string(),
509 alias: None,
510 };
511 assert_eq!(cfg.effective_alias(), "backend");
512
513 let cfg_with_alias = RepoRootConfig {
514 path: "/home/user/projects/backend".to_string(),
515 alias: Some("api".to_string()),
516 };
517 assert_eq!(cfg_with_alias.effective_alias(), "api");
518 }
519
520 #[test]
521 fn multi_repo_config_default_is_empty() {
522 let cfg = MultiRepoConfig::default();
523 assert!(cfg.repos.is_empty());
524 assert!(cfg.rrf_k.is_none());
525 }
526
527 #[test]
528 fn multi_repo_config_deserialize() {
529 let toml_str = r#"
530rrf_k = 45.0
531
532[[repos]]
533path = "/home/user/backend"
534alias = "backend"
535
536[[repos]]
537path = "/home/user/frontend"
538"#;
539 let cfg: MultiRepoConfig = toml::from_str(toml_str).unwrap();
540 assert_eq!(cfg.repos.len(), 2);
541 assert_eq!(cfg.rrf_k, Some(45.0));
542 assert_eq!(cfg.repos[0].alias, Some("backend".to_string()));
543 assert_eq!(cfg.repos[1].alias, None);
544 }
545
546 #[test]
547 fn manager_max_roots_enforced() {
548 let mut manager = MultiRepoManager::new();
549 for i in 0..MAX_ROOTS {
550 let dir = std::env::temp_dir().join(format!("multi_repo_test_{i}"));
551 let _ = std::fs::create_dir_all(&dir);
552 let _ = manager.add_root(&dir.to_string_lossy(), Some(&format!("repo{i}")));
553 }
554 let extra = std::env::temp_dir().join("multi_repo_test_extra");
555 let _ = std::fs::create_dir_all(&extra);
556 let result = manager.add_root(&extra.to_string_lossy(), None);
557 assert!(result.is_err());
558
559 for i in 0..=MAX_ROOTS {
560 let dir = std::env::temp_dir().join(format!("multi_repo_test_{i}"));
561 let _ = std::fs::remove_dir_all(&dir);
562 }
563 let _ = std::fs::remove_dir_all(&extra);
564 }
565
566 #[test]
567 fn manager_duplicate_root_rejected() {
568 let dir = std::env::temp_dir().join("multi_repo_dup_test");
569 let _ = std::fs::create_dir_all(&dir);
570 let mut manager = MultiRepoManager::new();
571 assert!(
572 manager
573 .add_root(&dir.to_string_lossy(), Some("first"))
574 .is_ok()
575 );
576 assert!(
577 manager
578 .add_root(&dir.to_string_lossy(), Some("second"))
579 .is_err()
580 );
581 let _ = std::fs::remove_dir_all(&dir);
582 }
583
584 #[test]
585 fn rrf_fusion_basic() {
586 let manager = MultiRepoManager::new().with_rrf_k(60.0);
587 let score: f64 = 1.0 / (60.0 + 0.0 + 1.0);
589 assert!((score - 0.01639).abs() < 0.001);
590
591 assert_eq!(manager.rrf_k, 60.0);
592 }
593
594 #[test]
595 fn remove_root_works() {
596 let dir = std::env::temp_dir().join("multi_repo_remove_test");
597 let _ = std::fs::create_dir_all(&dir);
598 let mut manager = MultiRepoManager::new();
599 manager
600 .add_root(&dir.to_string_lossy(), Some("removable"))
601 .unwrap();
602 assert_eq!(manager.root_count(), 1);
603 manager.remove_root(&dir.to_string_lossy()).unwrap();
604 assert_eq!(manager.root_count(), 0);
605 let _ = std::fs::remove_dir_all(&dir);
606 }
607
608 #[test]
609 fn list_roots_returns_info() {
610 let dir = std::env::temp_dir().join("multi_repo_list_test");
611 let _ = std::fs::create_dir_all(&dir);
612 let mut manager = MultiRepoManager::new();
613 manager
614 .add_root(&dir.to_string_lossy(), Some("myrepo"))
615 .unwrap();
616 let roots = manager.list_roots();
617 assert_eq!(roots.len(), 1);
618 assert_eq!(roots[0].alias, "myrepo");
619 let _ = std::fs::remove_dir_all(&dir);
620 }
621
622 #[test]
623 fn format_empty_results() {
624 let results: Vec<FusedSearchResult> = Vec::new();
625 let output = format_fused_results(&results);
626 assert!(output.contains("No results"));
627 }
628}