caretta 0.11.6

caretta agent
#[cfg(not(target_arch = "wasm32"))]
use crate::agent::workflow::{WorkflowEntry, list_presets, load_sidebar_entries};
#[cfg(not(target_arch = "wasm32"))]
use axum::{
    Json, Router,
    body::Body,
    extract::Path,
    extract::State,
    http::{StatusCode, header},
    response::IntoResponse,
    routing::get,
};
#[cfg(not(target_arch = "wasm32"))]
use rust_embed::RustEmbed;
#[cfg(not(target_arch = "wasm32"))]
use serde::Serialize;
#[cfg(not(target_arch = "wasm32"))]
use std::net::SocketAddr;
#[cfg(not(target_arch = "wasm32"))]
use tracing::info;

#[cfg(not(target_arch = "wasm32"))]
#[derive(RustEmbed)]
#[folder = "$WEB_ASSETS_DIR"]
struct WebAssets;

#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone)]
struct AppState {
    root: String,
}

#[cfg(not(target_arch = "wasm32"))]
#[derive(Serialize)]
struct PresetsResponse {
    presets: Vec<String>,
}

#[cfg(not(target_arch = "wasm32"))]
#[derive(Serialize)]
struct WorkflowsResponse {
    workflows: Vec<WorkflowEntry>,
}

#[cfg(not(target_arch = "wasm32"))]
#[derive(Serialize)]
struct HealthResponse {
    status: &'static str,
}

#[cfg(not(target_arch = "wasm32"))]
pub async fn serve(root: String, port: u16) -> anyhow::Result<()> {
    let state = AppState { root };
    let app = Router::new()
        .route("/api/health", get(api_health))
        .route("/api/workflows/presets", get(api_list_presets))
        .route("/api/workflows/:preset", get(api_list_workflows))
        .route("/", get(index_handler))
        .fallback(static_handler)
        .with_state(state);

    let addr = SocketAddr::from(([127, 0, 0, 1], port));
    let listener = tokio::net::TcpListener::bind(addr)
        .await
        .map_err(|e| anyhow::anyhow!("failed to bind to {}: {}", addr, e))?;
    let local_addr = listener
        .local_addr()
        .map_err(|e| anyhow::anyhow!("failed to read local addr: {}", e))?;
    info!("Serving web UI on http://{}", local_addr);
    info!("API routes available:");
    info!("  GET /api/health");
    info!("  GET /api/workflows/presets");
    info!("  GET /api/workflows/<preset>");
    axum::serve(listener, app)
        .await
        .map_err(|e| anyhow::anyhow!("axum server error: {}", e))?;
    Ok(())
}

#[cfg(not(target_arch = "wasm32"))]
async fn api_list_presets(State(state): State<AppState>) -> impl IntoResponse {
    let presets = list_presets(&state.root);
    info!("GET /api/workflows/presets => {} presets", presets.len());
    Json(PresetsResponse { presets })
}

#[cfg(not(target_arch = "wasm32"))]
async fn api_health() -> impl IntoResponse {
    info!("GET /api/health => ok");
    Json(HealthResponse { status: "ok" })
}

#[cfg(not(target_arch = "wasm32"))]
async fn api_list_workflows(
    State(state): State<AppState>,
    Path(preset): Path<String>,
) -> impl IntoResponse {
    let workflows = load_sidebar_entries(&state.root, &preset);
    info!(
        "GET /api/workflows/{} => {} workflows",
        preset,
        workflows.len()
    );
    Json(WorkflowsResponse { workflows })
}

#[cfg(not(target_arch = "wasm32"))]
async fn index_handler() -> impl IntoResponse {
    serve_static_file("index.html").into_response()
}

#[cfg(not(target_arch = "wasm32"))]
async fn static_handler(req: axum::http::Request<Body>) -> impl IntoResponse {
    let path = req.uri().path().trim_start_matches('/');
    let path_to_use = if path.is_empty() { "index.html" } else { path };
    let response = serve_static_file(path_to_use);

    if path_to_use == "index.html" {
        info!(
            "GET /{} -> index fallback",
            req.uri().path().trim_start_matches('/')
        );
    }

    response.into_response()
}

#[cfg(not(target_arch = "wasm32"))]
fn serve_static_file(file_path: &str) -> impl IntoResponse {
    match WebAssets::get(file_path) {
        Some(content) => {
            let mime = mime_guess::from_path(file_path).first_or_octet_stream();
            ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
        }
        None => {
            if file_path != "index.html"
                && let Some(content) = WebAssets::get("index.html")
            {
                let mime = mime_guess::from_path("index.html").first_or_octet_stream();
                ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
            } else {
                (StatusCode::NOT_FOUND, "404 Not Found").into_response()
            }
        }
    }
}

#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
    use super::*;

    #[test]
    fn workflows_api_shape_matches_sidebar_entries() {
        let root = env!("CARGO_MANIFEST_DIR");
        let payload = serde_json::to_value(WorkflowsResponse {
            workflows: load_sidebar_entries(root, "default"),
        })
        .expect("serialize workflows response");

        let workflows = payload
            .get("workflows")
            .and_then(|v| v.as_array())
            .expect("workflows array");

        assert!(
            workflows
                .iter()
                .any(|wf| wf.get("id").and_then(|v| v.as_str()) == Some("ideation")),
            "default preset should expose ideation in web API payload"
        );
        assert!(
            workflows
                .iter()
                .all(|wf| wf.get("category").and_then(|v| v.as_str()).is_some()),
            "web API payload should expose normalized top-level category fields"
        );
    }
}