use axum::extract::{Path as AxumPath, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use mockforge_core::consistency::ConsistencyEngine;
use mockforge_scenarios::ScenarioStorage;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone)]
pub struct ScenarioRuntimeState {
inner: Arc<Inner>,
}
struct Inner {
storage: RwLock<ScenarioStorage>,
engine: Arc<ConsistencyEngine>,
}
impl ScenarioRuntimeState {
pub fn new(storage: ScenarioStorage, engine: Arc<ConsistencyEngine>) -> Self {
Self {
inner: Arc::new(Inner {
storage: RwLock::new(storage),
engine,
}),
}
}
}
#[derive(Debug, Serialize)]
struct ScenarioSummary {
name: String,
version: String,
source: String,
installed_at: u64,
description: String,
}
#[derive(Debug, Serialize)]
struct ListResponse {
scenarios: Vec<ScenarioSummary>,
}
async fn list_handler(State(state): State<ScenarioRuntimeState>) -> Json<ListResponse> {
let storage = state.inner.storage.read().await;
let scenarios = storage
.list()
.into_iter()
.map(|s| ScenarioSummary {
name: s.name.clone(),
version: s.version.clone(),
source: s.source.clone(),
installed_at: s.installed_at,
description: s.manifest.description.clone(),
})
.collect();
Json(ListResponse { scenarios })
}
#[derive(Debug, Deserialize)]
struct WorkspaceQuery {
#[serde(default)]
workspace: Option<String>,
}
fn workspace_or_default(q: &WorkspaceQuery) -> String {
q.workspace.clone().unwrap_or_else(|| "default".to_string())
}
async fn activate_handler(
State(state): State<ScenarioRuntimeState>,
AxumPath(name): AxumPath<String>,
Query(q): Query<WorkspaceQuery>,
) -> Response {
let exists = {
let storage = state.inner.storage.read().await;
storage.get_latest(&name).is_some()
};
if !exists {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "scenario_not_found",
"message": format!("No installed scenario named '{}'", name),
})),
)
.into_response();
}
let workspace = workspace_or_default(&q);
if let Err(e) = state.inner.engine.set_active_scenario(&workspace, name.clone()).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "activate_failed",
"message": e.to_string(),
})),
)
.into_response();
}
Json(serde_json::json!({
"active": name,
"workspace": workspace,
}))
.into_response()
}
async fn deactivate_handler(
State(state): State<ScenarioRuntimeState>,
Query(q): Query<WorkspaceQuery>,
) -> Response {
let workspace = workspace_or_default(&q);
if let Err(e) = state.inner.engine.set_active_scenario(&workspace, String::new()).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "deactivate_failed",
"message": e.to_string(),
})),
)
.into_response();
}
StatusCode::NO_CONTENT.into_response()
}
async fn active_handler(
State(state): State<ScenarioRuntimeState>,
Query(q): Query<WorkspaceQuery>,
) -> Response {
let workspace = workspace_or_default(&q);
let unified = state.inner.engine.get_state(&workspace).await;
match unified.and_then(|s| s.active_scenario) {
Some(name) if !name.is_empty() => {
Json(serde_json::json!({ "active": name, "workspace": workspace })).into_response()
}
_ => StatusCode::NO_CONTENT.into_response(),
}
}
pub fn scenarios_api_router(state: ScenarioRuntimeState) -> Router {
Router::new()
.route("/", get(list_handler))
.route("/active", get(active_handler))
.route("/deactivate", post(deactivate_handler))
.route("/{name}/activate", post(activate_handler))
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_default_when_unset() {
assert_eq!(workspace_or_default(&WorkspaceQuery { workspace: None }), "default");
}
#[test]
fn workspace_uses_explicit_value() {
assert_eq!(
workspace_or_default(&WorkspaceQuery {
workspace: Some("billing-team".to_string())
}),
"billing-team"
);
}
}