Skip to main content

sqry_core/workspace/
index.rs

1//! `WorkspaceIndex`: multi-repo search orchestration using `SessionManager`.
2
3use std::path::{Path, PathBuf};
4
5use crate::graph::unified::NodeKind;
6use crate::query::QueryExecutor;
7use crate::session::SessionManager;
8
9use super::error::{WorkspaceError, WorkspaceResult};
10use super::registry::{WorkspaceRegistry, WorkspaceRepository};
11
12/// A workspace-level index that orchestrates queries across multiple repositories.
13///
14/// `WorkspaceIndex` delegates to `SessionManager` for individual repository queries,
15/// leveraging its caching and lazy-loading capabilities. Results are aggregated
16/// and tagged with repository metadata.
17pub struct WorkspaceIndex {
18    registry: WorkspaceRegistry,
19    session: SessionManager,
20    workspace_root: PathBuf,
21}
22
23impl WorkspaceIndex {
24    /// Open a workspace index from the registry file at the given path.
25    ///
26    /// # Arguments
27    ///
28    /// * `workspace_root` - Root directory of the workspace
29    /// * `registry_path` - Path to the .sqry-workspace registry file
30    ///
31    /// # Example
32    ///
33    /// ```no_run
34    /// use sqry_core::workspace::WorkspaceIndex;
35    /// use std::path::Path;
36    ///
37    /// let workspace = Path::new("/path/to/workspace");
38    /// let registry = workspace.join(".sqry-workspace");
39    /// let mut index = WorkspaceIndex::open(workspace, &registry).unwrap();
40    /// ```
41    ///
42    /// # Errors
43    ///
44    /// Returns [`WorkspaceError`] when registry loading or session creation fails.
45    pub fn open(workspace_root: impl Into<PathBuf>, registry_path: &Path) -> WorkspaceResult<Self> {
46        let workspace_root = workspace_root.into();
47        let registry = WorkspaceRegistry::load(registry_path)?;
48        let session = SessionManager::new().map_err(|e| WorkspaceError::QueryParsing {
49            message: format!("Failed to create SessionManager: {e}"),
50        })?;
51
52        Ok(Self {
53            registry,
54            session,
55            workspace_root,
56        })
57    }
58
59    /// Create a new workspace index with an existing registry and session.
60    ///
61    /// Useful for testing or when you want to provide a custom `SessionManager`.
62    pub fn new(
63        workspace_root: impl Into<PathBuf>,
64        registry: WorkspaceRegistry,
65        session: SessionManager,
66    ) -> Self {
67        Self {
68            registry,
69            session,
70            workspace_root: workspace_root.into(),
71        }
72    }
73
74    /// Execute a query across all repositories in the workspace.
75    ///
76    /// This method:
77    /// 1. Parses the query into AST and extracts repo filter
78    /// 2. Filters repositories based on the repo predicates
79    /// 3. Executes the normalized query (repo predicates stripped) against each matching repository
80    /// 4. Aggregates results with repository metadata
81    ///
82    /// # Arguments
83    ///
84    /// * `query` - The query string to execute (may include repo: predicates)
85    ///
86    /// # Returns
87    ///
88    /// A list of matches with their associated repository information.
89    ///
90    /// # Example
91    ///
92    /// ```no_run
93    /// use sqry_core::workspace::WorkspaceIndex;
94    /// use std::path::Path;
95    ///
96    /// let workspace = Path::new("/path/to/workspace");
97    /// let registry = workspace.join(".sqry-workspace");
98    /// let mut index = WorkspaceIndex::open(workspace, &registry).unwrap();
99    ///
100    /// let results = index.query("kind:function AND repo:backend-*").unwrap();
101    /// ```
102    ///
103    /// # Errors
104    ///
105    /// Returns [`WorkspaceError`] when parsing the query or executing repository queries fails.
106    pub fn query(&mut self, query: &str) -> WorkspaceResult<Vec<NodeWithRepo>> {
107        // Parse query into AST to extract repo filter.
108        let executor = QueryExecutor::new();
109        let parsed = executor
110            .parse_query_ast(query)
111            .map_err(|e| WorkspaceError::QueryParsing {
112                message: format!("Failed to parse query: {e}"),
113            })?;
114
115        // Filter repositories based on the repo predicates
116        let repos: Vec<&WorkspaceRepository> = self
117            .registry
118            .repositories
119            .iter()
120            .filter(|repo| parsed.repo_filter.matches(&repo.name))
121            .collect();
122
123        if repos.is_empty() {
124            return Ok(Vec::new());
125        }
126
127        // Use normalized query string (repo predicates stripped) for repository execution
128        // The repo filtering has already been done at the workspace level above
129        let query_str = parsed.normalized.as_ref();
130
131        // Execute query against each repository and aggregate results
132        let mut all_results = Vec::new();
133
134        for repo in repos {
135            // Resolve absolute path for the repository
136            let repo_path = if repo.root.is_absolute() {
137                repo.root.clone()
138            } else {
139                self.workspace_root.join(&repo.root)
140            };
141
142            // Query the repository via SessionManager
143            // SessionManager will parse the query again and execute it against the single-repo index
144            match self.session.query(&repo_path, query_str) {
145                Ok(results) => {
146                    // Tag each result with repo metadata
147                    for m in results.iter() {
148                        let match_info = MatchInfo {
149                            name: m.name().map(|s| s.to_string()).unwrap_or_default(),
150                            qualified_name: m.qualified_name().map(|s| s.to_string()),
151                            kind: m.kind(),
152                            language: m.language().map(|lang| lang.to_string()),
153                            file_path: m.relative_path().unwrap_or_default(),
154                            start_line: m.start_line(),
155                            start_column: m.start_column(),
156                            end_line: m.end_line(),
157                            end_column: m.end_column(),
158                            is_static: m.is_static(),
159                            signature: m.signature().map(|s| s.to_string()),
160                            doc: m.doc().map(|s| s.to_string()),
161                        };
162                        all_results.push(NodeWithRepo {
163                            match_info,
164                            repo_name: repo.name.clone(),
165                            repo_id: repo.id.clone(),
166                            repo_path: repo_path.clone(),
167                        });
168                    }
169                }
170                Err(err) => {
171                    // Log error but continue with other repos
172                    eprintln!(
173                        "Warning: Failed to query repository '{}': {}",
174                        repo.name, err
175                    );
176                }
177            }
178        }
179
180        Ok(all_results)
181    }
182
183    /// Get coarse workspace-level statistics.
184    ///
185    /// Returns aggregated stats across all repositories in the workspace.
186    ///
187    /// `total_symbols` sums each repository's
188    /// [`super::registry::WorkspaceRepository::symbol_count_at_registration`],
189    /// the last-known snapshot captured when the repository was
190    /// registered (`discover_repositories` / `sqry workspace add`), not a
191    /// live re-read of `.sqry/graph/manifest.json`. It goes stale after a
192    /// direct `sqry index --force` reindex until the member is removed
193    /// and re-added. Prefer [`Self::detailed_stats`] (backs `sqry
194    /// workspace stats`) when the count must reflect the current index.
195    #[must_use]
196    pub fn stats(&self) -> WorkspaceStats {
197        let total_repos = self.registry.repositories.len();
198        let indexed_repos = self
199            .registry
200            .repositories
201            .iter()
202            .filter(|r| r.last_indexed_at.is_some())
203            .count();
204        let total_symbols = self
205            .registry
206            .repositories
207            .iter()
208            .filter_map(|r| r.symbol_count_at_registration)
209            .sum();
210
211        WorkspaceStats {
212            total_repos,
213            indexed_repos,
214            total_symbols,
215        }
216    }
217
218    /// Get detailed workspace statistics including staleness tracking.
219    ///
220    /// Returns comprehensive stats with freshness buckets, health scores,
221    /// and other detailed metrics. Unlike [`Self::stats`], the symbol
222    /// counts here are read **live** from each member's
223    /// `.sqry/graph/manifest.json` at call time (see
224    /// [`super::stats::DetailedWorkspaceStats::from_registry`]), so a
225    /// direct `sqry index --force` reindex of a member is reflected
226    /// immediately without a `workspace remove` + `workspace add`
227    /// round-trip.
228    #[must_use]
229    pub fn detailed_stats(&self) -> super::stats::DetailedWorkspaceStats {
230        super::stats::DetailedWorkspaceStats::from_registry(&self.registry)
231    }
232
233    /// Get a reference to the underlying registry.
234    #[must_use]
235    pub fn registry(&self) -> &WorkspaceRegistry {
236        &self.registry
237    }
238
239    /// Get a mutable reference to the underlying registry.
240    pub fn registry_mut(&mut self) -> &mut WorkspaceRegistry {
241        &mut self.registry
242    }
243
244    /// Get the workspace root directory.
245    #[must_use]
246    pub fn workspace_root(&self) -> &Path {
247        &self.workspace_root
248    }
249}
250
251/// Query match information from `CodeGraph`.
252#[derive(Debug, Clone)]
253pub struct MatchInfo {
254    /// Node name
255    pub name: String,
256    /// Canonical qualified name, if available.
257    pub qualified_name: Option<String>,
258    /// Node kind (function, class, etc.)
259    pub kind: NodeKind,
260    /// Language derived from the backing file, if available.
261    pub language: Option<String>,
262    /// File path where the node is defined (relative to repo root)
263    pub file_path: PathBuf,
264    /// Starting line number (1-based)
265    pub start_line: u32,
266    /// Starting column (1-based)
267    pub start_column: u32,
268    /// Ending line number (1-based)
269    pub end_line: u32,
270    /// Ending column (1-based)
271    pub end_column: u32,
272    /// Whether this match represents a static member.
273    pub is_static: bool,
274    /// Optional signature
275    pub signature: Option<String>,
276    /// Optional documentation
277    pub doc: Option<String>,
278}
279
280/// A query match tagged with repository metadata for workspace-level queries.
281#[derive(Debug, Clone)]
282pub struct NodeWithRepo {
283    /// The match info from the query.
284    pub match_info: MatchInfo,
285    /// The name of the repository containing this node.
286    pub repo_name: String,
287    /// The unique identifier of the repository containing this node.
288    pub repo_id: super::registry::WorkspaceRepoId,
289    /// Absolute path to the repository containing this node.
290    pub repo_path: PathBuf,
291}
292
293/// Aggregated statistics for a workspace.
294#[derive(Debug, Clone)]
295pub struct WorkspaceStats {
296    /// Total number of repositories in the workspace.
297    pub total_repos: usize,
298    /// Number of repositories that have been indexed.
299    pub indexed_repos: usize,
300    /// Total symbol count across all indexed repositories.
301    pub total_symbols: u64,
302}