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 self.roots.iter().any(|r| r.path == root.path) {
211 return Err(format!("Root already exists: {path}"));
212 }
213 self.roots.push(root);
214 Ok(())
215 }
216
217 fn add_root_config(&mut self, config: RepoRootConfig) -> Result<(), String> {
218 if self.roots.len() >= MAX_ROOTS {
219 return Err(format!("Maximum number of roots ({MAX_ROOTS}) reached"));
220 }
221 let root = ActiveRepoRoot::new(config)?;
222 if self.roots.iter().any(|r| r.path == root.path) {
223 return Err(format!(
224 "Root already exists: {}",
225 root.path.to_string_lossy()
226 ));
227 }
228 self.roots.push(root);
229 Ok(())
230 }
231
232 pub fn remove_root(&mut self, path: &str) -> Result<(), String> {
233 let normalized = PathBuf::from(path)
234 .canonicalize()
235 .unwrap_or_else(|_| PathBuf::from(path));
236 let before = self.roots.len();
237 self.roots
238 .retain(|r| r.path != normalized && r.config.path != path);
239 if self.roots.len() == before {
240 return Err(format!("Root not found: {path}"));
241 }
242 Ok(())
243 }
244
245 pub fn list_roots(&self) -> Vec<RootInfo> {
246 self.roots
247 .iter()
248 .map(|r| RootInfo {
249 path: r.path.to_string_lossy().to_string(),
250 alias: r.alias(),
251 has_index: r.index.is_some(),
252 })
253 .collect()
254 }
255
256 pub fn root_count(&self) -> usize {
257 self.roots.len()
258 }
259
260 pub fn rrf_k(&self) -> f64 {
263 self.rrf_k
264 }
265
266 pub fn is_active(&self) -> bool {
267 self.roots.len() > 1
268 }
269
270 pub fn resolve_root(&self, repo: &str) -> Option<usize> {
272 self.roots.iter().position(|r| {
273 r.alias() == repo || r.config.path == repo || r.path.to_string_lossy() == repo
274 })
275 }
276
277 pub fn search(
279 &mut self,
280 query: &str,
281 max_results: usize,
282 filter_roots: Option<&[String]>,
283 ) -> Vec<FusedSearchResult> {
284 let per_root_max = (max_results * 2).max(20);
285
286 let mut all_results: HashMap<String, FusedSearchResult> = HashMap::new();
287
288 for root in &mut self.roots {
289 if let Some(filter) = filter_roots {
290 let alias = root.alias();
291 let path = root.path.to_string_lossy().to_string();
292 if !filter.iter().any(|f| f == &alias || f == &path) {
293 continue;
294 }
295 }
296
297 let results = root.search(query, per_root_max);
298
299 for (rank, result) in results.iter().enumerate() {
300 let rrf_contribution = 1.0 / (self.rrf_k + rank as f64 + 1.0);
301 let key = format!(
302 "{}:{}:{}",
303 result.repo_alias, result.file_path, result.start_line
304 );
305
306 all_results
307 .entry(key)
308 .and_modify(|existing| {
309 existing.rrf_score += rrf_contribution;
310 })
311 .or_insert_with(|| FusedSearchResult {
312 repo_alias: result.repo_alias.clone(),
313 repo_path: result.repo_path.clone(),
314 file_path: result.file_path.clone(),
315 symbol_name: result.symbol_name.clone(),
316 content: result.content.clone(),
317 start_line: result.start_line,
318 end_line: result.end_line,
319 rrf_score: rrf_contribution,
320 });
321 }
322 }
323
324 let mut fused: Vec<FusedSearchResult> = all_results.into_values().collect();
325 fused.sort_by(|a, b| {
326 b.rrf_score
327 .partial_cmp(&a.rrf_score)
328 .unwrap_or(std::cmp::Ordering::Equal)
329 });
330 fused.truncate(max_results);
331 fused
332 }
333
334 pub fn search_single_repo(
336 &mut self,
337 repo: &str,
338 query: &str,
339 max_results: usize,
340 ) -> Result<Vec<RepoSearchResult>, String> {
341 let idx = self
342 .resolve_root(repo)
343 .ok_or_else(|| format!("Unknown repo: {repo}"))?;
344 Ok(self.roots[idx].search(query, max_results))
345 }
346}
347
348impl Default for MultiRepoManager {
349 fn default() -> Self {
350 Self::new()
351 }
352}
353
354#[derive(Debug, Clone, Serialize)]
356pub struct RootInfo {
357 pub path: String,
358 pub alias: String,
359 pub has_index: bool,
360}
361
362pub fn config_file_path() -> PathBuf {
368 crate::core::paths::config_dir_member("multi-repo.toml")
369 .unwrap_or_else(|_| PathBuf::from("~/.config/lean-ctx/multi-repo.toml"))
370}
371
372static GLOBAL_MANAGER: std::sync::OnceLock<std::sync::Mutex<MultiRepoManager>> =
374 std::sync::OnceLock::new();
375
376pub fn global_manager() -> &'static std::sync::Mutex<MultiRepoManager> {
377 GLOBAL_MANAGER.get_or_init(|| {
378 let config = MultiRepoConfig::load();
379 let manager = MultiRepoManager::from_config(&config).unwrap_or_default();
380 std::sync::Mutex::new(manager)
381 })
382}
383
384pub fn init_with_roots(
386 roots: &[(String, Option<String>)],
387 rrf_k: Option<f64>,
388) -> Result<(), String> {
389 let mut manager = MultiRepoManager::new();
390 if let Some(k) = rrf_k {
391 manager.rrf_k = k;
392 }
393 for (path, alias) in roots {
394 manager.add_root(path, alias.as_deref())?;
395 }
396 GLOBAL_MANAGER
397 .set(std::sync::Mutex::new(manager))
398 .map_err(|_| "Multi-repo manager already initialized".to_string())
399}
400
401pub fn resolve_repo_root(repo: &str) -> Option<String> {
405 let manager = global_manager();
406 let mgr = manager.lock().ok()?;
407 let idx = mgr.resolve_root(repo)?;
408 Some(mgr.roots[idx].path.to_string_lossy().to_string())
409}
410
411pub fn known_aliases() -> Vec<String> {
415 let manager = global_manager();
416 let Ok(mgr) = manager.lock() else {
417 return Vec::new();
418 };
419 mgr.roots.iter().map(ActiveRepoRoot::alias).collect()
420}
421
422pub fn is_multi_repo_active() -> bool {
424 let manager = global_manager();
425 manager.lock().is_ok_and(|mgr| mgr.is_active())
426}
427
428pub fn all_root_paths() -> Vec<String> {
430 let manager = global_manager();
431 let Ok(mgr) = manager.lock() else {
432 return Vec::new();
433 };
434 mgr.roots
435 .iter()
436 .map(|r| r.path.to_string_lossy().to_string())
437 .collect()
438}
439
440pub fn format_fused_results(results: &[FusedSearchResult]) -> String {
442 if results.is_empty() {
443 return "No results found across repos.".to_string();
444 }
445
446 let mut out = String::with_capacity(results.len() * 200);
447 out.push_str(&format!(
448 "Cross-repo results ({} matches):\n\n",
449 results.len()
450 ));
451
452 for (i, result) in results.iter().enumerate() {
453 out.push_str(&format!(
454 "{}. [{}] {}:{}-{} ({})\n RRF: {:.4}\n",
455 i + 1,
456 result.repo_alias,
457 result.file_path,
458 result.start_line,
459 result.end_line,
460 result.symbol_name,
461 result.rrf_score,
462 ));
463 let preview: String = result
464 .content
465 .lines()
466 .take(3)
467 .collect::<Vec<_>>()
468 .join("\n");
469 if !preview.is_empty() {
470 out.push_str(&format!(" {}\n", preview.replace('\n', "\n ")));
471 }
472 out.push('\n');
473 }
474 out
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480
481 #[test]
482 fn repo_root_config_effective_alias() {
483 let cfg = RepoRootConfig {
484 path: "/home/user/projects/backend".to_string(),
485 alias: None,
486 };
487 assert_eq!(cfg.effective_alias(), "backend");
488
489 let cfg_with_alias = RepoRootConfig {
490 path: "/home/user/projects/backend".to_string(),
491 alias: Some("api".to_string()),
492 };
493 assert_eq!(cfg_with_alias.effective_alias(), "api");
494 }
495
496 #[test]
497 fn multi_repo_config_default_is_empty() {
498 let cfg = MultiRepoConfig::default();
499 assert!(cfg.repos.is_empty());
500 assert!(cfg.rrf_k.is_none());
501 }
502
503 #[test]
504 fn multi_repo_config_deserialize() {
505 let toml_str = r#"
506rrf_k = 45.0
507
508[[repos]]
509path = "/home/user/backend"
510alias = "backend"
511
512[[repos]]
513path = "/home/user/frontend"
514"#;
515 let cfg: MultiRepoConfig = toml::from_str(toml_str).unwrap();
516 assert_eq!(cfg.repos.len(), 2);
517 assert_eq!(cfg.rrf_k, Some(45.0));
518 assert_eq!(cfg.repos[0].alias, Some("backend".to_string()));
519 assert_eq!(cfg.repos[1].alias, None);
520 }
521
522 #[test]
523 fn manager_max_roots_enforced() {
524 let mut manager = MultiRepoManager::new();
525 for i in 0..MAX_ROOTS {
526 let dir = std::env::temp_dir().join(format!("multi_repo_test_{i}"));
527 let _ = std::fs::create_dir_all(&dir);
528 let _ = manager.add_root(&dir.to_string_lossy(), Some(&format!("repo{i}")));
529 }
530 let extra = std::env::temp_dir().join("multi_repo_test_extra");
531 let _ = std::fs::create_dir_all(&extra);
532 let result = manager.add_root(&extra.to_string_lossy(), None);
533 assert!(result.is_err());
534
535 for i in 0..=MAX_ROOTS {
536 let dir = std::env::temp_dir().join(format!("multi_repo_test_{i}"));
537 let _ = std::fs::remove_dir_all(&dir);
538 }
539 let _ = std::fs::remove_dir_all(&extra);
540 }
541
542 #[test]
543 fn manager_duplicate_root_rejected() {
544 let dir = std::env::temp_dir().join("multi_repo_dup_test");
545 let _ = std::fs::create_dir_all(&dir);
546 let mut manager = MultiRepoManager::new();
547 assert!(
548 manager
549 .add_root(&dir.to_string_lossy(), Some("first"))
550 .is_ok()
551 );
552 assert!(
553 manager
554 .add_root(&dir.to_string_lossy(), Some("second"))
555 .is_err()
556 );
557 let _ = std::fs::remove_dir_all(&dir);
558 }
559
560 #[test]
561 fn rrf_fusion_basic() {
562 let manager = MultiRepoManager::new().with_rrf_k(60.0);
563 let score: f64 = 1.0 / (60.0 + 0.0 + 1.0);
565 assert!((score - 0.01639).abs() < 0.001);
566
567 assert_eq!(manager.rrf_k, 60.0);
568 }
569
570 #[test]
571 fn remove_root_works() {
572 let dir = std::env::temp_dir().join("multi_repo_remove_test");
573 let _ = std::fs::create_dir_all(&dir);
574 let mut manager = MultiRepoManager::new();
575 manager
576 .add_root(&dir.to_string_lossy(), Some("removable"))
577 .unwrap();
578 assert_eq!(manager.root_count(), 1);
579 manager.remove_root(&dir.to_string_lossy()).unwrap();
580 assert_eq!(manager.root_count(), 0);
581 let _ = std::fs::remove_dir_all(&dir);
582 }
583
584 #[test]
585 fn list_roots_returns_info() {
586 let dir = std::env::temp_dir().join("multi_repo_list_test");
587 let _ = std::fs::create_dir_all(&dir);
588 let mut manager = MultiRepoManager::new();
589 manager
590 .add_root(&dir.to_string_lossy(), Some("myrepo"))
591 .unwrap();
592 let roots = manager.list_roots();
593 assert_eq!(roots.len(), 1);
594 assert_eq!(roots[0].alias, "myrepo");
595 let _ = std::fs::remove_dir_all(&dir);
596 }
597
598 #[test]
599 fn format_empty_results() {
600 let results: Vec<FusedSearchResult> = Vec::new();
601 let output = format_fused_results(&results);
602 assert!(output.contains("No results"));
603 }
604}