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
5use crate::args::Cli;
6use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph};
7use crate::index_discovery::find_nearest_index;
8use crate::output::OutputStreams;
9use anyhow::{Context, Result};
10use serde::Serialize;
11use sqry_core::query::{UnusedScope, compute_reachable_set_graph, is_node_unused};
12use std::collections::HashMap;
13
14/// Unused symbol for output
15#[derive(Debug, Serialize)]
16struct UnusedSymbol {
17    name: String,
18    qualified_name: String,
19    kind: String,
20    file: String,
21    line: u32,
22    language: String,
23    visibility: String,
24}
25
26/// Unused symbols grouped by file
27#[derive(Debug, Serialize)]
28struct UnusedByFile {
29    file: String,
30    count: usize,
31    symbols: Vec<UnusedSymbol>,
32}
33
34/// Run the unused command.
35///
36/// # Errors
37/// Returns an error if the graph cannot be loaded.
38pub fn run_unused(
39    cli: &Cli,
40    path: Option<&str>,
41    scope: &str,
42    lang_filter: Option<&str>,
43    kind_filter: Option<&str>,
44    max_results: usize,
45) -> Result<()> {
46    let mut streams = OutputStreams::new();
47
48    // Parse scope
49    let unused_scope = UnusedScope::try_parse(scope).with_context(|| {
50        format!("Invalid scope: {scope}. Use: public, private, function, struct, all")
51    })?;
52
53    // Find index
54    let search_path = path.map_or_else(
55        || std::env::current_dir().unwrap_or_default(),
56        std::path::PathBuf::from,
57    );
58
59    let index_location = find_nearest_index(&search_path);
60    let Some(ref loc) = index_location else {
61        streams
62            .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
63        return Ok(());
64    };
65
66    // Load unified graph
67    let config = GraphLoadConfig::default();
68    let graph = load_unified_graph(&loc.index_root, &config)
69        .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
70
71    // Compute reachable set once for performance
72    let reachable = compute_reachable_set_graph(&graph);
73
74    let strings = graph.strings();
75    let files = graph.files();
76
77    // Find unused symbols
78    let mut unused_symbols: Vec<UnusedSymbol> = Vec::new();
79    let mut count = 0;
80
81    for (node_id, entry) in graph.nodes().iter() {
82        if count >= max_results {
83            break;
84        }
85
86        // Get language
87        let language = files
88            .language_for_file(entry.file)
89            .map_or_else(|| "Unknown".to_string(), |l| l.to_string());
90
91        // Apply language filter
92        if let Some(lang) = lang_filter
93            && !language.to_lowercase().contains(&lang.to_lowercase())
94        {
95            continue;
96        }
97
98        // Apply kind filter
99        if let Some(kind) = kind_filter {
100            let kind_str = format!("{:?}", entry.kind).to_lowercase();
101            if !kind_str.contains(&kind.to_lowercase()) {
102                continue;
103            }
104        }
105
106        // Check if unused
107        if is_node_unused(node_id, unused_scope, &graph, Some(&reachable)) {
108            let name = strings
109                .resolve(entry.name)
110                .map(|s| s.to_string())
111                .unwrap_or_default();
112
113            let qualified_name = entry
114                .qualified_name
115                .and_then(|id| strings.resolve(id))
116                .map_or_else(|| name.clone(), |s| s.to_string());
117
118            let file_path = files
119                .resolve(entry.file)
120                .map(|p| p.display().to_string())
121                .unwrap_or_default();
122
123            let visibility = entry
124                .visibility
125                .and_then(|id| strings.resolve(id))
126                .map_or_else(|| "unknown".to_string(), |s| s.to_string());
127
128            unused_symbols.push(UnusedSymbol {
129                name,
130                qualified_name,
131                kind: format!("{:?}", entry.kind),
132                file: file_path,
133                line: entry.start_line,
134                language,
135                visibility,
136            });
137            count += 1;
138        }
139    }
140
141    // Group by file for text output
142    let mut by_file: HashMap<String, Vec<UnusedSymbol>> = HashMap::new();
143    for sym in unused_symbols {
144        by_file.entry(sym.file.clone()).or_default().push(sym);
145    }
146
147    let mut grouped: Vec<UnusedByFile> = by_file
148        .into_iter()
149        .map(|(file, symbols)| UnusedByFile {
150            file,
151            count: symbols.len(),
152            symbols,
153        })
154        .collect();
155
156    // Sort by file path
157    grouped.sort_by(|a, b| a.file.cmp(&b.file));
158
159    // Output
160    if cli.json {
161        let json = serde_json::to_string_pretty(&grouped).context("Failed to serialize to JSON")?;
162        streams.write_result(&json)?;
163    } else {
164        let output = format_unused_text(&grouped, unused_scope);
165        streams.write_result(&output)?;
166    }
167
168    Ok(())
169}
170
171/// Format unused symbols as human-readable text
172fn format_unused_text(groups: &[UnusedByFile], scope: UnusedScope) -> String {
173    let mut lines = Vec::new();
174
175    let total: usize = groups.iter().map(|g| g.count).sum();
176    let scope_name = match scope {
177        UnusedScope::Public => "public",
178        UnusedScope::Private => "private",
179        UnusedScope::Function => "function",
180        UnusedScope::Struct => "struct",
181        UnusedScope::All => "all",
182    };
183
184    lines.push(format!(
185        "Found {total} unused symbols (scope: {scope_name})"
186    ));
187    lines.push(String::new());
188
189    for group in groups {
190        lines.push(format!("{} ({} unused):", group.file, group.count));
191        for sym in &group.symbols {
192            lines.push(format!("  {} [{}] line {}", sym.name, sym.kind, sym.line));
193        }
194        lines.push(String::new());
195    }
196
197    if groups.is_empty() {
198        lines.push("No unused symbols found.".to_string());
199    }
200
201    lines.join("\n")
202}