routa-server 0.16.0

Routa.js HTTP Server — axum adapter on top of routa-core
Documentation
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;

use axum::{
    extract::{Query, State},
    http::StatusCode,
    routing::get,
    Json, Router,
};
use serde::Deserialize;
use serde_json::Value;
use tokio::process::Command;
use tokio::time::timeout;

use crate::api::repo_context::{json_error, resolve_repo_root, ResolveRepoRootOptions};
use crate::state::AppState;

const GRAPH_ANALYZE_TIMEOUT_MS: u64 = 30_000;
const GRAPH_LANG_VALUES: &[&str] = &["auto", "rust", "typescript", "java"];
const GRAPH_DEPTH_VALUES: &[&str] = &["fast", "normal"];

pub fn router() -> Router<AppState> {
    Router::new().route("/analyze", get(analyze_graph))
}

#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GraphAnalyzeQuery {
    workspace_id: Option<String>,
    codebase_id: Option<String>,
    repo_path: Option<String>,
    repo_root: Option<String>,
    lang: Option<String>,
    depth: Option<String>,
}

async fn analyze_graph(
    State(state): State<AppState>,
    Query(query): Query<GraphAnalyzeQuery>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let repo_path = query.repo_path.as_deref().or(query.repo_root.as_deref());
    let repo_root = resolve_repo_root(
        &state,
        query.workspace_id.as_deref(),
        query.codebase_id.as_deref(),
        repo_path,
        "缺少 graph 分析上下文,请提供 workspaceId / codebaseId / repoPath / repoRoot 之一",
        ResolveRepoRootOptions {
            prefer_current_repo_for_default_workspace: true,
        },
    )
    .await
    .map_err(map_context_error(
        "Graph 分析上下文无效",
        "Graph 分析调用失败",
    ))?;

    let lang = normalize_graph_lang(query.lang.as_deref()).map_err(|details| {
        (
            StatusCode::BAD_REQUEST,
            Json(json_error("Invalid graph language", details)),
        )
    })?;
    let depth = normalize_graph_depth(query.depth.as_deref()).map_err(|details| {
        (
            StatusCode::BAD_REQUEST,
            Json(json_error("Invalid graph depth", details)),
        )
    })?;

    let payload = run_graph_command(&repo_root, &lang, &depth)
        .await
        .map_err(|details| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json_error("Failed to analyze dependency graph", details)),
            )
        })?;

    Ok(Json(payload))
}

fn normalize_graph_lang(value: Option<&str>) -> Result<String, String> {
    let lang = value.unwrap_or("auto").trim().to_ascii_lowercase();
    if GRAPH_LANG_VALUES.contains(&lang.as_str()) {
        Ok(lang)
    } else {
        Err(format!(
            "expected one of [{}], got {lang}",
            GRAPH_LANG_VALUES.join(", ")
        ))
    }
}

fn normalize_graph_depth(value: Option<&str>) -> Result<String, String> {
    let depth = value.unwrap_or("fast").trim().to_ascii_lowercase();
    if GRAPH_DEPTH_VALUES.contains(&depth.as_str()) {
        Ok(depth)
    } else {
        Err(format!(
            "expected one of [{}], got {depth}",
            GRAPH_DEPTH_VALUES.join(", ")
        ))
    }
}

async fn run_graph_command(repo_root: &Path, lang: &str, depth: &str) -> Result<Value, String> {
    let app_root = std::env::current_dir()
        .map_err(|error| format!("failed to determine app root for graph analysis: {error}"))?;
    let mut command = build_graph_command(&app_root, repo_root, lang, depth);
    command
        .current_dir(&app_root)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

    let output = timeout(
        Duration::from_millis(GRAPH_ANALYZE_TIMEOUT_MS),
        command.output(),
    )
    .await
    .map_err(|_| format!("graph analysis command timed out after {GRAPH_ANALYZE_TIMEOUT_MS}ms"))?
    .map_err(|error| format!("graph analysis command failed to execute: {error}"))?;

    if !output.status.success() {
        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        let details = if stderr.trim().is_empty() {
            stdout.trim().to_string()
        } else {
            stderr.trim().to_string()
        };
        return Err(format!(
            "graph analysis command failed (exit {}): {}",
            output.status.code().unwrap_or(1),
            details
        ));
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let json_text = extract_json_output(&stdout)?;
    serde_json::from_str(&json_text)
        .map_err(|error| format!("failed to parse graph analysis output: {error}"))
}

fn build_graph_command(app_root: &Path, repo_root: &Path, lang: &str, depth: &str) -> Command {
    let graph_args = vec![
        "graph".to_string(),
        "analyze".to_string(),
        "-d".to_string(),
        repo_root.display().to_string(),
        "-l".to_string(),
        lang.to_string(),
        "--depth".to_string(),
        depth.to_string(),
        "-f".to_string(),
        "json".to_string(),
    ];

    if let Some(binary) = resolve_local_routa_binary(app_root) {
        let mut command = Command::new(binary);
        command.args(&graph_args);
        command
    } else {
        let mut cargo_args = vec![
            "run".to_string(),
            "-p".to_string(),
            "routa-cli".to_string(),
            "--".to_string(),
        ];
        cargo_args.extend(graph_args);
        let mut command = Command::new("cargo");
        command.args(cargo_args);
        command
    }
}

fn resolve_local_routa_binary(app_root: &Path) -> Option<PathBuf> {
    let candidates = [
        app_root.join("target/release/routa"),
        app_root.join("target/debug/routa"),
    ];
    candidates.into_iter().find(|candidate| candidate.is_file())
}

fn extract_json_output(raw: &str) -> Result<String, String> {
    let candidate = raw.trim();
    if candidate.is_empty() {
        return Err("Command produced no output".to_string());
    }
    if serde_json::from_str::<Value>(candidate).is_ok() {
        return Ok(candidate.to_string());
    }
    for (index, ch) in candidate.char_indices().rev() {
        if ch != '{' {
            continue;
        }
        let snippet = candidate[index..].trim();
        if snippet.ends_with('}') && serde_json::from_str::<Value>(snippet).is_ok() {
            return Ok(snippet.to_string());
        }
    }
    Err("Unable to parse command JSON output".to_string())
}

fn map_context_error(
    client_error: &'static str,
    server_error: &'static str,
) -> impl Fn(crate::error::ServerError) -> (StatusCode, Json<Value>) {
    move |error| match error {
        crate::error::ServerError::BadRequest(message) => (
            StatusCode::BAD_REQUEST,
            Json(json_error(client_error, message)),
        ),
        other => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json_error(server_error, other.to_string())),
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::{normalize_graph_depth, normalize_graph_lang, resolve_local_routa_binary};
    use tempfile::tempdir;

    #[test]
    fn accepts_supported_graph_langs() {
        assert_eq!(normalize_graph_lang(Some("rust")).expect("lang"), "rust");
        assert_eq!(normalize_graph_lang(None).expect("default"), "auto");
        assert!(normalize_graph_lang(Some("python")).is_err());
    }

    #[test]
    fn accepts_supported_graph_depths() {
        assert_eq!(
            normalize_graph_depth(Some("normal")).expect("depth"),
            "normal"
        );
        assert_eq!(normalize_graph_depth(None).expect("default"), "fast");
        assert!(normalize_graph_depth(Some("deep")).is_err());
    }

    #[test]
    fn resolves_local_binary_when_present() {
        let temp = tempdir().expect("tempdir");
        let target_dir = temp.path().join("target/debug");
        std::fs::create_dir_all(&target_dir).expect("target dir");
        let binary = target_dir.join("routa");
        std::fs::write(&binary, "stub").expect("write binary");

        let resolved = resolve_local_routa_binary(temp.path()).expect("binary");
        assert_eq!(resolved, binary);
    }
}