wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! Notes and links, over HTTP: read from every worktree of the workshop,
//! written into the worktree the page names.
//!
//! A note or a link is a file in the `.wsb/` of a project's main repo
//! ([`wsb::annotations`]), committed with the next commit there — never by
//! wsb. The page names where to write: the worktree it is in, or, from the
//! home page, the main worktree of the project it picked.

use std::path::PathBuf;
use std::sync::Arc;

use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{get, post, put};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json;

use super::sessions::ErrorResponse;
use crate::machine::MachineState;
use wsb::annotations::{Index, Link, Ref};

type Refusal = (StatusCode, Json<ErrorResponse>);

fn refuse(status: StatusCode, error: impl std::fmt::Display) -> Refusal {
    (status, Json(ErrorResponse { error: format!("{error:#}") }))
}

pub fn routes() -> Router<Arc<MachineState>> {
    Router::new()
        .route("/api/v1/annotations", get(read))
        .route("/api/v1/annotations/notes", post(add_note))
        .route("/api/v1/annotations/notes/:id", put(edit_note).delete(remove_note))
        .route("/api/v1/annotations/links", post(add_link).delete(remove_link))
}

/// Where a write goes: a worktree by its id (`<project>:<worktree>`, or
/// `~<hex>` for a checkout outside the workshop that this daemon has open).
async fn root_of(machine: &MachineState, worktree: &str) -> Result<PathBuf, Refusal> {
    let id = wsb::daemon::WorktreeId::parse(worktree).map_err(|e| refuse(StatusCode::BAD_REQUEST, e))?;
    if let Some((project, name)) = worktree.split_once(':') {
        let workshop = wsb_prj::Workshop::from_env().map_err(|e| refuse(StatusCode::CONFLICT, e))?;
        let workspace = workshop.workspace(project).map_err(|e| refuse(StatusCode::NOT_FOUND, e))?;
        let root = workspace.dir().join(wsb_prj::worktree_folder(name));
        if !root.is_dir() {
            return Err(refuse(StatusCode::NOT_FOUND, format!("{project} has no worktree {name}")));
        }
        return Ok(root);
    }
    machine
        .get(&id)
        .await
        .map(|runtime| runtime.web.worktree_root.clone())
        .ok_or_else(|| refuse(StatusCode::NOT_FOUND, format!("no worktree {worktree} is open on this machine")))
}

fn parse_ref(text: &str) -> Result<Ref, Refusal> {
    text.parse::<Ref>().map_err(|e| refuse(StatusCode::BAD_REQUEST, e))
}

async fn blocking<T: Send + 'static>(
    work: impl FnOnce() -> anyhow::Result<T> + Send + 'static,
) -> Result<T, Refusal> {
    tokio::task::spawn_blocking(work)
        .await
        .map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))?
        .map_err(|e| refuse(StatusCode::UNPROCESSABLE_ENTITY, e))
}

#[derive(Debug, Deserialize)]
struct About {
    /// Only what is about this ref.
    #[serde(rename = "ref")]
    about: Option<String>,
}

/// GET /api/v1/annotations[?ref=<ref>] — every note and link in the workshop,
/// or the notes linked to a ref and the links at it; and what could not be
/// read, said rather than skipped.
async fn read(Query(query): Query<About>) -> Result<Json<serde_json::Value>, Refusal> {
    let about = query.about.as_deref().map(parse_ref).transpose()?;
    let index: Index = blocking(wsb::cli::annotations::index).await?;
    Ok(Json(match about {
        None => json!({ "notes": index.notes, "links": index.links, "problems": index.problems }),
        Some(about) => json!({
            "notes": wsb::cli::annotations::notes_about(&index, Some(&about)),
            "links": wsb::cli::annotations::links_at(&index, Some(&about)),
            "problems": index.problems,
        }),
    }))
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct NewNote {
    worktree: String,
    title: String,
    #[serde(default)]
    body: String,
    /// What the note is about: a link from it to each.
    #[serde(default)]
    about: Vec<String>,
}

/// POST /api/v1/annotations/notes — write a note, linked to what it is about.
async fn add_note(
    State(machine): State<Arc<MachineState>>,
    Json(body): Json<NewNote>,
) -> Result<(StatusCode, Json<serde_json::Value>), Refusal> {
    let root = root_of(&machine, &body.worktree).await?;
    for about in &body.about {
        parse_ref(about)?;
    }
    let name = machine.identity.name();
    let (note, written) = blocking(move || {
        let targets = wsb::cli::annotations::Targets { refs: body.about, ..Default::default() };
        wsb::cli::annotations::note_add(&root, &body.title, &body.body, &targets, &name)
    })
    .await?;
    Ok((StatusCode::CREATED, Json(json!({ "note": note, "written": written }))))
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct NoteEdit {
    worktree: String,
    title: Option<String>,
    body: Option<String>,
}

/// PUT /api/v1/annotations/notes/:id — rewrite a note the worktree holds.
async fn edit_note(
    State(machine): State<Arc<MachineState>>,
    Path(id): Path<String>,
    Json(body): Json<NoteEdit>,
) -> Result<Json<serde_json::Value>, Refusal> {
    let root = root_of(&machine, &body.worktree).await?;
    let (note, path) = blocking(move || {
        wsb::cli::annotations::note_edit(&root, &id, body.title.as_deref(), body.body.as_deref())
    })
    .await?;
    Ok(Json(json!({ "note": note, "written": path })))
}

#[derive(Debug, Deserialize)]
struct InWorktree {
    worktree: String,
}

/// DELETE /api/v1/annotations/notes/:id?worktree=<id> — remove a note from
/// the worktree, and every link of it that names the note.
async fn remove_note(
    State(machine): State<Arc<MachineState>>,
    Path(id): Path<String>,
    Query(query): Query<InWorktree>,
) -> Result<Json<serde_json::Value>, Refusal> {
    let root = root_of(&machine, &query.worktree).await?;
    let removed = blocking(move || wsb::cli::annotations::note_rm(&root, &id)).await?;
    Ok(Json(json!({ "removed": removed })))
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct LinkBody {
    worktree: String,
    from: String,
    to: String,
    label: Option<String>,
}

/// POST /api/v1/annotations/links — link two things, in the worktree.
async fn add_link(
    State(machine): State<Arc<MachineState>>,
    Json(body): Json<LinkBody>,
) -> Result<(StatusCode, Json<serde_json::Value>), Refusal> {
    let root = root_of(&machine, &body.worktree).await?;
    let (from, to) = (parse_ref(&body.from)?, parse_ref(&body.to)?);
    let link = Link::new(from.clone(), to.clone(), body.label.clone()).map_err(|e| refuse(StatusCode::BAD_REQUEST, e))?;
    let written = blocking(move || wsb::cli::annotations::link_add(&root, &from, &to, body.label.as_deref())).await?;
    Ok((StatusCode::CREATED, Json(json!({ "link": link, "id": link.id(), "written": written }))))
}

/// DELETE /api/v1/annotations/links — remove a link from the worktree.
async fn remove_link(
    State(machine): State<Arc<MachineState>>,
    Json(body): Json<LinkBody>,
) -> Result<Json<serde_json::Value>, Refusal> {
    let root = root_of(&machine, &body.worktree).await?;
    let (from, to) = (parse_ref(&body.from)?, parse_ref(&body.to)?);
    let removed = blocking(move || wsb::cli::annotations::link_rm(&root, &from, &to, body.label.as_deref())).await?;
    Ok(Json(json!({ "removed": removed })))
}

#[cfg(test)]
#[path = "annotations_tests.rs"]
mod tests;