use anyhow::Context as _;
use reqwest::StatusCode;
use serde_json::Value;
use crate::config::Context;
use crate::db;
use crate::falkor;
use crate::models::PagedResponse;
use crate::output::{self, Format};
use crate::search::fts::{self, ResolvedGraphSymbol};
const GOBBY_HINT: &str =
"Graph commands require FalkorDB, available with Gobby. See: https://github.com/GobbyAI/gobby";
#[derive(Clone, Copy, Debug)]
enum GraphLifecycleAction {
Clear,
Rebuild,
}
impl GraphLifecycleAction {
fn cli_command(self) -> &'static str {
match self {
Self::Clear => "gcode graph clear",
Self::Rebuild => "gcode graph rebuild",
}
}
fn endpoint_path(self) -> &'static str {
match self {
Self::Clear => "/api/code-index/graph/clear",
Self::Rebuild => "/api/code-index/graph/rebuild",
}
}
fn success_prefix(self) -> &'static str {
match self {
Self::Clear => "Cleared code-index graph",
Self::Rebuild => "Rebuilt code-index graph",
}
}
}
fn require_daemon_url(
daemon_url: Option<&str>,
action: GraphLifecycleAction,
) -> anyhow::Result<&str> {
daemon_url.ok_or_else(|| {
anyhow::anyhow!(
"Gobby daemon URL is not configured. `{}` requires the Gobby daemon.",
action.cli_command()
)
})
}
fn build_lifecycle_url(
base_url: &str,
action: GraphLifecycleAction,
project_id: &str,
) -> anyhow::Result<reqwest::Url> {
let base = base_url.trim_end_matches('/');
let mut url = reqwest::Url::parse(&format!("{base}{}", action.endpoint_path()))
.with_context(|| format!("invalid Gobby daemon URL: {base_url}"))?;
url.query_pairs_mut().append_pair("project_id", project_id);
Ok(url)
}
fn compact_detail(body: &str) -> String {
let detail = body.split_whitespace().collect::<Vec<_>>().join(" ");
let detail = detail.trim();
if detail.len() > 240 {
format!("{}...", &detail[..237])
} else {
detail.to_string()
}
}
fn format_http_error(
action: GraphLifecycleAction,
url: &reqwest::Url,
status: StatusCode,
body: &str,
) -> String {
let detail = compact_detail(body);
if detail.is_empty() {
format!(
"`{}` failed: daemon returned HTTP {status} from {url}",
action.cli_command()
)
} else {
format!(
"`{}` failed: daemon returned HTTP {status} from {url}: {detail}",
action.cli_command()
)
}
}
fn parse_success_payload(
action: GraphLifecycleAction,
status: StatusCode,
body: &str,
) -> anyhow::Result<Value> {
serde_json::from_str(body).map_err(|err| {
let detail = compact_detail(body);
if detail.is_empty() {
anyhow::anyhow!(
"`{}` failed: daemon returned HTTP {status} with invalid JSON: {err}",
action.cli_command()
)
} else {
anyhow::anyhow!(
"`{}` failed: daemon returned HTTP {status} with invalid JSON: {err}. Response: {detail}",
action.cli_command()
)
}
})
}
fn extract_summary_text(payload: &Value) -> Option<String> {
match payload {
Value::String(text) => {
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
}
Value::Object(map) => ["summary", "message", "detail", "status"]
.iter()
.find_map(|key| map.get(*key).and_then(Value::as_str))
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned),
_ => None,
}
}
fn format_success_text(
action: GraphLifecycleAction,
project_id: &str,
payload: &Value,
) -> anyhow::Result<String> {
let detail = match extract_summary_text(payload) {
Some(summary) => summary,
None => serde_json::to_string(payload)?,
};
Ok(format!(
"{} for project {}: {}",
action.success_prefix(),
project_id,
detail
))
}
fn run_lifecycle_action(
ctx: &Context,
action: GraphLifecycleAction,
format: Format,
) -> anyhow::Result<()> {
let daemon_url = require_daemon_url(ctx.daemon_url.as_deref(), action)?;
let url = build_lifecycle_url(daemon_url, action, &ctx.project_id)?;
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.context("failed to build HTTP client")?;
let response = client
.post(url.clone())
.header("Accept", "application/json")
.send()
.with_context(|| {
format!(
"Failed to reach Gobby daemon at {daemon_url} for `{}`",
action.cli_command()
)
})?;
let status = response.status();
let body = response.text().unwrap_or_default();
if !status.is_success() {
anyhow::bail!("{}", format_http_error(action, &url, status, &body));
}
let payload = parse_success_payload(action, status, &body)?;
match format {
Format::Json => output::print_json(&payload),
Format::Text => {
eprintln!(
"{}",
format_success_text(action, &ctx.project_id, &payload)?
);
output::print_text(&payload)
}
}
}
pub fn clear(ctx: &Context, format: Format) -> anyhow::Result<()> {
run_lifecycle_action(ctx, GraphLifecycleAction::Clear, format)
}
pub fn rebuild(ctx: &Context, format: Format) -> anyhow::Result<()> {
run_lifecycle_action(ctx, GraphLifecycleAction::Rebuild, format)
}
fn hint_for(ctx: &Context) -> Option<String> {
if ctx.falkordb.is_none() {
Some(GOBBY_HINT.to_string())
} else {
None
}
}
fn print_graph_hint_text(ctx: &Context) {
if ctx.falkordb.is_none() {
eprintln!("Hint: {GOBBY_HINT}");
}
}
fn empty_response_for_unresolved(ctx: &Context, format: Format) -> anyhow::Result<()> {
match format {
Format::Json => output::print_json(&PagedResponse::<Vec<()>> {
project_id: ctx.project_id.clone(),
total: 0,
offset: 0,
limit: 0,
results: vec![],
hint: hint_for(ctx),
}),
Format::Text => Ok(()),
}
}
fn resolve_symbol(ctx: &Context, input: &str) -> 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 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(", ")
);
}
}
resolved
}
pub fn callers(
ctx: &Context,
symbol_name: &str,
limit: usize,
offset: usize,
format: Format,
) -> anyhow::Result<()> {
let symbol = match resolve_symbol(ctx, symbol_name) {
Some(symbol) => symbol,
None => return empty_response_for_unresolved(ctx, format),
};
let total = falkor::count_callers(ctx, &symbol.id)?;
let results = falkor::find_callers(ctx, &symbol.id, offset, limit)?;
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 {
println!("No callers found for '{}'", symbol.display_name);
print_graph_hint_text(ctx);
} else if results.is_empty() {
eprintln!("No callers at offset {offset} (total {total})");
} else {
for r in &results {
println!(
"{}:{} {} -> {}",
r.file_path, 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 symbol = match resolve_symbol(ctx, symbol_name) {
Some(symbol) => symbol,
None => return empty_response_for_unresolved(ctx, format),
};
let total = falkor::count_usages(ctx, &symbol.id)?;
let results = falkor::find_usages(ctx, &symbol.id, offset, limit)?;
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 {
println!("No usages found for '{}'", symbol.display_name);
print_graph_hint_text(ctx);
} else if results.is_empty() {
eprintln!("No usages at offset {offset} (total {total})");
} else {
for r in &results {
let rel = r.relation.as_deref().unwrap_or("unknown");
println!(
"{}:{} [{}] {} -> {}",
r.file_path, 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 results = falkor::get_imports(ctx, file)?;
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() {
println!("No imports found for '{file}'");
print_graph_hint_text(ctx);
} else {
for r in &results {
println!("{}", r.name);
}
}
Ok(())
}
}
}
pub fn blast_radius(
ctx: &Context,
target: &str,
depth: usize,
format: Format,
) -> anyhow::Result<()> {
let symbol = match resolve_symbol(ctx, target) {
Some(symbol) => symbol,
None => return empty_response_for_unresolved(ctx, format),
};
let results = falkor::blast_radius(ctx, &symbol.id, depth)?;
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() {
println!("No blast radius found for '{}'", symbol.display_name);
print_graph_hint_text(ctx);
} else {
for r in &results {
let dist = r.distance.unwrap_or(0);
println!("{}:{} [distance={}] {}", r.file_path, r.line, dist, r.name);
}
}
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_build_lifecycle_url_clear_uses_project_id_query() {
let url = build_lifecycle_url(
"http://localhost:60887/",
GraphLifecycleAction::Clear,
"project-123",
)
.expect("url builds");
assert_eq!(
url.as_str(),
"http://localhost:60887/api/code-index/graph/clear?project_id=project-123"
);
}
#[test]
fn test_build_lifecycle_url_rebuild_uses_project_id_query() {
let url = build_lifecycle_url(
"http://localhost:60887",
GraphLifecycleAction::Rebuild,
"project-123",
)
.expect("url builds");
assert_eq!(
url.as_str(),
"http://localhost:60887/api/code-index/graph/rebuild?project_id=project-123"
);
}
#[test]
fn test_require_daemon_url_errors_when_missing() {
let err = require_daemon_url(None, GraphLifecycleAction::Clear).expect_err("must fail");
assert!(
err.to_string()
.contains("Gobby daemon URL is not configured"),
"unexpected error: {err}"
);
assert!(
err.to_string().contains("gcode graph clear"),
"unexpected error: {err}"
);
}
#[test]
fn test_format_http_error_includes_status_and_body() {
let url = reqwest::Url::parse("http://localhost:60887/api/code-index/graph/clear")
.expect("valid url");
let message = format_http_error(
GraphLifecycleAction::Clear,
&url,
StatusCode::BAD_GATEWAY,
"daemon upstream unavailable",
);
assert!(message.contains("HTTP 502"), "unexpected error: {message}");
assert!(
message.contains("daemon upstream unavailable"),
"unexpected error: {message}"
);
}
#[test]
fn test_parse_success_payload_fails_on_invalid_json() {
let err = parse_success_payload(GraphLifecycleAction::Rebuild, StatusCode::OK, "not json")
.expect_err("invalid json must fail");
assert!(
err.to_string().contains("invalid JSON"),
"unexpected error: {err}"
);
assert!(
err.to_string().contains("HTTP 200 OK"),
"unexpected error: {err}"
);
}
#[test]
fn test_format_success_text_prefers_message_field() {
let payload = json!({
"message": "cleared 12 graph nodes",
"removed_nodes": 12
});
let text = format_success_text(GraphLifecycleAction::Clear, "project-123", &payload)
.expect("text formats");
assert_eq!(
text,
"Cleared code-index graph for project project-123: cleared 12 graph nodes"
);
}
#[test]
fn test_format_success_text_falls_back_to_compact_json() {
let payload = json!({
"replayed": 18,
"synced": 18
});
let text = format_success_text(GraphLifecycleAction::Rebuild, "project-123", &payload)
.expect("text formats");
assert_eq!(
text,
"Rebuilt code-index graph for project project-123: {\"replayed\":18,\"synced\":18}"
);
}
}