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