Skip to main content

sqry_cli/commands/
workspace.rs

1//! Workspace command implementations (`sqry workspace …`)
2
3use 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
16/// Entry point for all workspace subcommands.
17///
18/// # Errors
19/// Returns an error if any subcommand fails to execute.
20///
21/// # STEP_8 invariant
22///
23/// `sqry workspace …` is incompatible with the global `--workspace` /
24/// `SQRY_WORKSPACE_FILE` flag — each `WorkspaceCommand` variant carries its
25/// own positional `<workspace>` argument, and silently choosing between the
26/// two is a footgun. The collision is rejected by `main.rs` *before* entering
27/// this function; reaching here implies `cli.workspace.is_none()`.
28pub 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(&registry_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) = &registry.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(&registry_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        // Refresh metadata (discovery already sets last_indexed_at but be defensive)
151        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(&registry_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(&registry_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    // Check for the unified graph index path (.sqry/graph)
205    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(&registry_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(&registry_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(&registry_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, &registry_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, &registry_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}