wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! What is noted about a file or folder of the worktree: the wsb notes linked
//! to it ([`wsb::annotations`]), and the worktree's own documents that mention
//! it ([`crate::web::notes`]).
//!
//! A note about a path is made through `/api/v1/annotations/notes`, about the
//! ref this answers with: a note is the project's, wherever it was written,
//! and the path ref names the file in every worktree of the project.

use std::collections::BTreeMap;
use std::path::Path;

use axum::{
    extract::{Query, State},
    http::StatusCode,
    Json,
};
use serde::Deserialize;
use wsb::annotations::{ProjectId, Ref};

use super::sessions::ErrorResponse;
use crate::web::notes;
use crate::web::state::WebState;

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

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

#[derive(Deserialize)]
pub struct PathQuery {
    /// Worktree-relative. Without it, the counts for every path.
    pub path: Option<String>,
}

/// GET /path-notes?path=<path> — the ref notes about the path are linked to,
/// the notes linked to it, and the documents that mention it.
///
/// GET /path-notes — per worktree-relative path, how many notes and
/// documents are about it, for the explorer's badges.
///
/// A worktree whose project has no GitHub origin has no ref for its paths:
/// its documents are still read, and why it has no notes is said.
pub async fn list_path_notes(
    State(state): State<WebState>,
    Query(query): Query<PathQuery>,
) -> Result<Json<serde_json::Value>, Refusal> {
    let root = state.worktree_root.clone();
    tokio::task::spawn_blocking(move || match query.path {
        Some(path) => about(&root, &path),
        None => counts(&root),
    })
    .await
    .map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))?
    .map(Json)
}

fn about(root: &Path, path: &str) -> Result<serde_json::Value, Refusal> {
    let mentioned_in =
        notes::list_path_notes(root, path).map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))?;
    let mut problems = Vec::new();
    let (about, linked) = match ProjectId::of_checkout(root) {
        Ok(project) => {
            let about: Ref = format!("path:{project}/{path}")
                .parse()
                .map_err(|e| refuse(StatusCode::BAD_REQUEST, e))?;
            let index =
                wsb::cli::annotations::index().map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))?;
            problems.extend(index.problems.iter().cloned());
            let linked: Vec<_> =
                wsb::cli::annotations::notes_about(&index, Some(&about)).into_iter().cloned().collect();
            (Some(about.to_string()), linked)
        }
        Err(no_name) => {
            problems.push(format!("{no_name:#}"));
            (None, Vec::new())
        }
    };
    Ok(serde_json::json!({
        "about": about,
        "notes": linked,
        "mentioned_in": mentioned_in,
        "problems": problems,
    }))
}

fn counts(root: &Path) -> Result<serde_json::Value, Refusal> {
    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
    for document in notes::list_all_notes(root).map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))? {
        for path in document.links {
            *counts.entry(path).or_default() += 1;
        }
    }
    let mut problems = Vec::new();
    match ProjectId::of_checkout(root) {
        Ok(project) => {
            let index =
                wsb::cli::annotations::index().map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))?;
            problems.extend(index.problems.iter().cloned());
            for held in &index.links {
                let ends = [(&held.link.from, &held.link.to), (&held.link.to, &held.link.from)];
                for (here, there) in ends {
                    if let (Ref::Path { project: of, path }, Ref::Note { .. }) = (here, there) {
                        if *of == project {
                            *counts.entry(path.clone()).or_default() += 1;
                        }
                    }
                }
            }
        }
        Err(no_name) => problems.push(format!("{no_name:#}")),
    }
    Ok(serde_json::json!({ "counts": counts, "problems": problems }))
}

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