wsb-daemon 0.248.6265

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! The test catalogue over HTTP.
//!
//! The same reads the `wsb catalog` verbs perform, through the same repository,
//! so a page and a command cannot disagree about how many tests there are.
//! Scanning is a write and a long one, so it runs as a background op like every
//! other long operation rather than blocking a request until it finishes.

use wsb::prelude::*;
use axum::{
    extract::{Query, State},
    http::StatusCode,
    Json,
};

use super::sessions::ErrorResponse;
use crate::web::ops::OpKind;
use crate::web::state::WebState;

fn failed(error: impl std::fmt::Display) -> (StatusCode, Json<ErrorResponse>) {
    (
        StatusCode::INTERNAL_SERVER_ERROR,
        Json(ErrorResponse {
            error: error.to_string(),
        }),
    )
}

#[derive(Deserialize)]
pub struct ListQuery {
    pub offset: Option<i64>,
    pub limit: Option<i64>,
}

/// Every catalogued test, paged.
pub async fn list_tests(
    State(state): State<WebState>,
    Query(query): Query<ListQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
    let db = state.db.clone();
    let (tests, total) = tokio::task::spawn_blocking(move || {
        let catalogue = db.catalogue_repo();
        let conn = <_ as Repository<
            wsb::persist::local::models::catalogued_test::CataloguedTest,
        >>::get_connection(&*catalogue)?;
        let tests = catalogue.list_impl(&conn, query.offset, query.limit)?;
        let total = catalogue.count_impl(&conn)?;
        anyhow::Ok((tests, total))
    })
    .await
    .unwrap_or_else(|e| Err(anyhow!("join error: {}", e)))
    .map_err(failed)?;

    Ok(Json(serde_json::json!({ "tests": tests, "total": total })))
}

/// Counts, duplicates and misnumbering — the whole survey in one response.
pub async fn survey(
    State(state): State<WebState>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
    let db = state.db.clone();
    let survey = tokio::task::spawn_blocking(move || {
        wsb::catalogue::report::Survey::read(&db.catalogue_repo())
    })
    .await
    .unwrap_or_else(|e| Err(anyhow!("join error: {}", e)))
    .map_err(failed)?;

    Ok(Json(serde_json::json!({
        "total": survey.total,
        "numbered": survey.numbered,
        "clean": survey.is_clean(),
        "per_repo": survey
            .per_repo
            .iter()
            .map(|(repo, all, numbered)| serde_json::json!({
                "repo": repo,
                "tests": all,
                "numbered": numbered,
            }))
            .collect::<Vec<_>>(),
        "duplicates": survey
            .duplicates
            .iter()
            .map(|(repo, number, members)| serde_json::json!({
                "repo": repo,
                "number": number,
                "members": members,
            }))
            .collect::<Vec<_>>(),
        "misnumbered": survey
            .misnumbered
            .iter()
            .map(|test| serde_json::json!({
                "test": test,
                "described_number": test.described_number(),
            }))
            .collect::<Vec<_>>(),
    })))
}

#[derive(Deserialize)]
pub struct ParityQuery {
    /// The mirror family to compare. Declared in `.wsb/test.json`; there is no
    /// default, because which family is meant is not something to guess.
    pub family: String,
}

/// The number × repository matrix for one declared family of mirrors.
pub async fn parity(
    State(state): State<WebState>,
    Query(query): Query<ParityQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
    let db = state.db.clone();
    let root = state.workspace_root.clone();

    let (members, gaps, complete) = tokio::task::spawn_blocking(move || {
        let members = wsb::catalogue::families::members(&root, &query.family)?;
        let parity = wsb::catalogue::report::Parity::read(&db.catalogue_repo(), &members)?;
        let gaps = parity.gaps();
        let complete = parity.rows.len() - gaps.len();
        anyhow::Ok((members, gaps, complete))
    })
    .await
    .unwrap_or_else(|e| Err(anyhow!("join error: {}", e)))
    .map_err(failed)?;

    Ok(Json(serde_json::json!({
        "members": members,
        "complete": complete,
        "gaps": gaps
            .into_iter()
            .map(|(number, missing)| serde_json::json!({
                "number": number,
                "missing_from": missing,
            }))
            .collect::<Vec<_>>(),
    })))
}

#[derive(Deserialize)]
pub struct FreeQuery {
    pub from: Option<i64>,
    pub count: Option<usize>,
}

/// The next numbers no test claims.
pub async fn free_numbers(
    State(state): State<WebState>,
    Query(query): Query<FreeQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
    let db = state.db.clone();
    let from = query.from.unwrap_or(1);
    let count = query.count.unwrap_or(5).min(100);
    let numbers = tokio::task::spawn_blocking(move || db.catalogue_repo().free_numbers(from, count))
        .await
        .unwrap_or_else(|e| Err(anyhow!("join error: {}", e)))
        .map_err(failed)?;
    Ok(Json(serde_json::json!({ "numbers": numbers })))
}

/// Rescan the workspace into the catalogue.
///
/// Registered as a background op and answered immediately with its id: a full
/// scan reads every repository in the superproject, and a request that waited
/// for it would time out long before it finished.
pub async fn scan(
    State(state): State<WebState>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
    let handle = state
        .ops
        .start_op(OpKind::CatalogueScan, "scanning the workspace for tests")
        .await;
    let id = handle.id().to_string();

    let db = state.db.clone();
    let root = state.workspace_root.clone();
    tokio::spawn(async move {
        let outcome = tokio::task::spawn_blocking(move || {
            wsb::catalogue::scan::scan_workspace(&root, &db.catalogue_repo())
        })
        .await;
        match outcome {
            Ok(Ok(_)) => handle.complete().await,
            Ok(Err(message)) => handle.fail(message).await,
            Err(join) => handle.fail(format!("the scan task did not finish: {join}")).await,
        }
    });

    Ok(Json(serde_json::json!({ "op_id": id })))
}