Skip to main content

sqry_cli/commands/
unused.rs

1//! Unused command implementation
2//!
3//! Provides CLI interface for finding unused/dead code in the codebase.
4//!
5//! # Dispatch path (DB18)
6//!
7//! `unused` is a **name-keyed predicate** under the Phase 3C dispatch
8//! taxonomy: the question is "which nodes match this scope AND are not
9//! reachable from any entry point", which is exactly the planner-canonical
10//! contract that sqry-db's [`sqry_db::queries::UnusedQuery`] caches, keyed
11//! on [`sqry_db::queries::UnusedKey`]. See also
12//! [`sqry_mcp::execution::tools::analysis::execute_find_unused`] — this
13//! CLI handler mirrors the same MCP-style superset-key + post-filter
14//! pattern so CLI and MCP share one cache behavior.
15
16use crate::args::Cli;
17use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph_for_cli, no_op_reporter};
18use crate::index_discovery::find_nearest_index;
19use crate::output::OutputStreams;
20use anyhow::{Context, Result};
21use serde::Serialize;
22use sqry_core::graph::unified::concurrent::GraphSnapshot;
23use sqry_core::graph::unified::node::id::NodeId;
24use sqry_core::query::UnusedScope;
25use std::collections::HashMap;
26use std::path::Path;
27use std::sync::Arc;
28
29/// Unused symbol for output
30#[derive(Debug, Serialize)]
31struct UnusedSymbol {
32    name: String,
33    qualified_name: String,
34    kind: String,
35    file: String,
36    line: u32,
37    language: String,
38    visibility: String,
39}
40
41/// Unused symbols grouped by file
42#[derive(Debug, Serialize)]
43struct UnusedByFile {
44    file: String,
45    count: usize,
46    symbols: Vec<UnusedSymbol>,
47}
48
49/// Run the unused command.
50///
51/// # Dispatch path (DB18)
52///
53/// The handler acquires a per-call [`sqry_db::QueryDb`] via
54/// [`sqry_db::queries::dispatch::make_query_db`], dispatches
55/// [`sqry_db::queries::UnusedQuery`] keyed on
56/// [`sqry_db::queries::UnusedKey`], and applies the CLI's free-form
57/// `--lang` / `--kind` substring filters as a post-filter (they are
58/// user-supplied free-form substrings, not the structured filter lists
59/// sqry-db consumes — sqry-db must see the full candidate set so
60/// filtered-out prefixes cannot push valid later matches out of the
61/// window).
62///
63/// The handler asks sqry-db for `node_count` rows (an upper bound; `UnusedQuery`
64/// early-breaks once the underlying graph is exhausted) so the CLI substring
65/// filters and the binding-plane post-filter are the single authoritative
66/// gates on what reaches the user. This mirrors the MCP pattern in
67/// [`sqry_mcp::execution::tools::analysis::execute_find_unused`].
68///
69/// The `--scope` argument maps one-to-one onto
70/// [`sqry_core::query::UnusedScope`] (the same enum sqry-db consumes),
71/// so no scope-superset widening is needed here — unlike MCP's
72/// `UnusedScope::Struct` which is strictly broader than sqry-db's
73/// (MCP's `Struct` includes `Interface | Trait`).
74///
75/// # Errors
76/// Returns an error if the graph cannot be loaded.
77pub fn run_unused(
78    cli: &Cli,
79    path: Option<&str>,
80    scope: &str,
81    lang_filter: Option<&str>,
82    kind_filter: Option<&str>,
83    max_results: usize,
84) -> Result<()> {
85    let mut streams = OutputStreams::new();
86
87    // Parse scope
88    let unused_scope = UnusedScope::try_parse(scope).with_context(|| {
89        format!("Invalid scope: {scope}. Use: public, private, function, struct, all")
90    })?;
91
92    // Find index
93    let search_path = path.map_or_else(
94        || std::env::current_dir().unwrap_or_default(),
95        std::path::PathBuf::from,
96    );
97
98    let index_location = find_nearest_index(&search_path);
99    let Some(ref loc) = index_location else {
100        streams
101            .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
102        return Ok(());
103    };
104
105    // Load unified graph
106    let config = GraphLoadConfig::default();
107    let graph = load_unified_graph_for_cli(&loc.index_root, &config, cli, no_op_reporter())
108        .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
109
110    // Route through sqry-db: `UnusedQuery` is a name-keyed predicate in
111    // the planner taxonomy, cached per-snapshot. Boundary filters can
112    // suppress raw rows even when the user supplied no `--lang` / `--kind`
113    // filter, so request the full candidate pool and truncate only after all
114    // user-facing filters run.
115    let snapshot = std::sync::Arc::new(graph.snapshot());
116    let unused_ids =
117        boundary_filtered_unused_ids(&snapshot, &loc.index_root, unused_scope, max_results);
118
119    let strings = snapshot.strings();
120    let files = snapshot.files();
121
122    // Post-filter: apply --lang and --kind substring filters + truncate
123    // at max_results.
124    let mut unused_symbols: Vec<UnusedSymbol> = Vec::new();
125    for &node_id in &unused_ids {
126        if unused_symbols.len() >= max_results {
127            break;
128        }
129
130        let Some(entry) = snapshot.nodes().get(node_id) else {
131            continue;
132        };
133
134        let language = files
135            .language_for_file(entry.file)
136            .map_or_else(|| "Unknown".to_string(), |l| l.to_string());
137
138        if let Some(lang) = lang_filter
139            && !language.to_lowercase().contains(&lang.to_lowercase())
140        {
141            continue;
142        }
143
144        if let Some(kind) = kind_filter {
145            let kind_str = format!("{:?}", entry.kind).to_lowercase();
146            if !kind_str.contains(&kind.to_lowercase()) {
147                continue;
148            }
149        }
150
151        let name = strings
152            .resolve(entry.name)
153            .map(|s| s.to_string())
154            .unwrap_or_default();
155
156        let qualified_name = entry
157            .qualified_name
158            .and_then(|id| strings.resolve(id))
159            .map_or_else(|| name.clone(), |s| s.to_string());
160
161        let file_path = files
162            .resolve(entry.file)
163            .map(|p| p.display().to_string())
164            .unwrap_or_default();
165
166        let visibility = entry
167            .visibility
168            .and_then(|id| strings.resolve(id))
169            .map_or_else(|| "unknown".to_string(), |s| s.to_string());
170
171        unused_symbols.push(UnusedSymbol {
172            name,
173            qualified_name,
174            kind: format!("{:?}", entry.kind),
175            file: file_path,
176            line: entry.start_line,
177            language,
178            visibility,
179        });
180    }
181
182    // Group by file for text output
183    let mut by_file: HashMap<String, Vec<UnusedSymbol>> = HashMap::new();
184    for sym in unused_symbols {
185        by_file.entry(sym.file.clone()).or_default().push(sym);
186    }
187
188    let mut grouped: Vec<UnusedByFile> = by_file
189        .into_iter()
190        .map(|(file, symbols)| UnusedByFile {
191            file,
192            count: symbols.len(),
193            symbols,
194        })
195        .collect();
196
197    // Sort by file path
198    grouped.sort_by(|a, b| a.file.cmp(&b.file));
199
200    // Output
201    if cli.json {
202        let json = serde_json::to_string_pretty(&grouped).context("Failed to serialize to JSON")?;
203        streams.write_result(&json)?;
204    } else {
205        let output = format_unused_text(&grouped, unused_scope);
206        streams.write_result(&output)?;
207    }
208
209    Ok(())
210}
211
212fn boundary_filtered_unused_ids(
213    snapshot: &Arc<GraphSnapshot>,
214    index_root: &Path,
215    unused_scope: UnusedScope,
216    max_results: usize,
217) -> Vec<NodeId> {
218    // PN3 CLIENT_LOAD: opportunistic cold-load from workspace companion file.
219    let db = sqry_db::queries::dispatch::make_query_db_cold(Arc::clone(snapshot), index_root);
220    let candidate_cap = snapshot.nodes().len().max(max_results);
221    let key = sqry_db::queries::UnusedKey {
222        scope: unused_scope,
223        max_results: candidate_cap,
224    };
225    let raw_unused_ids = db.get::<sqry_db::queries::UnusedQuery>(&key);
226    sqry_db::queries::unused_post_filter::apply_binding_plane_post_filter(
227        &raw_unused_ids,
228        snapshot,
229        &db,
230    )
231}
232
233/// Format unused symbols as human-readable text
234fn format_unused_text(groups: &[UnusedByFile], scope: UnusedScope) -> String {
235    let mut lines = Vec::new();
236
237    let total: usize = groups.iter().map(|g| g.count).sum();
238    let scope_name = match scope {
239        UnusedScope::Public => "public",
240        UnusedScope::Private => "private",
241        UnusedScope::Function => "function",
242        UnusedScope::Struct => "struct",
243        UnusedScope::All => "all",
244    };
245
246    lines.push(format!(
247        "Found {total} unused symbols (scope: {scope_name})"
248    ));
249    lines.push(String::new());
250
251    for group in groups {
252        lines.push(format!("{} ({} unused):", group.file, group.count));
253        for sym in &group.symbols {
254            lines.push(format!("  {} [{}] line {}", sym.name, sym.kind, sym.line));
255        }
256        lines.push(String::new());
257    }
258
259    if groups.is_empty() {
260        lines.push("No unused symbols found.".to_string());
261    }
262
263    lines.join("\n")
264}