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