1use crate::args::{Cli, WorkspaceCommand, WorkspaceDiscoveryMode};
4use crate::output::{JsonFormatter, NameDisplayMode, OutputStreams, TextFormatter};
5use anyhow::{Context, Result, anyhow, bail};
6use sqry_core::workspace::{
7 DiscoveryMode, WorkspaceIndex, WorkspaceRegistry, WorkspaceRepoId, WorkspaceRepository,
8 discover_repositories,
9};
10use std::collections::HashSet;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14const REGISTRY_FILE: &str = ".sqry-workspace";
15
16pub fn run_workspace(cli: &Cli, action: &WorkspaceCommand) -> Result<()> {
21 match action {
22 WorkspaceCommand::Init {
23 workspace,
24 mode,
25 name,
26 } => init_workspace(cli, workspace, *mode, name.as_ref()),
27 WorkspaceCommand::Scan {
28 workspace,
29 mode,
30 prune_stale,
31 } => scan_workspace(cli, workspace, *mode, *prune_stale),
32 WorkspaceCommand::Add {
33 workspace,
34 repo,
35 name,
36 } => add_repository(cli, workspace, repo, name.as_ref()),
37 WorkspaceCommand::Remove { workspace, repo_id } => {
38 remove_repository(cli, workspace, repo_id)
39 }
40 WorkspaceCommand::Query {
41 workspace,
42 query,
43 threads,
44 } => query_workspace(cli, workspace, query, *threads),
45 WorkspaceCommand::Stats { workspace } => stats_workspace(cli, workspace),
46 }
47}
48
49fn init_workspace(
50 cli: &Cli,
51 workspace: &str,
52 mode: WorkspaceDiscoveryMode,
53 name: Option<&String>,
54) -> Result<()> {
55 let workspace_root = PathBuf::from(workspace);
56 fs::create_dir_all(&workspace_root).with_context(|| {
57 format!(
58 "Failed to create workspace directory {}",
59 workspace_root.display()
60 )
61 })?;
62
63 let registry_path = registry_path(&workspace_root);
64 if registry_path.exists() {
65 bail!(
66 "Workspace registry already exists at {}. Use `sqry workspace scan` or other commands instead.",
67 registry_path.display()
68 );
69 }
70
71 let mut registry = WorkspaceRegistry::new(
72 name.cloned()
73 .or_else(|| derive_workspace_name(&workspace_root)),
74 );
75 registry.metadata.default_discovery_mode = Some(mode_label(mode).to_string());
76 registry
77 .save(®istry_path)
78 .with_context(|| format!("Failed to write registry at {}", registry_path.display()))?;
79
80 let mut streams = OutputStreams::with_pager(cli.pager_config());
81 streams.write_result(&format!(
82 "Workspace initialised at {}",
83 registry_path.display()
84 ))?;
85 if let Some(name) = ®istry.metadata.workspace_name {
86 streams.write_result(&format!("Name: {name}"))?;
87 }
88 streams.write_result(&format!("Default discovery mode: {}", mode_label(mode)))?;
89 streams.finish_checked()
90}
91
92fn scan_workspace(
93 cli: &Cli,
94 workspace: &str,
95 mode: WorkspaceDiscoveryMode,
96 prune_stale: bool,
97) -> Result<()> {
98 let workspace_root = canonicalize_existing(workspace)
99 .with_context(|| format!("Workspace path {workspace} not found"))?;
100 let registry_path = registry_path(&workspace_root);
101
102 let mut registry = WorkspaceRegistry::load(®istry_path)
103 .with_context(|| format!("Failed to load registry at {}", registry_path.display()))?;
104
105 let discovery_mode = convert_mode(mode);
106 let discovered = discover_repositories(&workspace_root, discovery_mode).with_context(|| {
107 format!(
108 "Failed to discover repositories under {}",
109 workspace_root.display()
110 )
111 })?;
112
113 let mut known_ids: HashSet<_> = registry
114 .repositories
115 .iter()
116 .map(|repo| repo.id.clone())
117 .collect();
118
119 let mut added = 0usize;
120 let mut updated = 0usize;
121
122 for mut repo in discovered {
123 if let Ok(meta) = fs::metadata(&repo.index_path)
125 && let Ok(modified) = meta.modified()
126 {
127 repo.last_indexed_at = Some(modified);
128 }
129
130 if known_ids.contains(&repo.id) {
131 updated += 1;
132 } else {
133 added += 1;
134 known_ids.insert(repo.id.clone());
135 }
136 registry.upsert_repo(repo)?;
137 }
138
139 let mut pruned = 0usize;
140 if prune_stale {
141 let before = registry.repositories.len();
142 registry
143 .repositories
144 .retain(|repo| repo.index_path.exists());
145 pruned = before.saturating_sub(registry.repositories.len());
146 }
147
148 registry.metadata.default_discovery_mode = Some(mode_label(mode).to_string());
149 registry
150 .save(®istry_path)
151 .with_context(|| format!("Failed to update registry at {}", registry_path.display()))?;
152
153 let mut streams = OutputStreams::with_pager(cli.pager_config());
154 streams.write_result(&format!(
155 "Scan complete: {} added, {} updated{}",
156 added,
157 updated,
158 if prune_stale {
159 format!(", {pruned} pruned")
160 } else {
161 String::new()
162 }
163 ))?;
164 streams.write_result(&format!("Registry saved to {}", registry_path.display()))?;
165 streams.finish_checked()
166}
167
168fn add_repository(cli: &Cli, workspace: &str, repo: &str, name: Option<&String>) -> Result<()> {
169 let workspace_root = canonicalize_existing(workspace)
170 .with_context(|| format!("Workspace path {workspace} not found"))?;
171 let registry_path = registry_path(&workspace_root);
172 let mut registry = WorkspaceRegistry::load(®istry_path)
173 .with_context(|| format!("Failed to load registry at {}", registry_path.display()))?;
174
175 let repo_root =
176 canonicalize_existing(repo).with_context(|| format!("Repository path {repo} not found"))?;
177 let index_path = repo_root.join(".sqry").join("graph");
179 if !index_path.exists() {
180 bail!(
181 "Repository {} does not contain an index. Run `sqry index {}` first.",
182 repo_root.display(),
183 repo_root.display()
184 );
185 }
186
187 let relative = repo_root.strip_prefix(&workspace_root).map_err(|_| {
188 anyhow!(
189 "Repository {} is not inside the workspace {}",
190 repo_root.display(),
191 workspace_root.display()
192 )
193 })?;
194 let repo_id = WorkspaceRepoId::new(relative);
195 let repo_name = name
196 .cloned()
197 .or_else(|| derive_workspace_name(&repo_root))
198 .ok_or_else(|| {
199 anyhow!(
200 "Unable to determine repository name for {}",
201 repo_root.display()
202 )
203 })?;
204
205 let last_indexed_at = fs::metadata(&index_path)
206 .ok()
207 .and_then(|meta| meta.modified().ok());
208 let repo_entry = WorkspaceRepository::new(
209 repo_id.clone(),
210 repo_name.clone(),
211 repo_root.clone(),
212 index_path,
213 last_indexed_at,
214 );
215
216 let existed = registry
217 .repositories
218 .iter()
219 .any(|existing| existing.id == repo_id);
220 registry.upsert_repo(repo_entry)?;
221 registry
222 .save(®istry_path)
223 .with_context(|| format!("Failed to update registry at {}", registry_path.display()))?;
224
225 let mut streams = OutputStreams::with_pager(cli.pager_config());
226 if existed {
227 streams.write_result(&format!(
228 "Updated repository {} ({}) in {}",
229 repo_name,
230 repo_id.as_str(),
231 registry_path.display()
232 ))?;
233 } else {
234 streams.write_result(&format!(
235 "Added repository {} ({}) to {}",
236 repo_name,
237 repo_id.as_str(),
238 registry_path.display()
239 ))?;
240 }
241 streams.finish_checked()
242}
243
244fn remove_repository(cli: &Cli, workspace: &str, repo_id: &str) -> Result<()> {
245 let workspace_root = canonicalize_existing(workspace)
246 .with_context(|| format!("Workspace path {workspace} not found"))?;
247 let registry_path = registry_path(&workspace_root);
248 let mut registry = WorkspaceRegistry::load(®istry_path)
249 .with_context(|| format!("Failed to load registry at {}", registry_path.display()))?;
250
251 let removed = registry.remove_repo(&WorkspaceRepoId::new(repo_id));
252 if !removed {
253 bail!("Repository '{repo_id}' not found in workspace");
254 }
255
256 registry
257 .save(®istry_path)
258 .with_context(|| format!("Failed to update registry at {}", registry_path.display()))?;
259 let mut streams = OutputStreams::with_pager(cli.pager_config());
260 streams.write_result(&format!(
261 "Removed repository '{}' from {}",
262 repo_id,
263 registry_path.display()
264 ))?;
265 streams.finish_checked()
266}
267
268fn query_workspace(
269 cli: &Cli,
270 workspace: &str,
271 query_str: &str,
272 threads: Option<usize>,
273) -> Result<()> {
274 if threads.is_some() {
275 log::info!("Thread override not applicable for workspace queries (build-time only)");
276 }
277
278 let workspace_root = canonicalize_existing(workspace)
279 .with_context(|| format!("Workspace path {workspace} not found"))?;
280 let registry_path = registry_path(&workspace_root);
281
282 let mut index = WorkspaceIndex::open(&workspace_root, ®istry_path)
283 .with_context(|| format!("Failed to open workspace at {}", registry_path.display()))?;
284
285 let mut results = index
286 .query(query_str)
287 .with_context(|| "Workspace query execution failed".to_string())?;
288
289 let total_results = results.len();
290 if let Some(limit) = cli.limit
291 && results.len() > limit
292 {
293 results.truncate(limit);
294 }
295
296 if cli.count {
297 println!("{}", results.len());
298 return Ok(());
299 }
300 let mut streams = OutputStreams::with_pager(cli.pager_config());
301
302 if cli.json {
303 JsonFormatter::format_workspace(&results, &mut streams)?;
304 } else {
305 let mode = if cli.qualified_names {
306 NameDisplayMode::Qualified
307 } else {
308 NameDisplayMode::Simple
309 };
310 let theme = crate::output::resolve_theme(cli);
311 let use_color = !cli.no_color
312 && theme != crate::output::ThemeName::None
313 && std::env::var("NO_COLOR").is_err();
314 let formatter = TextFormatter::new(use_color, mode, theme);
315 formatter.format_workspace(&results, &mut streams)?;
316 if let Some(limit) = cli.limit
317 && total_results > limit
318 {
319 streams.write_diagnostic(&format!(
320 "Note: showing {} of {} results (--limit={})",
321 results.len(),
322 total_results,
323 limit
324 ))?;
325 }
326 }
327
328 streams.finish_checked()
329}
330
331fn stats_workspace(cli: &Cli, workspace: &str) -> Result<()> {
332 let workspace_root = canonicalize_existing(workspace)
333 .with_context(|| format!("Workspace path {workspace} not found"))?;
334 let registry_path = registry_path(&workspace_root);
335
336 let index = WorkspaceIndex::open(&workspace_root, ®istry_path)
337 .with_context(|| format!("Failed to open workspace at {}", registry_path.display()))?;
338 let detailed_stats = index.detailed_stats();
339 let metadata = &index.registry().metadata;
340
341 let mut streams = OutputStreams::with_pager(cli.pager_config());
342
343 if cli.json {
344 let json = serde_json::json!({
345 "workspace": {
346 "path": workspace_root,
347 "name": metadata.workspace_name,
348 "default_discovery_mode": metadata.default_discovery_mode,
349 },
350 "repositories": {
351 "total": detailed_stats.total_repos,
352 "indexed": detailed_stats.indexed_repos,
353 "unindexed": detailed_stats.unindexed_repos,
354 },
355 "symbols": {
356 "total": detailed_stats.total_symbols,
357 "avg_per_repo": detailed_stats.avg_symbols_per_repo,
358 },
359 "freshness": {
360 "fresh": detailed_stats.freshness.fresh,
361 "recent": detailed_stats.freshness.recent,
362 "stale": detailed_stats.freshness.stale,
363 "very_stale": detailed_stats.freshness.very_stale,
364 "never_indexed": detailed_stats.freshness.never_indexed,
365 },
366 "health": {
367 "score": detailed_stats.health_score(),
368 "status": detailed_stats.health_status(),
369 }
370 });
371 streams.write_result(&serde_json::to_string_pretty(&json)?)?;
372 } else {
373 streams.write_result(&format!("Workspace: {}", workspace_root.display()))?;
374 if let Some(name) = &metadata.workspace_name {
375 streams.write_result(&format!("Name: {name}"))?;
376 }
377 if let Some(mode) = &metadata.default_discovery_mode {
378 streams.write_result(&format!("Default discovery mode: {mode}"))?;
379 }
380 streams.write_result("")?;
381 streams.write_result(&format!(
382 "Repositories: {} total ({} indexed, {} unindexed)",
383 detailed_stats.total_repos,
384 detailed_stats.indexed_repos,
385 detailed_stats.unindexed_repos
386 ))?;
387 streams.write_result(&format!(
388 "Total symbols: {} ({:.1} avg per repo)",
389 detailed_stats.total_symbols, detailed_stats.avg_symbols_per_repo
390 ))?;
391 streams.write_result("")?;
392 streams.write_result("Freshness:")?;
393 streams.write_result(&format!(
394 " Fresh (< 1 hour): {}",
395 detailed_stats.freshness.fresh
396 ))?;
397 streams.write_result(&format!(
398 " Recent (< 1 day): {}",
399 detailed_stats.freshness.recent
400 ))?;
401 streams.write_result(&format!(
402 " Stale (< 1 week): {}",
403 detailed_stats.freshness.stale
404 ))?;
405 streams.write_result(&format!(
406 " Very stale (> 1 week): {}",
407 detailed_stats.freshness.very_stale
408 ))?;
409 streams.write_result(&format!(
410 " Never indexed: {}",
411 detailed_stats.freshness.never_indexed
412 ))?;
413 streams.write_result("")?;
414 streams.write_result(&format!(
415 "Health: {} ({:.1}%)",
416 detailed_stats.health_status(),
417 detailed_stats.health_score() * 100.0
418 ))?;
419 }
420
421 streams.finish_checked()
422}
423
424fn registry_path(workspace_root: &Path) -> PathBuf {
425 workspace_root.join(REGISTRY_FILE)
426}
427
428fn convert_mode(mode: WorkspaceDiscoveryMode) -> DiscoveryMode {
429 match mode {
430 WorkspaceDiscoveryMode::IndexFiles => DiscoveryMode::IndexFiles,
431 WorkspaceDiscoveryMode::GitRoots => DiscoveryMode::GitRoots,
432 }
433}
434
435fn mode_label(mode: WorkspaceDiscoveryMode) -> &'static str {
436 match mode {
437 WorkspaceDiscoveryMode::IndexFiles => "index-files",
438 WorkspaceDiscoveryMode::GitRoots => "git-roots",
439 }
440}
441
442fn derive_workspace_name(path: &Path) -> Option<String> {
443 path.file_name()
444 .or_else(|| {
445 path.components()
446 .next_back()
447 .map(std::path::Component::as_os_str)
448 })
449 .map(|os| os.to_string_lossy().into_owned())
450}
451
452fn canonicalize_existing(path: &str) -> Result<PathBuf> {
453 let candidate = PathBuf::from(path);
454 if candidate.exists() {
455 candidate
456 .canonicalize()
457 .with_context(|| format!("Failed to resolve path {path}"))
458 } else {
459 Err(anyhow!("Path '{path}' does not exist"))
460 }
461}