use crate::args::Cli;
use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph_for_cli};
use crate::index_discovery::find_nearest_index;
use crate::output::OutputStreams;
use anyhow::{Context, Result};
use serde::Serialize;
use sqry_core::graph::unified::concurrent::GraphSnapshot;
use sqry_core::graph::unified::node::id::NodeId;
use sqry_core::query::UnusedScope;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
#[derive(Debug, Serialize)]
struct UnusedSymbol {
name: String,
qualified_name: String,
kind: String,
file: String,
line: u32,
language: String,
visibility: String,
}
#[derive(Debug, Serialize)]
struct UnusedByFile {
file: String,
count: usize,
symbols: Vec<UnusedSymbol>,
}
pub fn run_unused(
cli: &Cli,
path: Option<&str>,
scope: &str,
lang_filter: Option<&str>,
kind_filter: Option<&str>,
max_results: usize,
) -> Result<()> {
let mut streams = OutputStreams::new();
let unused_scope = UnusedScope::try_parse(scope).with_context(|| {
format!("Invalid scope: {scope}. Use: public, private, function, struct, all")
})?;
let search_path = path.map_or_else(
|| std::env::current_dir().unwrap_or_default(),
std::path::PathBuf::from,
);
let index_location = find_nearest_index(&search_path);
let Some(ref loc) = index_location else {
streams
.write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
return Ok(());
};
let config = GraphLoadConfig::default();
let graph = load_unified_graph_for_cli(&loc.index_root, &config, cli)
.context("Failed to load graph. Run 'sqry index' to build the graph.")?;
let snapshot = std::sync::Arc::new(graph.snapshot());
let unused_ids =
boundary_filtered_unused_ids(&snapshot, &loc.index_root, unused_scope, max_results);
let strings = snapshot.strings();
let files = snapshot.files();
let mut unused_symbols: Vec<UnusedSymbol> = Vec::new();
for &node_id in &unused_ids {
if unused_symbols.len() >= max_results {
break;
}
let Some(entry) = snapshot.nodes().get(node_id) else {
continue;
};
let language = files
.language_for_file(entry.file)
.map_or_else(|| "Unknown".to_string(), |l| l.to_string());
if let Some(lang) = lang_filter
&& !language.to_lowercase().contains(&lang.to_lowercase())
{
continue;
}
if let Some(kind) = kind_filter {
let kind_str = format!("{:?}", entry.kind).to_lowercase();
if !kind_str.contains(&kind.to_lowercase()) {
continue;
}
}
let name = strings
.resolve(entry.name)
.map(|s| s.to_string())
.unwrap_or_default();
let qualified_name = entry
.qualified_name
.and_then(|id| strings.resolve(id))
.map_or_else(|| name.clone(), |s| s.to_string());
let file_path = files
.resolve(entry.file)
.map(|p| p.display().to_string())
.unwrap_or_default();
let visibility = entry
.visibility
.and_then(|id| strings.resolve(id))
.map_or_else(|| "unknown".to_string(), |s| s.to_string());
unused_symbols.push(UnusedSymbol {
name,
qualified_name,
kind: format!("{:?}", entry.kind),
file: file_path,
line: entry.start_line,
language,
visibility,
});
}
let mut by_file: HashMap<String, Vec<UnusedSymbol>> = HashMap::new();
for sym in unused_symbols {
by_file.entry(sym.file.clone()).or_default().push(sym);
}
let mut grouped: Vec<UnusedByFile> = by_file
.into_iter()
.map(|(file, symbols)| UnusedByFile {
file,
count: symbols.len(),
symbols,
})
.collect();
grouped.sort_by(|a, b| a.file.cmp(&b.file));
if cli.json {
let json = serde_json::to_string_pretty(&grouped).context("Failed to serialize to JSON")?;
streams.write_result(&json)?;
} else {
let output = format_unused_text(&grouped, unused_scope);
streams.write_result(&output)?;
}
Ok(())
}
fn boundary_filtered_unused_ids(
snapshot: &Arc<GraphSnapshot>,
index_root: &Path,
unused_scope: UnusedScope,
max_results: usize,
) -> Vec<NodeId> {
let db = sqry_db::queries::dispatch::make_query_db_cold(Arc::clone(snapshot), index_root);
let candidate_cap = snapshot.nodes().len().max(max_results);
let key = sqry_db::queries::UnusedKey {
scope: unused_scope,
max_results: candidate_cap,
};
let raw_unused_ids = db.get::<sqry_db::queries::UnusedQuery>(&key);
sqry_db::queries::unused_post_filter::apply_binding_plane_post_filter(
&raw_unused_ids,
snapshot,
&db,
)
}
fn format_unused_text(groups: &[UnusedByFile], scope: UnusedScope) -> String {
let mut lines = Vec::new();
let total: usize = groups.iter().map(|g| g.count).sum();
let scope_name = match scope {
UnusedScope::Public => "public",
UnusedScope::Private => "private",
UnusedScope::Function => "function",
UnusedScope::Struct => "struct",
UnusedScope::All => "all",
};
lines.push(format!(
"Found {total} unused symbols (scope: {scope_name})"
));
lines.push(String::new());
for group in groups {
lines.push(format!("{} ({} unused):", group.file, group.count));
for sym in &group.symbols {
lines.push(format!(" {} [{}] line {}", sym.name, sym.kind, sym.line));
}
lines.push(String::new());
}
if groups.is_empty() {
lines.push("No unused symbols found.".to_string());
}
lines.join("\n")
}