Skip to main content

gobby_code/graph/code_graph/
lifecycle.rs

1use std::fmt;
2use std::time::Duration;
3
4use anyhow::Context as _;
5use reqwest::StatusCode;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use crate::config::Context;
10
11const GRAPH_CLEAR_TIMEOUT_ENV: &str = "GCODE_GRAPH_CLEAR_TIMEOUT_SECS";
12const GRAPH_REBUILD_TIMEOUT_ENV: &str = "GCODE_GRAPH_REBUILD_TIMEOUT_SECS";
13const DEFAULT_GRAPH_CLEAR_TIMEOUT_SECS: u64 = 15;
14const DEFAULT_GRAPH_REBUILD_TIMEOUT_SECS: u64 = 120;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum GraphLifecycleAction {
19    Clear,
20    Rebuild,
21}
22
23impl GraphLifecycleAction {
24    pub fn cli_command(self) -> &'static str {
25        match self {
26            Self::Clear => "gcode graph clear",
27            Self::Rebuild => "gcode graph rebuild",
28        }
29    }
30
31    pub fn endpoint_path(self) -> &'static str {
32        match self {
33            Self::Clear => "/api/code-index/graph/clear",
34            Self::Rebuild => "/api/code-index/graph/rebuild",
35        }
36    }
37
38    pub fn success_prefix(self) -> &'static str {
39        match self {
40            Self::Clear => "Cleared code-index graph",
41            Self::Rebuild => "Rebuilt code-index graph",
42        }
43    }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct GraphLifecycleRequest {
48    pub project_id: String,
49    pub daemon_url: Option<String>,
50    #[serde(default)]
51    pub timeouts: GraphLifecycleTimeouts,
52}
53
54impl GraphLifecycleRequest {
55    pub fn from_context(ctx: &Context) -> Self {
56        Self {
57            project_id: ctx.project_id.clone(),
58            daemon_url: ctx.daemon_url.clone(),
59            timeouts: GraphLifecycleTimeouts::from_env(),
60        }
61    }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65pub struct GraphLifecycleTimeouts {
66    pub clear: Duration,
67    pub rebuild: Duration,
68}
69
70impl Default for GraphLifecycleTimeouts {
71    fn default() -> Self {
72        Self {
73            clear: Duration::from_secs(DEFAULT_GRAPH_CLEAR_TIMEOUT_SECS),
74            rebuild: Duration::from_secs(DEFAULT_GRAPH_REBUILD_TIMEOUT_SECS),
75        }
76    }
77}
78
79impl GraphLifecycleTimeouts {
80    pub fn from_env() -> Self {
81        Self {
82            clear: timeout_from_env(GRAPH_CLEAR_TIMEOUT_ENV, DEFAULT_GRAPH_CLEAR_TIMEOUT_SECS),
83            rebuild: timeout_from_env(
84                GRAPH_REBUILD_TIMEOUT_ENV,
85                DEFAULT_GRAPH_REBUILD_TIMEOUT_SECS,
86            ),
87        }
88    }
89
90    fn for_action(self, action: GraphLifecycleAction) -> Duration {
91        match action {
92            GraphLifecycleAction::Clear => self.clear,
93            GraphLifecycleAction::Rebuild => self.rebuild,
94        }
95    }
96}
97
98fn timeout_from_env(key: &str, default_secs: u64) -> Duration {
99    std::env::var(key)
100        .ok()
101        .and_then(|value| value.parse::<u64>().ok())
102        .filter(|value| *value > 0)
103        .map(Duration::from_secs)
104        .unwrap_or_else(|| Duration::from_secs(default_secs))
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
108pub struct GraphLifecycleOutput {
109    pub project_id: String,
110    pub action: GraphLifecycleAction,
111    pub summary: String,
112    pub payload: Value,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct GraphReadRequest {
117    pub project_id: String,
118    pub symbol_id: String,
119    pub offset: usize,
120    pub limit: usize,
121    pub depth: usize,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum GraphReadError {
126    NotConfigured,
127    Unreachable { message: String },
128    QueryFailed { message: String },
129    InvalidTarget { message: String },
130}
131
132impl fmt::Display for GraphReadError {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match self {
135            Self::NotConfigured => {
136                f.write_str("FalkorDB is not configured; graph read APIs require FalkorDB")
137            }
138            Self::Unreachable { message } => {
139                write!(
140                    f,
141                    "FalkorDB is unreachable; graph read APIs require FalkorDB: {message}"
142                )
143            }
144            Self::QueryFailed { message } => {
145                write!(f, "FalkorDB graph read failed: {message}")
146            }
147            Self::InvalidTarget { message } => f.write_str(message),
148        }
149    }
150}
151
152impl std::error::Error for GraphReadError {}
153
154pub fn require_daemon_url(
155    daemon_url: Option<&str>,
156    action: GraphLifecycleAction,
157) -> anyhow::Result<&str> {
158    daemon_url.ok_or_else(|| {
159        anyhow::anyhow!(
160            "Gobby daemon URL is not configured. `{}` requires the Gobby daemon.",
161            action.cli_command()
162        )
163    })
164}
165
166pub(crate) fn build_lifecycle_url(
167    base_url: &str,
168    action: GraphLifecycleAction,
169    project_id: &str,
170) -> anyhow::Result<reqwest::Url> {
171    let base = base_url.trim_end_matches('/');
172    let mut url = reqwest::Url::parse(&format!("{base}{}", action.endpoint_path()))
173        .with_context(|| format!("invalid Gobby daemon URL: {base_url}"))?;
174    url.query_pairs_mut().append_pair("project_id", project_id);
175    Ok(url)
176}
177
178pub(crate) fn compact_detail(body: &str) -> String {
179    let detail = body.split_whitespace().collect::<Vec<_>>().join(" ");
180    let detail = detail.trim();
181    const MAX_CHARS: usize = 240;
182    const TRUNCATED_CHARS: usize = MAX_CHARS - 3;
183    if detail.chars().count() > MAX_CHARS {
184        format!(
185            "{}...",
186            detail.chars().take(TRUNCATED_CHARS).collect::<String>()
187        )
188    } else {
189        detail.to_string()
190    }
191}
192
193pub(crate) fn format_http_error(
194    action: GraphLifecycleAction,
195    url: &reqwest::Url,
196    status: StatusCode,
197    body: &str,
198) -> String {
199    let detail = compact_detail(body);
200    if detail.is_empty() {
201        format!(
202            "`{}` failed: daemon returned HTTP {status} from {url}",
203            action.cli_command()
204        )
205    } else {
206        format!(
207            "`{}` failed: daemon returned HTTP {status} from {url}: {detail}",
208            action.cli_command()
209        )
210    }
211}
212
213pub(crate) fn parse_success_payload(
214    action: GraphLifecycleAction,
215    status: StatusCode,
216    body: &str,
217) -> anyhow::Result<Value> {
218    serde_json::from_str(body).map_err(|err| {
219        let detail = compact_detail(body);
220        if detail.is_empty() {
221            anyhow::anyhow!(
222                "`{}` failed: daemon returned HTTP {status} with invalid JSON: {err}",
223                action.cli_command()
224            )
225        } else {
226            anyhow::anyhow!(
227                "`{}` failed: daemon returned HTTP {status} with invalid JSON: {err}. Response: {detail}",
228                action.cli_command()
229            )
230        }
231    })
232}
233
234pub(crate) fn extract_summary_text(payload: &Value) -> Option<String> {
235    match payload {
236        Value::String(text) => {
237            let text = text.trim();
238            (!text.is_empty()).then(|| text.to_string())
239        }
240        Value::Object(map) => ["summary", "message", "detail", "status"]
241            .iter()
242            .find_map(|key| map.get(*key).and_then(Value::as_str))
243            .map(str::trim)
244            .filter(|text| !text.is_empty())
245            .map(ToOwned::to_owned),
246        _ => None,
247    }
248}
249
250pub fn run_lifecycle_action(
251    request: &GraphLifecycleRequest,
252    action: GraphLifecycleAction,
253) -> anyhow::Result<GraphLifecycleOutput> {
254    let daemon_url = require_daemon_url(request.daemon_url.as_deref(), action)?;
255    let url = build_lifecycle_url(daemon_url, action, &request.project_id)?;
256    let client = reqwest::blocking::Client::builder()
257        .timeout(request.timeouts.for_action(action))
258        .build()
259        .context("failed to build HTTP client")?;
260
261    let response = client
262        .post(url.clone())
263        .header("Accept", "application/json")
264        .send()
265        .with_context(|| {
266            format!(
267                "Failed to reach Gobby daemon at {daemon_url} for `{}`",
268                action.cli_command()
269            )
270        })?;
271
272    let status = response.status();
273    let body = response.text().unwrap_or_default();
274    if !status.is_success() {
275        anyhow::bail!("{}", format_http_error(action, &url, status, &body));
276    }
277
278    let payload = parse_success_payload(action, status, &body)?;
279    let summary = extract_summary_text(&payload).unwrap_or_else(|| payload.to_string());
280    Ok(GraphLifecycleOutput {
281        project_id: request.project_id.clone(),
282        action,
283        summary,
284        payload,
285    })
286}