routa-server 0.14.0

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

use reqwest::{Client, StatusCode};
use serde_json::Value;
use tempfile::TempDir;

use routa_server::{start_server, ServerConfig};

struct ApiFixture {
    base_url: String,
    client: Client,
    db_path: PathBuf,
}

impl ApiFixture {
    async fn new() -> Self {
        let db_path = random_db_path();
        let config = ServerConfig {
            host: "127.0.0.1".to_string(),
            port: 0,
            db_path: db_path.to_string_lossy().to_string(),
            static_dir: None,
        };

        let addr = start_server(config)
            .await
            .expect("start server for api fixture");
        let base_url = format!("http://{addr}");
        let client = Client::new();
        let fixture = Self {
            base_url,
            client,
            db_path,
        };
        fixture.wait_until_ready().await;
        fixture
    }

    fn endpoint(&self, path: &str) -> String {
        format!("{}{}", self.base_url, path)
    }

    async fn wait_until_ready(&self) {
        for _ in 0..50 {
            if self
                .client
                .get(self.endpoint("/api/health"))
                .send()
                .await
                .is_ok_and(|resp| resp.status() == StatusCode::OK)
            {
                return;
            }
            tokio::time::sleep(Duration::from_millis(20)).await;
        }

        panic!("server did not become ready");
    }
}

impl Drop for ApiFixture {
    fn drop(&mut self) {
        let _ = fs::remove_file(&self.db_path);
    }
}

fn random_db_path() -> PathBuf {
    std::env::temp_dir().join(format!("routa-server-api-{}.db", uuid::Uuid::new_v4()))
}

struct GitRepoFixture {
    _temp: TempDir,
    repo_path: PathBuf,
    feature_sha: String,
}

impl GitRepoFixture {
    fn new() -> Self {
        let temp = tempfile::tempdir().expect("tempdir should exist");
        let repo_path = temp.path().join("repo");
        fs::create_dir_all(&repo_path).expect("repo dir should exist");

        let init = Command::new("git")
            .args(["init", "-b", "main"])
            .current_dir(&repo_path)
            .output()
            .expect("git init should run");
        if !init.status.success() {
            panic!(
                "git init failed: {}",
                String::from_utf8_lossy(&init.stderr).trim()
            );
        }

        run_git(&repo_path, &["config", "user.name", "Routa Test"]);
        run_git(
            &repo_path,
            &["config", "user.email", "routa-test@example.com"],
        );

        write_file(&repo_path, "README.md", "# Test Repo\n");
        run_git(&repo_path, &["add", "README.md"]);
        run_git(&repo_path, &["commit", "-m", "chore: initial commit"]);
        run_git(&repo_path, &["tag", "v0.1.0"]);

        run_git(&repo_path, &["checkout", "-b", "feature/log-panel"]);
        write_file(&repo_path, "feature.txt", "feature branch change\n");
        run_git(&repo_path, &["add", "feature.txt"]);
        run_git(&repo_path, &["commit", "-m", "feat: add git panel"]);
        let feature_sha = run_git(&repo_path, &["rev-parse", "HEAD"]);

        run_git(&repo_path, &["checkout", "main"]);
        write_file(&repo_path, "main.txt", "main branch only\n");
        run_git(&repo_path, &["add", "main.txt"]);
        run_git(&repo_path, &["commit", "-m", "chore: main line"]);

        Self {
            _temp: temp,
            repo_path,
            feature_sha,
        }
    }

    fn encoded_repo_path(&self) -> String {
        urlencoding::encode(self.repo_path.to_string_lossy().as_ref()).into_owned()
    }
}

fn run_git(repo_path: &Path, args: &[&str]) -> String {
    let output = Command::new("git")
        .args(args)
        .current_dir(repo_path)
        .output()
        .unwrap_or_else(|error| panic!("git {:?} failed to start: {}", args, error));

    if !output.status.success() {
        panic!(
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }

    String::from_utf8_lossy(&output.stdout).trim().to_string()
}

fn write_file(repo_path: &Path, relative_path: &str, content: &str) {
    let path = repo_path.join(relative_path);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).expect("parent directory should exist");
    }
    fs::write(path, content).expect("file should be written");
}

#[tokio::test]
async fn git_read_routes_require_repo_path_and_valid_sha() {
    let fixture = ApiFixture::new().await;

    let refs_response = fixture
        .client
        .get(fixture.endpoint("/api/git/refs"))
        .send()
        .await
        .expect("refs request should succeed");
    assert_eq!(refs_response.status(), StatusCode::BAD_REQUEST);
    let refs_json: Value = refs_response.json().await.expect("decode refs error");
    assert_eq!(refs_json["error"].as_str(), Some("repoPath is required"));

    let repo = GitRepoFixture::new();
    let commit_response = fixture
        .client
        .get(fixture.endpoint(&format!(
            "/api/git/commit?repoPath={}&sha=oops",
            repo.encoded_repo_path()
        )))
        .send()
        .await
        .expect("commit request should succeed");
    assert_eq!(commit_response.status(), StatusCode::BAD_REQUEST);
    let commit_json: Value = commit_response.json().await.expect("decode commit error");
    assert_eq!(commit_json["error"].as_str(), Some("sha is invalid"));
}

#[tokio::test]
async fn git_read_routes_expose_refs_log_filters_and_commit_detail() {
    let fixture = ApiFixture::new().await;
    let repo = GitRepoFixture::new();
    let repo_path = repo.encoded_repo_path();

    let refs_response = fixture
        .client
        .get(fixture.endpoint(&format!("/api/git/refs?repoPath={repo_path}")))
        .send()
        .await
        .expect("refs request should succeed");
    assert_eq!(refs_response.status(), StatusCode::OK);
    let refs_json: Value = refs_response.json().await.expect("decode refs");
    assert_eq!(refs_json["head"]["name"].as_str(), Some("main"));
    assert!(refs_json["local"]
        .as_array()
        .expect("local refs array")
        .iter()
        .any(|git_ref| git_ref["name"].as_str() == Some("feature/log-panel")));
    assert!(refs_json["tags"]
        .as_array()
        .expect("tags array")
        .iter()
        .any(|git_ref| git_ref["name"].as_str() == Some("v0.1.0")));

    let branch_log_response = fixture
        .client
        .get(fixture.endpoint(&format!(
            "/api/git/log?repoPath={repo_path}&branches={}",
            urlencoding::encode("feature/log-panel")
        )))
        .send()
        .await
        .expect("branch log request should succeed");
    assert_eq!(branch_log_response.status(), StatusCode::OK);
    let branch_log_json: Value = branch_log_response.json().await.expect("decode branch log");
    let branch_commits = branch_log_json["commits"]
        .as_array()
        .expect("branch log commits array");
    assert!(branch_commits
        .iter()
        .any(|commit| commit["summary"].as_str() == Some("feat: add git panel")));
    assert!(branch_commits
        .iter()
        .all(|commit| commit["summary"].as_str() != Some("chore: main line")));

    let search_response = fixture
        .client
        .get(fixture.endpoint(&format!(
            "/api/git/log?repoPath={repo_path}&search={}",
            urlencoding::encode("git panel")
        )))
        .send()
        .await
        .expect("search log request should succeed");
    assert_eq!(search_response.status(), StatusCode::OK);
    let search_json: Value = search_response.json().await.expect("decode search log");
    assert_eq!(search_json["total"].as_u64(), Some(1));
    assert_eq!(
        search_json["commits"][0]["summary"].as_str(),
        Some("feat: add git panel")
    );

    let commit_response = fixture
        .client
        .get(fixture.endpoint(&format!(
            "/api/git/commit?repoPath={repo_path}&sha={}",
            repo.feature_sha
        )))
        .send()
        .await
        .expect("commit detail request should succeed");
    assert_eq!(commit_response.status(), StatusCode::OK);
    let commit_json: Value = commit_response.json().await.expect("decode commit detail");
    assert_eq!(
        commit_json["commit"]["summary"].as_str(),
        Some("feat: add git panel")
    );
    assert!(commit_json["files"]
        .as_array()
        .expect("files array")
        .iter()
        .any(|file| {
            file["path"].as_str() == Some("feature.txt") && file["status"].as_str() == Some("added")
        }));
}