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    }
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(&registry_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) = &registry.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(&registry_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        // Refresh metadata (discovery already sets last_indexed_at but be defensive)
137        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(&registry_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(&registry_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    // Check for the unified graph index path (.sqry/graph)
191    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(&registry_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(&registry_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(&registry_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, &registry_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, &registry_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}