gobby_code/graph/code_graph/
lifecycle.rs1use 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}