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))
}
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 {
#[serde(rename = "ref")]
about: Option<String>,
}
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,
#[serde(default)]
about: Vec<String>,
}
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>,
}
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,
}
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>,
}
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 }))))
}
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;