Skip to main content

rustbrain_core/
registry.rs

1//! Global developer registry of rustbrain workspaces (XDG-aware).
2//!
3//! The registry powers `rustbrain query --all-workspaces`: each `rustbrain init`
4//! / `sync` registers the workspace path under the user config directory so
5//! multi-repo developers can search every brain on the machine.
6
7use crate::error::{BrainError, Result};
8use crate::query::{QueryOptions, RankedHit};
9use crate::storage::Database;
10use crate::types::Node;
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::{Path, PathBuf};
14
15/// Global registry of rustbrain workspaces on this machine.
16///
17/// Stored at `$XDG_CONFIG_HOME/rustbrain/registry.json` (Linux), or the platform
18/// equivalent returned by the `dirs` crate.
19#[derive(Debug, Clone, Serialize, Deserialize, Default)]
20pub struct GlobalRegistry {
21    /// Absolute workspace paths (canonicalized when possible).
22    #[serde(default)]
23    pub workspaces: Vec<String>,
24}
25
26/// A ranked hit tagged with its originating workspace.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct GlobalRankedHit {
29    /// Absolute workspace path.
30    pub workspace: String,
31    /// Hit within that workspace's brain.
32    pub hit: RankedHit,
33}
34
35impl GlobalRegistry {
36    /// Config path: `$XDG_CONFIG_HOME/rustbrain/registry.json` or platform equivalent.
37    pub fn config_path() -> Result<PathBuf> {
38        let base = dirs::config_dir()
39            .ok_or_else(|| BrainError::other("could not determine user config directory"))?;
40        let dir = base.join("rustbrain");
41        fs::create_dir_all(&dir)?;
42        Ok(dir.join("registry.json"))
43    }
44
45    /// Load registry, pruning missing workspace paths.
46    pub fn load() -> Result<Self> {
47        let path = Self::config_path()?;
48        if !path.exists() {
49            return Ok(Self::default());
50        }
51        let content = fs::read_to_string(&path)?;
52        let mut reg: Self = serde_json::from_str(&content).unwrap_or_default();
53        reg.prune_missing();
54        Ok(reg)
55    }
56
57    /// Persist registry atomically.
58    pub fn save(&self) -> Result<()> {
59        let path = Self::config_path()?;
60        let tmp = path.with_extension("json.tmp");
61        let json = serde_json::to_string_pretty(self)?;
62        fs::write(&tmp, json)?;
63        fs::rename(&tmp, &path)?;
64        Ok(())
65    }
66
67    /// Drop workspace paths that no longer have a brain database.
68    pub fn prune_missing(&mut self) {
69        self.workspaces
70            .retain(|ws| Path::new(ws).join(".brain").join("db.sqlite").exists());
71    }
72
73    /// Register a workspace directory path.
74    pub fn register<P: AsRef<Path>>(&mut self, workspace_path: P) -> Result<()> {
75        let abs_path = fs::canonicalize(workspace_path.as_ref())
76            .unwrap_or_else(|_| workspace_path.as_ref().to_path_buf())
77            .to_string_lossy()
78            .to_string();
79
80        if !self.workspaces.contains(&abs_path) {
81            self.workspaces.push(abs_path);
82            self.save()?;
83        }
84        Ok(())
85    }
86
87    /// Search FTS across all registered workspaces (unranked grouping).
88    pub fn search_all(&self, query: &str) -> Result<Vec<(String, Vec<Node>)>> {
89        let ranked = self.search_all_ranked(query, &QueryOptions::default())?;
90        let mut by_ws: std::collections::BTreeMap<String, Vec<Node>> =
91            std::collections::BTreeMap::new();
92        for gh in ranked {
93            by_ws.entry(gh.workspace).or_default().push(gh.hit.node);
94        }
95        Ok(by_ws.into_iter().collect())
96    }
97
98    /// Cross-workspace ranked merge: highest score first, workspace tag preserved.
99    pub fn search_all_ranked(
100        &self,
101        query: &str,
102        opts: &QueryOptions,
103    ) -> Result<Vec<GlobalRankedHit>> {
104        let mut merged: Vec<GlobalRankedHit> = Vec::new();
105
106        for ws in &self.workspaces {
107            let db_path = PathBuf::from(ws).join(".brain").join("db.sqlite");
108            if !db_path.exists() {
109                continue;
110            }
111            match Database::open(&db_path) {
112                Ok(db) => match db.search_ranked(query, opts) {
113                    Ok(hits) => {
114                        for hit in hits {
115                            merged.push(GlobalRankedHit {
116                                workspace: ws.clone(),
117                                hit,
118                            });
119                        }
120                    }
121                    Err(e) => {
122                        eprintln!("rustbrain: skip workspace {ws}: {e}");
123                    }
124                },
125                Err(e) => {
126                    eprintln!("rustbrain: cannot open {ws}: {e}");
127                }
128            }
129        }
130
131        merged.sort_by(|a, b| {
132            b.hit
133                .score
134                .partial_cmp(&a.hit.score)
135                .unwrap_or(std::cmp::Ordering::Equal)
136        });
137        merged.truncate(opts.limit);
138        Ok(merged)
139    }
140}