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, ®istry).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, ®istry).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}