use std::fmt;
use std::time::Duration;
use anyhow::Context as _;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::config::Context;
const GRAPH_CLEAR_TIMEOUT_ENV: &str = "GCODE_GRAPH_CLEAR_TIMEOUT_SECS";
const GRAPH_REBUILD_TIMEOUT_ENV: &str = "GCODE_GRAPH_REBUILD_TIMEOUT_SECS";
const DEFAULT_GRAPH_CLEAR_TIMEOUT_SECS: u64 = 15;
const DEFAULT_GRAPH_REBUILD_TIMEOUT_SECS: u64 = 120;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GraphLifecycleAction {
Clear,
Rebuild,
}
impl GraphLifecycleAction {
pub fn cli_command(self) -> &'static str {
match self {
Self::Clear => "gcode graph clear",
Self::Rebuild => "gcode graph rebuild",
}
}
pub fn endpoint_path(self) -> &'static str {
match self {
Self::Clear => "/api/code-index/graph/clear",
Self::Rebuild => "/api/code-index/graph/rebuild",
}
}
pub fn success_prefix(self) -> &'static str {
match self {
Self::Clear => "Cleared code-index graph",
Self::Rebuild => "Rebuilt code-index graph",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphLifecycleRequest {
pub project_id: String,
pub daemon_url: Option<String>,
#[serde(default)]
pub timeouts: GraphLifecycleTimeouts,
}
impl GraphLifecycleRequest {
pub fn from_context(ctx: &Context) -> Self {
Self {
project_id: ctx.project_id.clone(),
daemon_url: ctx.daemon_url.clone(),
timeouts: GraphLifecycleTimeouts::from_env(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphLifecycleTimeouts {
pub clear: Duration,
pub rebuild: Duration,
}
impl Default for GraphLifecycleTimeouts {
fn default() -> Self {
Self {
clear: Duration::from_secs(DEFAULT_GRAPH_CLEAR_TIMEOUT_SECS),
rebuild: Duration::from_secs(DEFAULT_GRAPH_REBUILD_TIMEOUT_SECS),
}
}
}
impl GraphLifecycleTimeouts {
pub fn from_env() -> Self {
Self {
clear: timeout_from_env(GRAPH_CLEAR_TIMEOUT_ENV, DEFAULT_GRAPH_CLEAR_TIMEOUT_SECS),
rebuild: timeout_from_env(
GRAPH_REBUILD_TIMEOUT_ENV,
DEFAULT_GRAPH_REBUILD_TIMEOUT_SECS,
),
}
}
fn for_action(self, action: GraphLifecycleAction) -> Duration {
match action {
GraphLifecycleAction::Clear => self.clear,
GraphLifecycleAction::Rebuild => self.rebuild,
}
}
}
fn timeout_from_env(key: &str, default_secs: u64) -> Duration {
std::env::var(key)
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value > 0)
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(default_secs))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GraphLifecycleOutput {
pub project_id: String,
pub action: GraphLifecycleAction,
pub summary: String,
pub payload: Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphReadRequest {
pub project_id: String,
pub symbol_id: String,
pub offset: usize,
pub limit: usize,
pub depth: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GraphReadError {
NotConfigured,
Unreachable { message: String },
QueryFailed { message: String },
InvalidTarget { message: String },
}
impl fmt::Display for GraphReadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotConfigured => {
f.write_str("FalkorDB is not configured; graph read APIs require FalkorDB")
}
Self::Unreachable { message } => {
write!(
f,
"FalkorDB is unreachable; graph read APIs require FalkorDB: {message}"
)
}
Self::QueryFailed { message } => {
write!(f, "FalkorDB graph read failed: {message}")
}
Self::InvalidTarget { message } => f.write_str(message),
}
}
}
impl std::error::Error for GraphReadError {}
pub 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()
)
})
}
pub(crate) 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)
}
pub(crate) fn compact_detail(body: &str) -> String {
let detail = body.split_whitespace().collect::<Vec<_>>().join(" ");
let detail = detail.trim();
const MAX_CHARS: usize = 240;
const TRUNCATED_CHARS: usize = MAX_CHARS - 3;
if detail.chars().count() > MAX_CHARS {
format!(
"{}...",
detail.chars().take(TRUNCATED_CHARS).collect::<String>()
)
} else {
detail.to_string()
}
}
pub(crate) 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()
)
}
}
pub(crate) 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()
)
}
})
}
pub(crate) 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,
}
}
pub fn run_lifecycle_action(
request: &GraphLifecycleRequest,
action: GraphLifecycleAction,
) -> anyhow::Result<GraphLifecycleOutput> {
let daemon_url = require_daemon_url(request.daemon_url.as_deref(), action)?;
let url = build_lifecycle_url(daemon_url, action, &request.project_id)?;
let client = reqwest::blocking::Client::builder()
.timeout(request.timeouts.for_action(action))
.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)?;
let summary = extract_summary_text(&payload).unwrap_or_else(|| payload.to_string());
Ok(GraphLifecycleOutput {
project_id: request.project_id.clone(),
action,
summary,
payload,
})
}