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 !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    /// The configured RRF constant, so external fusers (e.g. the hybrid
267    /// multi-repo search) score identically to [`Self::search`].
268    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    /// Resolve a repo alias or path to the corresponding root index.
277    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    /// Search across all roots (or a subset) and merge with Reciprocal Rank Fusion.
284    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    /// Search within a specific repo root (no RRF, single-repo query).
344    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/// Summary info about a registered root.
369#[derive(Debug, Clone, Serialize)]
370pub struct RootInfo {
371    pub path: String,
372    pub alias: String,
373    pub has_index: bool,
374}
375
376/// Returns the path to the multi-repo config file.
377///
378/// #594: resolved through the unified config base so it matches `config.toml`
379/// (on macOS this no longer diverges to `~/Library/Application Support`); any
380/// legacy copy at the old location is adopted on first access.
381pub 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
386/// Global multi-repo manager instance (lazily initialized).
387static 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
398/// Initialize the global manager with explicit roots (e.g. from CLI `--root` flags).
399pub 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
415/// Resolve a `repo` alias/path to the actual filesystem root.
416/// Used by existing tools (ctx_read, ctx_search, etc.) when a `repo` param is provided.
417/// Returns the absolute path to the repo root, or None if multi-repo is inactive or repo not found.
418pub 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
426/// Every registered repo alias, for naming known aliases in an "unknown repo"
427/// error — a bare `resolve_repo_root` miss gives no hint of what *was*
428/// registered, which just invites another guess.
429pub 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
437/// Check if multi-repo mode is active (more than 1 root configured).
438pub fn is_multi_repo_active() -> bool {
439    let manager = global_manager();
440    manager.lock().is_ok_and(|mgr| mgr.is_active())
441}
442
443/// Get all configured repo root paths (for tools that need to iterate).
444pub 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
456/// Whether a registered repo root may be used now: every registered root
457/// without a Workspace, only roots of the request's snapshot under one
458/// (#2120) — a root registered earlier or saved in `multi-repo.toml` grants
459/// nothing by itself.
460fn authorized(root: &Path) -> bool {
461    crate::core::workspace_authority::root_authorized(root)
462}
463
464/// Format search results for MCP output.
465pub 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        // RRF score for rank 0: 1/(60+0+1) = 1/61 ≈ 0.01639
588        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}