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