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}