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(|_| format!("graph analysis command timed out after {GRAPH_ANALYZE_TIMEOUT_MS}ms"))?
123    .map_err(|error| format!("graph analysis command failed to execute: {error}"))?;
124
125    if !output.status.success() {
126        let stdout = String::from_utf8_lossy(&output.stdout);
127        let stderr = String::from_utf8_lossy(&output.stderr);
128        let details = if stderr.trim().is_empty() {
129            stdout.trim().to_string()
130        } else {
131            stderr.trim().to_string()
132        };
133        return Err(format!(
134            "graph analysis command failed (exit {}): {}",
135            output.status.code().unwrap_or(1),
136            details
137        ));
138    }
139
140    let stdout = String::from_utf8_lossy(&output.stdout);
141    let json_text = extract_json_output(&stdout)?;
142    serde_json::from_str(&json_text)
143        .map_err(|error| format!("failed to parse graph analysis output: {error}"))
144}
145
146fn build_graph_command(app_root: &Path, repo_root: &Path, lang: &str, depth: &str) -> Command {
147    let graph_args = vec![
148        "graph".to_string(),
149        "analyze".to_string(),
150        "-d".to_string(),
151        repo_root.display().to_string(),
152        "-l".to_string(),
153        lang.to_string(),
154        "--depth".to_string(),
155        depth.to_string(),
156        "-f".to_string(),
157        "json".to_string(),
158    ];
159
160    if let Some(binary) = resolve_local_routa_binary(app_root) {
161        let mut command = Command::new(binary);
162        command.args(&graph_args);
163        command
164    } else {
165        let mut cargo_args = vec![
166            "run".to_string(),
167            "-p".to_string(),
168            "routa-cli".to_string(),
169            "--".to_string(),
170        ];
171        cargo_args.extend(graph_args);
172        let mut command = Command::new("cargo");
173        command.args(cargo_args);
174        command
175    }
176}
177
178fn resolve_local_routa_binary(app_root: &Path) -> Option<PathBuf> {
179    let candidates = [
180        app_root.join("target/release/routa"),
181        app_root.join("target/debug/routa"),
182    ];
183    candidates.into_iter().find(|candidate| candidate.is_file())
184}
185
186fn extract_json_output(raw: &str) -> Result<String, String> {
187    let candidate = raw.trim();
188    if candidate.is_empty() {
189        return Err("Command produced no output".to_string());
190    }
191    if serde_json::from_str::<Value>(candidate).is_ok() {
192        return Ok(candidate.to_string());
193    }
194    for (index, ch) in candidate.char_indices().rev() {
195        if ch != '{' {
196            continue;
197        }
198        let snippet = candidate[index..].trim();
199        if snippet.ends_with('}') && serde_json::from_str::<Value>(snippet).is_ok() {
200            return Ok(snippet.to_string());
201        }
202    }
203    Err("Unable to parse command JSON output".to_string())
204}
205
206fn map_context_error(
207    client_error: &'static str,
208    server_error: &'static str,
209) -> impl Fn(crate::error::ServerError) -> (StatusCode, Json<Value>) {
210    move |error| match error {
211        crate::error::ServerError::BadRequest(message) => (
212            StatusCode::BAD_REQUEST,
213            Json(json_error(client_error, message)),
214        ),
215        other => (
216            StatusCode::INTERNAL_SERVER_ERROR,
217            Json(json_error(server_error, other.to_string())),
218        ),
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::{normalize_graph_depth, normalize_graph_lang, resolve_local_routa_binary};
225    use tempfile::tempdir;
226
227    #[test]
228    fn accepts_supported_graph_langs() {
229        assert_eq!(normalize_graph_lang(Some("rust")).expect("lang"), "rust");
230        assert_eq!(normalize_graph_lang(None).expect("default"), "auto");
231        assert!(normalize_graph_lang(Some("python")).is_err());
232    }
233
234    #[test]
235    fn accepts_supported_graph_depths() {
236        assert_eq!(
237            normalize_graph_depth(Some("normal")).expect("depth"),
238            "normal"
239        );
240        assert_eq!(normalize_graph_depth(None).expect("default"), "fast");
241        assert!(normalize_graph_depth(Some("deep")).is_err());
242    }
243
244    #[test]
245    fn resolves_local_binary_when_present() {
246        let temp = tempdir().expect("tempdir");
247        let target_dir = temp.path().join("target/debug");
248        std::fs::create_dir_all(&target_dir).expect("target dir");
249        let binary = target_dir.join("routa");
250        std::fs::write(&binary, "stub").expect("write binary");
251
252        let resolved = resolve_local_routa_binary(temp.path()).expect("binary");
253        assert_eq!(resolved, binary);
254    }
255}