Skip to main content

routa_server/api/
graph.rs

1use std::path::{Path, PathBuf};
2use std::process::Stdio;
3use std::time::Duration;
4
5use axum::{
6    extract::{Query, State},
7    http::StatusCode,
8    routing::get,
9    Json, Router,
10};
11use serde::Deserialize;
12use serde_json::Value;
13use tokio::process::Command;
14use tokio::time::timeout;
15
16use crate::api::repo_context::{json_error, resolve_repo_root, ResolveRepoRootOptions};
17use crate::state::AppState;
18
19const GRAPH_ANALYZE_TIMEOUT_MS: u64 = 30_000;
20const GRAPH_LANG_VALUES: &[&str] = &["auto", "rust", "typescript", "java"];
21const GRAPH_DEPTH_VALUES: &[&str] = &["fast", "normal"];
22
23pub fn router() -> Router<AppState> {
24    Router::new().route("/analyze", get(analyze_graph))
25}
26
27#[derive(Debug, Default, Deserialize)]
28#[serde(rename_all = "camelCase")]
29struct GraphAnalyzeQuery {
30    workspace_id: Option<String>,
31    codebase_id: Option<String>,
32    repo_path: Option<String>,
33    repo_root: Option<String>,
34    lang: Option<String>,
35    depth: Option<String>,
36}
37
38async fn analyze_graph(
39    State(state): State<AppState>,
40    Query(query): Query<GraphAnalyzeQuery>,
41) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
42    let repo_path = query.repo_path.as_deref().or(query.repo_root.as_deref());
43    let repo_root = resolve_repo_root(
44        &state,
45        query.workspace_id.as_deref(),
46        query.codebase_id.as_deref(),
47        repo_path,
48        "缺少 graph 分析上下文,请提供 workspaceId / codebaseId / repoPath / repoRoot 之一",
49        ResolveRepoRootOptions {
50            prefer_current_repo_for_default_workspace: true,
51        },
52    )
53    .await
54    .map_err(map_context_error(
55        "Graph 分析上下文无效",
56        "Graph 分析调用失败",
57    ))?;
58
59    let lang = normalize_graph_lang(query.lang.as_deref()).map_err(|details| {
60        (
61            StatusCode::BAD_REQUEST,
62            Json(json_error("Invalid graph language", details)),
63        )
64    })?;
65    let depth = normalize_graph_depth(query.depth.as_deref()).map_err(|details| {
66        (
67            StatusCode::BAD_REQUEST,
68            Json(json_error("Invalid graph depth", details)),
69        )
70    })?;
71
72    let payload = run_graph_command(&repo_root, &lang, &depth)
73        .await
74        .map_err(|details| {
75            (
76                StatusCode::INTERNAL_SERVER_ERROR,
77                Json(json_error("Failed to analyze dependency graph", details)),
78            )
79        })?;
80
81    Ok(Json(payload))
82}
83
84fn normalize_graph_lang(value: Option<&str>) -> Result<String, String> {
85    let lang = value.unwrap_or("auto").trim().to_ascii_lowercase();
86    if GRAPH_LANG_VALUES.contains(&lang.as_str()) {
87        Ok(lang)
88    } else {
89        Err(format!(
90            "expected one of [{}], got {lang}",
91            GRAPH_LANG_VALUES.join(", ")
92        ))
93    }
94}
95
96fn normalize_graph_depth(value: Option<&str>) -> Result<String, String> {
97    let depth = value.unwrap_or("fast").trim().to_ascii_lowercase();
98    if GRAPH_DEPTH_VALUES.contains(&depth.as_str()) {
99        Ok(depth)
100    } else {
101        Err(format!(
102            "expected one of [{}], got {depth}",
103            GRAPH_DEPTH_VALUES.join(", ")
104        ))
105    }
106}
107
108async fn run_graph_command(repo_root: &Path, lang: &str, depth: &str) -> Result<Value, String> {
109    let app_root = std::env::current_dir()
110        .map_err(|error| format!("failed to determine app root for graph analysis: {error}"))?;
111    let mut command = build_graph_command(&app_root, repo_root, lang, depth);
112    command
113        .current_dir(&app_root)
114        .stdout(Stdio::piped())
115        .stderr(Stdio::piped());
116
117    let output = timeout(
118        Duration::from_millis(GRAPH_ANALYZE_TIMEOUT_MS),
119        command.output(),
120    )
121    .await
122    .map_err(|_| {
123        format!(
124            "graph analysis command timed out after {}ms",
125            GRAPH_ANALYZE_TIMEOUT_MS
126        )
127    })?
128    .map_err(|error| format!("graph analysis command failed to execute: {error}"))?;
129
130    if !output.status.success() {
131        let stdout = String::from_utf8_lossy(&output.stdout);
132        let stderr = String::from_utf8_lossy(&output.stderr);
133        let details = if stderr.trim().is_empty() {
134            stdout.trim().to_string()
135        } else {
136            stderr.trim().to_string()
137        };
138        return Err(format!(
139            "graph analysis command failed (exit {}): {}",
140            output.status.code().unwrap_or(1),
141            details
142        ));
143    }
144
145    let stdout = String::from_utf8_lossy(&output.stdout);
146    let json_text = extract_json_output(&stdout)?;
147    serde_json::from_str(&json_text)
148        .map_err(|error| format!("failed to parse graph analysis output: {error}"))
149}
150
151fn build_graph_command(app_root: &Path, repo_root: &Path, lang: &str, depth: &str) -> Command {
152    let graph_args = vec![
153        "graph".to_string(),
154        "analyze".to_string(),
155        "-d".to_string(),
156        repo_root.display().to_string(),
157        "-l".to_string(),
158        lang.to_string(),
159        "--depth".to_string(),
160        depth.to_string(),
161        "-f".to_string(),
162        "json".to_string(),
163    ];
164
165    if let Some(binary) = resolve_local_routa_binary(app_root) {
166        let mut command = Command::new(binary);
167        command.args(&graph_args);
168        command
169    } else {
170        let mut cargo_args = vec![
171            "run".to_string(),
172            "-p".to_string(),
173            "routa-cli".to_string(),
174            "--".to_string(),
175        ];
176        cargo_args.extend(graph_args);
177        let mut command = Command::new("cargo");
178        command.args(cargo_args);
179        command
180    }
181}
182
183fn resolve_local_routa_binary(app_root: &Path) -> Option<PathBuf> {
184    let candidates = [
185        app_root.join("target/release/routa"),
186        app_root.join("target/debug/routa"),
187    ];
188    candidates.into_iter().find(|candidate| candidate.is_file())
189}
190
191fn extract_json_output(raw: &str) -> Result<String, String> {
192    let candidate = raw.trim();
193    if candidate.is_empty() {
194        return Err("Command produced no output".to_string());
195    }
196    if serde_json::from_str::<Value>(candidate).is_ok() {
197        return Ok(candidate.to_string());
198    }
199    for (index, ch) in candidate.char_indices().rev() {
200        if ch != '{' {
201            continue;
202        }
203        let snippet = candidate[index..].trim();
204        if snippet.ends_with('}') && serde_json::from_str::<Value>(snippet).is_ok() {
205            return Ok(snippet.to_string());
206        }
207    }
208    Err("Unable to parse command JSON output".to_string())
209}
210
211fn map_context_error(
212    client_error: &'static str,
213    server_error: &'static str,
214) -> impl Fn(crate::error::ServerError) -> (StatusCode, Json<Value>) {
215    move |error| match error {
216        crate::error::ServerError::BadRequest(message) => (
217            StatusCode::BAD_REQUEST,
218            Json(json_error(client_error, message)),
219        ),
220        other => (
221            StatusCode::INTERNAL_SERVER_ERROR,
222            Json(json_error(server_error, other.to_string())),
223        ),
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::{normalize_graph_depth, normalize_graph_lang, resolve_local_routa_binary};
230    use tempfile::tempdir;
231
232    #[test]
233    fn accepts_supported_graph_langs() {
234        assert_eq!(normalize_graph_lang(Some("rust")).expect("lang"), "rust");
235        assert_eq!(normalize_graph_lang(None).expect("default"), "auto");
236        assert!(normalize_graph_lang(Some("python")).is_err());
237    }
238
239    #[test]
240    fn accepts_supported_graph_depths() {
241        assert_eq!(
242            normalize_graph_depth(Some("normal")).expect("depth"),
243            "normal"
244        );
245        assert_eq!(normalize_graph_depth(None).expect("default"), "fast");
246        assert!(normalize_graph_depth(Some("deep")).is_err());
247    }
248
249    #[test]
250    fn resolves_local_binary_when_present() {
251        let temp = tempdir().expect("tempdir");
252        let target_dir = temp.path().join("target/debug");
253        std::fs::create_dir_all(&target_dir).expect("target dir");
254        let binary = target_dir.join("routa");
255        std::fs::write(&binary, "stub").expect("write binary");
256
257        let resolved = resolve_local_routa_binary(temp.path()).expect("binary");
258        assert_eq!(resolved, binary);
259    }
260}