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 workspace-level statistics.
184 ///
185 /// Returns aggregated stats across all repositories in the workspace.
186 #[must_use]
187 pub fn stats(&self) -> WorkspaceStats {
188 let total_repos = self.registry.repositories.len();
189 let indexed_repos = self
190 .registry
191 .repositories
192 .iter()
193 .filter(|r| r.last_indexed_at.is_some())
194 .count();
195 let total_symbols = self
196 .registry
197 .repositories
198 .iter()
199 .filter_map(|r| r.symbol_count)
200 .sum();
201
202 WorkspaceStats {
203 total_repos,
204 indexed_repos,
205 total_symbols,
206 }
207 }
208
209 /// Get detailed workspace statistics including staleness tracking.
210 ///
211 /// Returns comprehensive stats with freshness buckets, health scores,
212 /// and other detailed metrics.
213 #[must_use]
214 pub fn detailed_stats(&self) -> super::stats::DetailedWorkspaceStats {
215 super::stats::DetailedWorkspaceStats::from_registry(&self.registry)
216 }
217
218 /// Get a reference to the underlying registry.
219 #[must_use]
220 pub fn registry(&self) -> &WorkspaceRegistry {
221 &self.registry
222 }
223
224 /// Get a mutable reference to the underlying registry.
225 pub fn registry_mut(&mut self) -> &mut WorkspaceRegistry {
226 &mut self.registry
227 }
228
229 /// Get the workspace root directory.
230 #[must_use]
231 pub fn workspace_root(&self) -> &Path {
232 &self.workspace_root
233 }
234}
235
236/// Query match information from `CodeGraph`.
237#[derive(Debug, Clone)]
238pub struct MatchInfo {
239 /// Node name
240 pub name: String,
241 /// Canonical qualified name, if available.
242 pub qualified_name: Option<String>,
243 /// Node kind (function, class, etc.)
244 pub kind: NodeKind,
245 /// Language derived from the backing file, if available.
246 pub language: Option<String>,
247 /// File path where the node is defined (relative to repo root)
248 pub file_path: PathBuf,
249 /// Starting line number (1-based)
250 pub start_line: u32,
251 /// Starting column (1-based)
252 pub start_column: u32,
253 /// Ending line number (1-based)
254 pub end_line: u32,
255 /// Ending column (1-based)
256 pub end_column: u32,
257 /// Whether this match represents a static member.
258 pub is_static: bool,
259 /// Optional signature
260 pub signature: Option<String>,
261 /// Optional documentation
262 pub doc: Option<String>,
263}
264
265/// A query match tagged with repository metadata for workspace-level queries.
266#[derive(Debug, Clone)]
267pub struct NodeWithRepo {
268 /// The match info from the query.
269 pub match_info: MatchInfo,
270 /// The name of the repository containing this node.
271 pub repo_name: String,
272 /// The unique identifier of the repository containing this node.
273 pub repo_id: super::registry::WorkspaceRepoId,
274 /// Absolute path to the repository containing this node.
275 pub repo_path: PathBuf,
276}
277
278/// Aggregated statistics for a workspace.
279#[derive(Debug, Clone)]
280pub struct WorkspaceStats {
281 /// Total number of repositories in the workspace.
282 pub total_repos: usize,
283 /// Number of repositories that have been indexed.
284 pub indexed_repos: usize,
285 /// Total symbol count across all indexed repositories.
286 pub total_symbols: u64,
287}