Skip to main content

lean_ctx/core/
multi_repo.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::core::bm25_index::{BM25Index, SearchResult};
7
8/// Default RRF parameter (controls how quickly rank decay affects fusion scores).
9const DEFAULT_RRF_K: f64 = 60.0;
10
11/// Maximum number of repo roots that can be served simultaneously.
12const MAX_ROOTS: usize = 16;
13
14/// A single search result from one repo root.
15#[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/// A merged result after RRF fusion across multiple repos.
28#[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/// Configuration for a single repository root in multi-repo mode.
41#[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/// Multi-repo configuration loaded from `~/.config/lean-ctx/multi-repo.toml`.
61#[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
97/// An active repo root with its loaded BM25 index.
98pub 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
171/// Manages multiple repository roots and performs cross-repo search with RRF fusion.
172pub 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    /// The configured RRF constant, so external fusers (e.g. the hybrid
261    /// multi-repo search) score identically to [`Self::search`].
262    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    /// Resolve a repo alias or path to the corresponding root index.
271    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    /// Search across all roots (or a subset) and merge with Reciprocal Rank Fusion.
278    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    /// Search within a specific repo root (no RRF, single-repo query).
335    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/// Summary info about a registered root.
355#[derive(Debug, Clone, Serialize)]
356pub struct RootInfo {
357    pub path: String,
358    pub alias: String,
359    pub has_index: bool,
360}
361
362/// Returns the path to the multi-repo config file.
363///
364/// #594: resolved through the unified config base so it matches `config.toml`
365/// (on macOS this no longer diverges to `~/Library/Application Support`); any
366/// legacy copy at the old location is adopted on first access.
367pub 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
372/// Global multi-repo manager instance (lazily initialized).
373static 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
384/// Initialize the global manager with explicit roots (e.g. from CLI `--root` flags).
385pub 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
401/// Resolve a `repo` alias/path to the actual filesystem root.
402/// Used by existing tools (ctx_read, ctx_search, etc.) when a `repo` param is provided.
403/// Returns the absolute path to the repo root, or None if multi-repo is inactive or repo not found.
404pub 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
411/// Every registered repo alias, for naming known aliases in an "unknown repo"
412/// error — a bare `resolve_repo_root` miss gives no hint of what *was*
413/// registered, which just invites another guess.
414pub 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
422/// Check if multi-repo mode is active (more than 1 root configured).
423pub fn is_multi_repo_active() -> bool {
424    let manager = global_manager();
425    manager.lock().is_ok_and(|mgr| mgr.is_active())
426}
427
428/// Get all configured repo root paths (for tools that need to iterate).
429pub 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
440/// Format search results for MCP output.
441pub 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        // RRF score for rank 0: 1/(60+0+1) = 1/61 ≈ 0.01639
564        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}