use std::collections::BTreeMap;
use crate::config::Context;
use crate::db;
use crate::graph::code_graph;
use crate::models::{GraphResult, PagedResponse};
use crate::output::{self, Format};
use crate::search::fts::{self, ResolvedGraphSymbol};
use serde::Serialize;
const GRAPH_BACKEND_HINT: &str =
"Graph commands require a configured FalkorDB graph backend and synced graph projection.";
fn hint_for(ctx: &Context) -> Option<String> {
if ctx.falkordb.is_none() {
Some(GRAPH_BACKEND_HINT.to_string())
} else {
None
}
}
fn hint_for_error(ctx: &Context, error: &anyhow::Error) -> Option<String> {
match error.downcast_ref::<code_graph::GraphReadError>() {
Some(code_graph::GraphReadError::NotConfigured) => hint_for(ctx),
Some(code_graph::GraphReadError::Unreachable { message }) => Some(format!(
"FalkorDB is configured but unreachable; graph results are unavailable: {message}"
)),
_ => hint_for(ctx),
}
}
fn print_graph_hint_text(ctx: &Context, error: Option<&anyhow::Error>) {
let hint = error.and_then(|err| hint_for_error(ctx, err));
let hint = hint.or_else(|| hint_for(ctx));
if let Some(hint) = hint {
eprintln!("Hint: {hint}");
}
}
fn graph_read_unavailable(error: &anyhow::Error) -> bool {
matches!(
error.downcast_ref::<code_graph::GraphReadError>(),
Some(
code_graph::GraphReadError::NotConfigured
| code_graph::GraphReadError::Unreachable { .. }
)
)
}
fn empty_paged_response<T: Serialize>(
ctx: &Context,
offset: usize,
limit: usize,
format: Format,
error: Option<&anyhow::Error>,
) -> anyhow::Result<()> {
match format {
Format::Json => output::print_json(&PagedResponse::<T> {
project_id: ctx.project_id.clone(),
total: 0,
offset,
limit,
results: vec![],
hint: error
.and_then(|err| hint_for_error(ctx, err))
.or_else(|| hint_for(ctx)),
}),
Format::Text => {
print_graph_hint_text(ctx, error);
Ok(())
}
}
}
fn graph_read_or_empty<T: Serialize>(
ctx: &Context,
offset: usize,
limit: usize,
format: Format,
read: impl FnOnce() -> anyhow::Result<T>,
) -> anyhow::Result<Option<T>> {
match read() {
Ok(value) => Ok(Some(value)),
Err(err) if graph_read_unavailable(&err) => {
empty_paged_response::<T>(ctx, offset, limit, format, Some(&err))?;
Ok(None)
}
Err(err) => Err(err),
}
}
pub(super) fn format_grouped_graph_results<F>(results: &[GraphResult], format_line: F) -> String
where
F: Fn(&GraphResult) -> String,
{
let mut grouped: BTreeMap<&str, Vec<&GraphResult>> = BTreeMap::new();
for result in results {
grouped.entry(&result.file_path).or_default().push(result);
}
let mut lines = Vec::new();
for (file_path, mut entries) in grouped {
lines.push(if file_path.is_empty() {
"<unknown>".to_string()
} else {
file_path.to_string()
});
entries.sort_by(|a, b| {
a.line
.cmp(&b.line)
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.relation.cmp(&b.relation))
.then_with(|| a.distance.cmp(&b.distance))
});
lines.extend(entries.into_iter().map(&format_line));
}
lines.join("\n")
}
fn resolve_symbol(ctx: &Context, input: &str) -> anyhow::Result<Option<ResolvedGraphSymbol>> {
let mut conn = match db::connect_readonly(&ctx.database_url) {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to open index for graph resolution: {e}");
return Ok(None);
}
};
let (resolved, suggestions) = fts::resolve_graph_symbol(&mut conn, input, &ctx.project_id)?;
if resolved.is_none() {
if suggestions.is_empty() {
eprintln!("No symbol matching '{input}' found");
} else {
eprintln!(
"Ambiguous symbol '{input}'. Refine the query. Matches: {}",
suggestions.join(", ")
);
}
}
Ok(resolved)
}
fn resolve_symbol_or_empty_response(
ctx: &Context,
input: &str,
offset: usize,
limit: usize,
format: Format,
) -> anyhow::Result<Option<ResolvedGraphSymbol>> {
match resolve_symbol(ctx, input)? {
Some(symbol) => Ok(Some(symbol)),
None => {
empty_paged_response::<crate::models::GraphResult>(ctx, offset, limit, format, None)?;
Ok(None)
}
}
}
fn read_paged_symbol_graph_results(
ctx: &Context,
symbol_name: &str,
limit: usize,
offset: usize,
format: Format,
count: impl FnOnce(&Context, &str) -> anyhow::Result<usize>,
find: impl FnOnce(&Context, &str, usize, usize) -> anyhow::Result<Vec<GraphResult>>,
) -> anyhow::Result<Option<(ResolvedGraphSymbol, usize, Vec<GraphResult>)>> {
let Some(()) = graph_read_or_empty::<()>(ctx, offset, limit, format, || {
code_graph::require_graph_reads(ctx)
})?
else {
return Ok(None);
};
let Some(symbol) = resolve_symbol_or_empty_response(ctx, symbol_name, offset, limit, format)?
else {
return Ok(None);
};
let Some(total) =
graph_read_or_empty::<usize>(ctx, offset, limit, format, || count(ctx, &symbol.id))?
else {
return Ok(None);
};
let Some(results) =
graph_read_or_empty::<Vec<GraphResult>>(ctx, offset, limit, format, || {
find(ctx, &symbol.id, offset, limit)
})?
else {
return Ok(None);
};
Ok(Some((symbol, total, results)))
}
pub fn callers(
ctx: &Context,
symbol_name: &str,
limit: usize,
offset: usize,
format: Format,
) -> anyhow::Result<()> {
let Some((symbol, total, results)) = read_paged_symbol_graph_results(
ctx,
symbol_name,
limit,
offset,
format,
code_graph::count_callers,
code_graph::find_callers,
)?
else {
return Ok(());
};
match format {
Format::Json => output::print_json(&PagedResponse {
project_id: ctx.project_id.clone(),
total,
offset,
limit,
results,
hint: hint_for(ctx),
}),
Format::Text => {
if results.is_empty() && offset == 0 {
output::print_text(&format!("No callers found for '{}'", symbol.display_name))?;
print_graph_hint_text(ctx, None);
} else if results.is_empty() {
eprintln!("No callers at offset {offset} (total {total})");
} else {
output::print_text(&format_grouped_graph_results(&results, |r| {
format!("{} {} -> {}", r.line, r.name, symbol.display_name)
}))?;
if total > offset + results.len() {
eprintln!(
"-- {} of {} results (use --offset {} for more)",
results.len(),
total,
offset + results.len()
);
}
}
Ok(())
}
}
}
pub fn usages(
ctx: &Context,
symbol_name: &str,
limit: usize,
offset: usize,
format: Format,
) -> anyhow::Result<()> {
let Some((symbol, total, results)) = read_paged_symbol_graph_results(
ctx,
symbol_name,
limit,
offset,
format,
code_graph::count_usages,
code_graph::find_usages,
)?
else {
return Ok(());
};
match format {
Format::Json => output::print_json(&PagedResponse {
project_id: ctx.project_id.clone(),
total,
offset,
limit,
results,
hint: hint_for(ctx),
}),
Format::Text => {
if results.is_empty() && offset == 0 {
output::print_text(&format!("No usages found for '{}'", symbol.display_name))?;
print_graph_hint_text(ctx, None);
} else if results.is_empty() {
eprintln!("No usages at offset {offset} (total {total})");
} else {
output::print_text(&format_grouped_graph_results(&results, |r| {
let rel = r.relation.as_deref().unwrap_or("unknown");
format!("{} [{}] {} -> {}", r.line, rel, r.name, symbol.display_name)
}))?;
if total > offset + results.len() {
eprintln!(
"-- {} of {} results (use --offset {} for more)",
results.len(),
total,
offset + results.len()
);
}
}
Ok(())
}
}
}
pub fn imports(ctx: &Context, file: &str, format: Format) -> anyhow::Result<()> {
let Some(()) =
graph_read_or_empty::<()>(ctx, 0, 0, format, || code_graph::require_graph_reads(ctx))?
else {
return Ok(());
};
let Some(results) =
graph_read_or_empty::<Vec<crate::models::GraphResult>>(ctx, 0, 0, format, || {
code_graph::get_imports(ctx, file)
})?
else {
return Ok(());
};
let total = results.len();
match format {
Format::Json => output::print_json(&PagedResponse {
project_id: ctx.project_id.clone(),
total,
offset: 0,
limit: total,
results,
hint: hint_for(ctx),
}),
Format::Text => {
if results.is_empty() {
output::print_text(&format!("No imports found for '{file}'"))?;
print_graph_hint_text(ctx, None);
} else {
for r in &results {
output::print_text(&r.name)?;
}
}
Ok(())
}
}
}
pub fn blast_radius(
ctx: &Context,
target: &str,
depth: usize,
format: Format,
) -> anyhow::Result<()> {
let Some(()) =
graph_read_or_empty::<()>(ctx, 0, 0, format, || code_graph::require_graph_reads(ctx))?
else {
return Ok(());
};
let Some(symbol) = resolve_symbol_or_empty_response(ctx, target, 0, 0, format)? else {
return Ok(());
};
let Some(results) =
graph_read_or_empty::<Vec<crate::models::GraphResult>>(ctx, 0, 0, format, || {
code_graph::blast_radius(ctx, &symbol.id, depth)
})?
else {
return Ok(());
};
let total = results.len();
match format {
Format::Json => output::print_json(&PagedResponse {
project_id: ctx.project_id.clone(),
total,
offset: 0,
limit: total,
results,
hint: hint_for(ctx),
}),
Format::Text => {
if results.is_empty() {
output::print_text(&format!(
"No blast radius found for '{}'",
symbol.display_name
))?;
print_graph_hint_text(ctx, None);
} else {
output::print_text(&format_grouped_graph_results(&results, |r| {
let dist = r.distance.unwrap_or(0);
format!("{} [distance={}] {}", r.line, dist, r.name)
}))?;
}
Ok(())
}
}
}