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}