Skip to main content

kb/
api.rs

1//! HTTP API type definition and handlers.
2//!
3//! Ported from Haskell `KB/API.hs` and `KB/API/Handlers.hs`.
4//! Uses axum instead of Servant.
5//!
6//! ## Endpoints
7//!
8//! - `GET    /nodes/:id`            → 200 `NodeView` | 404
9//! - `PUT    /nodes/:id`            → `UpdateNodeRequest` → 200 `NodeView` | 404
10//! - `DELETE /nodes/:id`            → 204 | 404
11//! - `GET    /nodes/:id/neighbors`  → 200 `NeighborhoodResponse`
12//! - `POST   /nodes?id=…`           → `CreateNodeRequest` → 201 `NodeView` | 409
13//! - `GET    /nodes?limit=&offset=` → 200 [`NodeSummary`]
14//! - `GET    /search?q=…`           → 200 [`NodeSummary`]
15//! - `GET    /tags/:tag`            → 200 [`NodeSummary`]
16//! - `GET    /recent`               → 200 [`NodeSummary`]
17//! - `POST   /admin/relink`         → 200 `RelinkResponse`
18//!
19//! ## Content negotiation
20//!
21//! Read/write endpoints honour `text/org` (with `text/plain` as a synonym)
22//! on both `Content-Type` (request) and `Accept` (response). An org
23//! request body is parsed via [`crate::parser`]; an org response renders
24//! the document via [`crate::generator`]. An org response carries the
25//! node id and timestamps in a leading `:PROPERTIES:` drawer (see
26//! [`crate::org_meta`]); a matching drawer on an org request body is
27//! stripped before storage. On `PUT` the drawer's `:ID:`, when present,
28//! must equal the path id (a mismatch is `400`); on `POST` it is
29//! discarded — callers wanting to set an id must use `?id=`.
30
31#![allow(
32    clippy::significant_drop_tightening,
33    reason = "lock/connection/server guards are intentionally held for the operation's duration; early drop would break atomicity"
34)]
35
36use axum::{
37    Json, Router,
38    body::Bytes,
39    extract::{DefaultBodyLimit, Path, Query, State},
40    http::{HeaderMap, StatusCode, header},
41    response::{IntoResponse, NoContent, Response},
42    routing::{delete, get, post, put},
43};
44use serde::{Deserialize, Serialize};
45use std::sync::{Arc, Mutex};
46
47use crate::embedding::EmbeddingClient;
48use crate::org_meta;
49use crate::parser;
50use crate::storage;
51use tftio_org::ast::{Document, NodeId, Tag, Title};
52
53/// Shared application state passed to all handlers.
54pub struct AppState {
55    /// `SQLite` connection wrapped in a sync mutex.
56    pub conn: Mutex<rusqlite::Connection>,
57    /// Optional async embedding client. When `Some`, write-path handlers
58    /// compute an embedding for each new/updated node and `/search`
59    /// runs hybrid (FTS + vector) ranking. `None` short-circuits to
60    /// FTS-only with no embeddings rows written.
61    pub embedding_client: Option<Arc<dyn EmbeddingClient>>,
62    /// Model name stamped into the `embeddings.model` column when a
63    /// client is configured. `None` is equivalent to no client (no
64    /// embedding row is written).
65    pub embedding_model: Option<String>,
66}
67
68/// Full node response: identity, derived metadata, the AST, and timestamps.
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70pub struct NodeView {
71    /// Stable node identifier.
72    pub id: NodeId,
73    /// Derived node title (first heading).
74    pub title: Title,
75    /// Tags collected from the document.
76    pub tags: Vec<Tag>,
77    /// The full node document AST.
78    pub document: Document,
79    /// RFC 3339 creation timestamp.
80    #[serde(rename = "createdAt")]
81    pub created_at: String,
82    /// RFC 3339 last-update timestamp.
83    #[serde(rename = "updatedAt")]
84    pub updated_at: String,
85}
86
87/// Compact node listing for search/tags/recent results.
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89pub struct NodeSummary {
90    /// Stable node identifier.
91    pub id: NodeId,
92    /// Derived node title (first heading).
93    pub title: Title,
94}
95
96/// `POST /nodes` body.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct CreateNodeRequest {
99    /// Optional caller-supplied identifier; a UUID is minted when absent.
100    pub id: Option<String>,
101    /// The node document AST to store.
102    pub document: Document,
103}
104
105/// `PUT /nodes/:id` body.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct UpdateNodeRequest {
108    /// The replacement node document AST.
109    pub document: Document,
110}
111
112/// One edge in a node's link neighborhood.
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114pub struct NeighborEdge {
115    /// Identifier of the node on the other end of the edge.
116    pub target: String,
117    /// Whether the edge was resolved by id or by name.
118    #[serde(rename = "linkType")]
119    pub link_type: storage::LinkType,
120}
121
122/// The link neighborhood of a node: outgoing and incoming edges.
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124pub struct NeighborhoodResponse {
125    /// Edges pointing from this node to others.
126    pub outgoing: Vec<NeighborEdge>,
127    /// Edges pointing from other nodes to this one.
128    pub incoming: Vec<NeighborEdge>,
129}
130
131/// The shape returned by `POST /admin/relink`.
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133pub struct RelinkResponse {
134    /// Number of nodes rescanned.
135    pub nodes: u64,
136    /// Number of link edges rebuilt.
137    pub links: u64,
138}
139
140/// `GET /search` query parameters.
141#[derive(Debug, Deserialize)]
142pub struct SearchQuery {
143    /// Free-text query string; empty or absent returns recent nodes.
144    pub q: Option<String>,
145}
146
147/// `GET /nodes` query parameters.
148#[derive(Debug, Deserialize, Default)]
149pub struct ListNodesQuery {
150    /// Maximum number of nodes to return.
151    pub limit: Option<i64>,
152    /// Number of leading nodes to skip.
153    pub offset: Option<i64>,
154}
155
156/// `POST /nodes` query parameters.
157#[derive(Debug, Deserialize, Default)]
158pub struct CreateNodeQuery {
159    /// Optional caller-supplied identifier passed as a query parameter.
160    pub id: Option<String>,
161}
162
163/// Maximum request body size for write-path handlers.
164///
165/// Raised from the axum default of 2 MiB so real-world transcripts
166/// (long Claude Code sessions, large Slack/Discord exports) can be
167/// ingested without hitting `413 Payload Too Large`. The 32 MiB
168/// ceiling is fixed in this phase; bodies larger than this still
169/// return 413.
170pub const REQUEST_BODY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
171
172/// Build the full axum Router for the KB API.
173pub fn build_router(state: Arc<AppState>) -> Router {
174    Router::new()
175        .route("/nodes/{id}", get(get_node_handler))
176        .route("/nodes/{id}", put(put_node_handler))
177        .route("/nodes/{id}", delete(delete_node_handler))
178        .route("/nodes/{id}/neighbors", get(neighbors_handler))
179        .route("/nodes", get(list_nodes_handler))
180        .route("/nodes", post(post_node_handler))
181        .route("/search", get(search_handler))
182        .route("/tags/{tag}", get(tags_handler))
183        .route("/recent", get(recent_handler))
184        .route("/admin/relink", post(relink_handler))
185        .layer(DefaultBodyLimit::max(REQUEST_BODY_LIMIT_BYTES))
186        .with_state(state)
187}
188
189// ── Handlers ────────────────────────────────────────────────────────────
190
191async fn get_node_handler(
192    State(state): State<Arc<AppState>>,
193    Path(id): Path<String>,
194    headers: HeaderMap,
195) -> Result<Response, AppError> {
196    let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
197    let nf = storage::get_node_full(&conn, &id)
198        .map_err(|_| AppError::Internal)?
199        .ok_or(AppError::NotFound)?;
200    Ok(render_node_view(&headers, to_node_view(&id, &nf)))
201}
202
203async fn put_node_handler(
204    State(state): State<Arc<AppState>>,
205    Path(id): Path<String>,
206    headers: HeaderMap,
207    body: Bytes,
208) -> Result<Response, AppError> {
209    let req = parse_update_body(&headers, &body, &id)?;
210    let embedding = compute_doc_embedding(&state, &req.document, &id).await;
211    let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
212    let ok = storage::update_node_with(
213        &conn,
214        &id,
215        &req.document,
216        embedding,
217        state.embedding_model.as_deref(),
218    )
219    .map_err(|_| AppError::Internal)?;
220    if !ok {
221        return Err(AppError::NotFound);
222    }
223    let nf = storage::get_node_full(&conn, &id)
224        .map_err(|_| AppError::Internal)?
225        .ok_or(AppError::Internal)?;
226    Ok(render_node_view(&headers, to_node_view(&id, &nf)))
227}
228
229async fn delete_node_handler(
230    State(state): State<Arc<AppState>>,
231    Path(id): Path<String>,
232) -> Result<NoContent, AppError> {
233    let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
234    let ok = storage::delete_node(&conn, &id).map_err(|_| AppError::Internal)?;
235    if ok {
236        Ok(NoContent)
237    } else {
238        Err(AppError::NotFound)
239    }
240}
241
242async fn post_node_handler(
243    State(state): State<Arc<AppState>>,
244    Query(query): Query<CreateNodeQuery>,
245    headers: HeaderMap,
246    body: Bytes,
247) -> Result<Response, AppError> {
248    let req = parse_create_body(&headers, &body)?;
249    // Precedence: query ?id= wins over body.id wins over server-minted UUID.
250    // Resolve id and verify availability with a short-lived lock — drop
251    // the connection guard before .await so we never hold the mutex
252    // across the embedding HTTP call.
253    let nid = {
254        let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
255        match query.id.or_else(|| req.id.clone()) {
256            Some(supplied) => {
257                if storage::get_node(&conn, &supplied)
258                    .map_err(|_| AppError::Internal)?
259                    .is_some()
260                {
261                    return Err(AppError::Conflict);
262                }
263                supplied
264            }
265            None => uuid::Uuid::new_v4().to_string(),
266        }
267    };
268    let embedding = compute_doc_embedding(&state, &req.document, &nid).await;
269    let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
270    storage::insert_node_with(
271        &conn,
272        &nid,
273        &req.document,
274        embedding,
275        state.embedding_model.as_deref(),
276    )
277    .map_err(|_| AppError::Internal)?;
278    let nf = storage::get_node_full(&conn, &nid)
279        .map_err(|_| AppError::Internal)?
280        .ok_or(AppError::Internal)?;
281    Ok(render_created_node_view(&headers, to_node_view(&nid, &nf)))
282}
283
284async fn neighbors_handler(
285    State(state): State<Arc<AppState>>,
286    Path(id): Path<String>,
287) -> Result<Json<NeighborhoodResponse>, AppError> {
288    let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
289    let n = storage::get_neighborhood(&conn, &id).map_err(|_| AppError::Internal)?;
290    let outgoing = n
291        .outgoing
292        .into_iter()
293        .map(|(t, lt)| NeighborEdge {
294            target: t.0,
295            link_type: lt,
296        })
297        .collect();
298    let incoming = n
299        .incoming
300        .into_iter()
301        .map(|(t, lt)| NeighborEdge {
302            target: t.0,
303            link_type: lt,
304        })
305        .collect();
306    Ok(Json(NeighborhoodResponse { outgoing, incoming }))
307}
308
309async fn list_nodes_handler(
310    State(state): State<Arc<AppState>>,
311    Query(query): Query<ListNodesQuery>,
312) -> Result<Json<Vec<NodeSummary>>, AppError> {
313    // Match `KB/API/Handlers.hs::listNodesHandler`:
314    //   limit = clamp 1 1000 (fromMaybe 100 mLimit)
315    //   offset = max 0 (fromMaybe 0 mOffset)
316    let limit = query.limit.unwrap_or(100).clamp(1, 1000);
317    let offset = query.offset.unwrap_or(0).max(0);
318    let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
319    let rows = storage::list_all_nodes(
320        &conn,
321        usize::try_from(limit).unwrap_or(100),
322        usize::try_from(offset).unwrap_or(0),
323    )
324    .map_err(|_| AppError::Internal)?;
325    Ok(Json(
326        rows.into_iter()
327            .map(|(id, title)| NodeSummary { id, title })
328            .collect(),
329    ))
330}
331
332async fn search_handler(
333    State(state): State<Arc<AppState>>,
334    Query(query): Query<SearchQuery>,
335) -> Result<Json<Vec<NodeSummary>>, AppError> {
336    let q = query.q.unwrap_or_default();
337    if q.trim().is_empty() {
338        return Ok(Json(vec![]));
339    }
340    // Compute the query embedding asynchronously *before* taking the
341    // SQLite mutex so we never hold the lock across an HTTP await.
342    let query_embedding: Option<(Vec<f32>, &str)> = match (
343        state.embedding_client.as_ref(),
344        state.embedding_model.as_deref(),
345    ) {
346        (Some(client), Some(model)) => match client.embed(&q).await {
347            Ok(v) => Some((v, model)),
348            Err(err) => {
349                tracing::error!(error = %err, "kb /search: embedding query failed");
350                None
351            }
352        },
353        _ => None,
354    };
355    let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
356    let ids: Vec<String> = storage::search_hybrid(&conn, &q, query_embedding)
357        .map_err(|_| AppError::Internal)?
358        .into_iter()
359        .map(|n| n.0)
360        .collect();
361    let titles = storage::fetch_titles(&conn, &ids).map_err(|_| AppError::Internal)?;
362    Ok(Json(
363        titles
364            .into_iter()
365            .map(|(id, title)| NodeSummary {
366                id: NodeId(id),
367                title: Title(title),
368            })
369            .collect(),
370    ))
371}
372
373async fn tags_handler(
374    State(state): State<Arc<AppState>>,
375    Path(tag): Path<String>,
376) -> Result<Json<Vec<NodeSummary>>, AppError> {
377    let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
378    let rows = storage::list_by_tag(&conn, &tag).map_err(|_| AppError::Internal)?;
379    let ids: Vec<String> = rows.iter().map(|r| r.id.0.clone()).collect();
380    let titles = storage::fetch_titles(&conn, &ids).map_err(|_| AppError::Internal)?;
381    Ok(Json(
382        titles
383            .into_iter()
384            .map(|(id, title)| NodeSummary {
385                id: NodeId(id),
386                title: Title(title),
387            })
388            .collect(),
389    ))
390}
391
392async fn recent_handler(
393    State(state): State<Arc<AppState>>,
394) -> Result<Json<Vec<NodeSummary>>, AppError> {
395    let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
396    let rows = storage::list_recent(&conn, 50).map_err(|_| AppError::Internal)?;
397    let ids: Vec<String> = rows.iter().map(|r| r.id.0.clone()).collect();
398    let titles = storage::fetch_titles(&conn, &ids).map_err(|_| AppError::Internal)?;
399    Ok(Json(
400        titles
401            .into_iter()
402            .map(|(id, title)| NodeSummary {
403                id: NodeId(id),
404                title: Title(title),
405            })
406            .collect(),
407    ))
408}
409
410async fn relink_handler(
411    State(state): State<Arc<AppState>>,
412) -> Result<Json<RelinkResponse>, AppError> {
413    let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
414    let (nodes, links) = storage::relink_all(&conn).map_err(|_| AppError::Internal)?;
415    Ok(Json(RelinkResponse {
416        nodes: nodes as u64,
417        links: links as u64,
418    }))
419}
420
421// ── Content-type negotiation ────────────────────────────────────────────
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
424enum WireFormat {
425    Json,
426    Org,
427}
428
429/// Request body formats. A superset of [`WireFormat`]: markdown is an
430/// ingest-only format (kb never renders markdown back out), so it has no
431/// place in response negotiation.
432#[derive(Debug, Clone, Copy, PartialEq, Eq)]
433enum RequestFormat {
434    Json,
435    Org,
436    Markdown,
437}
438
439/// Org content type emitted on responses. Matches Haskell `Accept OrgText`
440/// which lists `text/org` first.
441const ORG_CONTENT_TYPE: &str = "text/org";
442
443/// Pick a response format from the `Accept` header. Walks entries in
444/// order and returns the first match. Defaults to JSON when the header
445/// is absent or contains nothing recognised — matching Servant, which
446/// picks the first entry of `'[JSON, OrgText]` for `*/*`.
447fn negotiate_response(headers: &HeaderMap) -> WireFormat {
448    let Some(accept) = headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()) else {
449        return WireFormat::Json;
450    };
451    for entry in accept.split(',') {
452        let mime = entry.split(';').next().unwrap_or("").trim();
453        match mime {
454            "application/json" | "*/*" | "application/*" => return WireFormat::Json,
455            "text/org" | "text/plain" => return WireFormat::Org,
456            _ => {}
457        }
458    }
459    WireFormat::Json
460}
461
462/// Pick a request body format from `Content-Type`. Defaults to JSON.
463fn request_format(headers: &HeaderMap) -> RequestFormat {
464    let Some(ct) = headers
465        .get(header::CONTENT_TYPE)
466        .and_then(|v| v.to_str().ok())
467    else {
468        return RequestFormat::Json;
469    };
470    let mime = ct.split(';').next().unwrap_or("").trim();
471    match mime {
472        "text/org" | "text/plain" => RequestFormat::Org,
473        "text/markdown" => RequestFormat::Markdown,
474        _ => RequestFormat::Json,
475    }
476}
477
478/// Convert a markdown request body into a [`Document`].
479///
480/// A missing `pandoc` binary is a server-side misconfiguration (500);
481/// any other conversion failure is attributed to the request body (400).
482fn parse_markdown_body(body: &[u8]) -> Result<Document, AppError> {
483    let text = std::str::from_utf8(body)
484        .map_err(|e| AppError::BadRequest(format!("markdown body is not valid UTF-8: {e}")))?;
485    crate::markdown::markdown_to_document(text).map_err(|e| match e {
486        // A missing pandoc binary is a server-side misconfiguration (500);
487        // every input-shaped failure is attributed to the request body (400).
488        crate::markdown::MarkdownError::PandocNotFound => AppError::Internal,
489        other => AppError::BadRequest(format!("markdown conversion failure: {other}")),
490    })
491}
492
493fn parse_create_body(headers: &HeaderMap, body: &[u8]) -> Result<CreateNodeRequest, AppError> {
494    match request_format(headers) {
495        RequestFormat::Json => serde_json::from_slice::<CreateNodeRequest>(body)
496            .map_err(|e| AppError::BadRequest(format!("invalid JSON body: {e}"))),
497        RequestFormat::Org => {
498            let mut document = parse_org_body(body)?;
499            // Strip a round-tripped kb metadata drawer so it never reaches
500            // the stored body. text/org POSTs never carry a body id — the
501            // ?id= query param is the only client-supplied id channel, so
502            // the drawer's :ID: is discarded here.
503            let _ = org_meta::hydrate(&mut document);
504            Ok(CreateNodeRequest { id: None, document })
505        }
506        RequestFormat::Markdown => {
507            // Markdown bodies carry no kb metadata drawer; the ?id= query
508            // param remains the only client-supplied id channel.
509            let document = parse_markdown_body(body)?;
510            Ok(CreateNodeRequest { id: None, document })
511        }
512    }
513}
514
515fn parse_update_body(
516    headers: &HeaderMap,
517    body: &[u8],
518    node_id: &str,
519) -> Result<UpdateNodeRequest, AppError> {
520    match request_format(headers) {
521        RequestFormat::Json => serde_json::from_slice::<UpdateNodeRequest>(body)
522            .map_err(|e| AppError::BadRequest(format!("invalid JSON body: {e}"))),
523        RequestFormat::Org => {
524            let mut document = parse_org_body(body)?;
525            // Strip the round-tripped kb metadata drawer. When it carries
526            // an :ID:, it must name the node being updated — a mismatch
527            // signals an exported file applied to the wrong node.
528            if let Some(drawer_id) = org_meta::hydrate(&mut document)
529                && drawer_id != node_id
530            {
531                return Err(AppError::BadRequest(format!(
532                    "document :ID: {drawer_id} does not match target node id {node_id}"
533                )));
534            }
535            Ok(UpdateNodeRequest { document })
536        }
537        RequestFormat::Markdown => {
538            let document = parse_markdown_body(body)?;
539            Ok(UpdateNodeRequest { document })
540        }
541    }
542}
543
544fn parse_org_body(body: &[u8]) -> Result<Document, AppError> {
545    let text = std::str::from_utf8(body)
546        .map_err(|e| AppError::BadRequest(format!("org body is not valid UTF-8: {e}")))?;
547    parser::parse_document(text)
548        .map_err(|e| AppError::BadRequest(format!("org parse failure: {e}")))
549}
550
551fn render_node_view(headers: &HeaderMap, view: NodeView) -> Response {
552    match negotiate_response(headers) {
553        WireFormat::Json => Json(view).into_response(),
554        WireFormat::Org => render_org(&view),
555    }
556}
557
558fn render_created_node_view(headers: &HeaderMap, view: NodeView) -> Response {
559    match negotiate_response(headers) {
560        WireFormat::Json => (StatusCode::CREATED, Json(view)).into_response(),
561        WireFormat::Org => {
562            let body = org_meta::render_with_metadata(
563                view.id.as_str(),
564                &view.created_at,
565                &view.updated_at,
566                &view.document,
567            );
568            (
569                StatusCode::CREATED,
570                [(header::CONTENT_TYPE, ORG_CONTENT_TYPE)],
571                body,
572            )
573                .into_response()
574        }
575    }
576}
577
578fn render_org(view: &NodeView) -> Response {
579    let body = org_meta::render_with_metadata(
580        view.id.as_str(),
581        &view.created_at,
582        &view.updated_at,
583        &view.document,
584    );
585    ([(header::CONTENT_TYPE, ORG_CONTENT_TYPE)], body).into_response()
586}
587
588// ── Helpers ─────────────────────────────────────────────────────────────
589
590/// Compute the embedding payload for a write-path handler.
591///
592/// Mirrors the v5 `embed_node` behaviour exactly: payload is
593/// `title + "\n" + body_text`, and an embedding-generation failure is
594/// logged via `tracing` but does not roll back the storage write — the
595/// handler proceeds with `None`. With no embedding client configured at
596/// startup, returns `None` without invoking any embedding code.
597async fn compute_doc_embedding(
598    state: &Arc<AppState>,
599    document: &Document,
600    node_id: &str,
601) -> Option<Vec<f32>> {
602    let client = state.embedding_client.as_ref()?;
603    state.embedding_model.as_deref()?;
604    let title = storage::extract_title(document);
605    let body_text = storage::extract_body_text(document);
606    let payload = format!("{title}\n{body_text}");
607    match client.embed(&payload).await {
608        Ok(v) => Some(v),
609        Err(err) => {
610            tracing::error!(node_id, error = %err, "kb embed generation failed");
611            None
612        }
613    }
614}
615
616fn to_node_view(id: &str, nf: &storage::NodeFullData) -> NodeView {
617    NodeView {
618        id: NodeId(id.to_string()),
619        title: Title(nf.title.clone()),
620        tags: nf.tags.clone(),
621        document: nf.document.clone(),
622        created_at: nf.created_at.clone(),
623        updated_at: nf.updated_at.clone(),
624    }
625}
626
627/// Application-level error responses.
628enum AppError {
629    NotFound,
630    Conflict,
631    BadRequest(String),
632    Internal,
633}
634
635impl IntoResponse for AppError {
636    fn into_response(self) -> Response {
637        match self {
638            Self::NotFound => (StatusCode::NOT_FOUND, "not found\n").into_response(),
639            Self::Conflict => (StatusCode::CONFLICT, "id already exists\n").into_response(),
640            Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, format!("{msg}\n")).into_response(),
641            Self::Internal => {
642                (StatusCode::INTERNAL_SERVER_ERROR, "internal server error\n").into_response()
643            }
644        }
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use crate::generator;
652    use crate::storage;
653    use axum::body::Body;
654    use axum::http::Request;
655    use rusqlite::Connection;
656    use tftio_org::ast::{Block, Inline};
657    use tower::ServiceExt;
658
659    fn setup_state() -> Arc<AppState> {
660        let conn = Connection::open_in_memory().unwrap();
661        conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
662        storage::init_db(&conn).unwrap();
663        Arc::new(AppState {
664            conn: Mutex::new(conn),
665            embedding_client: None,
666            embedding_model: None,
667        })
668    }
669
670    fn sample_doc() -> Document {
671        Document {
672            blocks: vec![Block::Heading {
673                level: 1,
674                title: Title("Test".into()),
675                tags: vec![Tag("api".into())],
676                children: vec![Block::Paragraph {
677                    inlines: vec![Inline::Plain("hello".into())],
678                }],
679            }],
680        }
681    }
682
683    fn link_doc(targets: &[&str]) -> Document {
684        let inlines: Vec<Inline> = targets
685            .iter()
686            .map(|t| Inline::Link {
687                target: format!("id:{t}"),
688                description: None,
689            })
690            .collect();
691        Document {
692            blocks: vec![Block::Paragraph { inlines }],
693        }
694    }
695
696    async fn send(
697        app: Router,
698        method: &str,
699        uri: &str,
700        content_type: Option<&str>,
701        accept: Option<&str>,
702        body: Vec<u8>,
703    ) -> (StatusCode, HeaderMap, Vec<u8>) {
704        let mut req = Request::builder().method(method).uri(uri);
705        if let Some(ct) = content_type {
706            req = req.header(header::CONTENT_TYPE, ct);
707        }
708        if let Some(a) = accept {
709            req = req.header(header::ACCEPT, a);
710        }
711        let resp = app
712            .oneshot(req.body(Body::from(body)).unwrap())
713            .await
714            .unwrap();
715        let status = resp.status();
716        let headers = resp.headers().clone();
717        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
718            .await
719            .unwrap()
720            .to_vec();
721        (status, headers, bytes)
722    }
723
724    fn json_str(bytes: &[u8]) -> serde_json::Value {
725        serde_json::from_slice(bytes).expect("response was not valid JSON")
726    }
727
728    #[test]
729    fn node_view_roundtrip_json() {
730        let view = NodeView {
731            id: NodeId("abc".into()),
732            title: Title("Test".into()),
733            tags: vec![Tag("api".into())],
734            document: sample_doc(),
735            created_at: "2026-01-01T00:00:00Z".into(),
736            updated_at: "2026-01-01T00:00:00Z".into(),
737        };
738        let json = serde_json::to_string(&view).unwrap();
739        let parsed: NodeView = serde_json::from_str(&json).unwrap();
740        assert_eq!(view, parsed);
741    }
742
743    #[test]
744    fn insert_and_get_node_full() {
745        let state = setup_state();
746        let conn = state.conn.lock().unwrap();
747        let nid = "test-1";
748        let doc = sample_doc();
749        storage::insert_node(&conn, nid, &doc).unwrap();
750        let nf = storage::get_node_full(&conn, nid).unwrap().unwrap();
751        assert_eq!(nf.title, "Test");
752        assert_eq!(nf.tags.len(), 1);
753        assert_eq!(nf.tags[0].0, "api");
754        assert_eq!(nf.document, doc);
755    }
756
757    #[test]
758    fn get_node_full_nonexistent() {
759        let state = setup_state();
760        let conn = state.conn.lock().unwrap();
761        let nf = storage::get_node_full(&conn, "no-such").unwrap();
762        assert!(nf.is_none());
763    }
764
765    #[test]
766    fn fetch_titles_works() {
767        let state = setup_state();
768        let conn = state.conn.lock().unwrap();
769        let doc = sample_doc();
770        storage::insert_node(&conn, "a", &doc).unwrap();
771        storage::insert_node(&conn, "b", &doc).unwrap();
772        let titles = storage::fetch_titles(&conn, &["a".into(), "b".into()]).unwrap();
773        assert_eq!(titles.len(), 2);
774    }
775
776    #[test]
777    fn search_fts_finds_results() {
778        let state = setup_state();
779        let conn = state.conn.lock().unwrap();
780        let doc = Document {
781            blocks: vec![Block::Heading {
782                level: 1,
783                title: Title("Rust Programming".into()),
784                tags: vec![],
785                children: vec![Block::Paragraph {
786                    inlines: vec![Inline::Plain("systems programming language".into())],
787                }],
788            }],
789        };
790        storage::insert_node(&conn, "fts-1", &doc).unwrap();
791        let results = storage::search_fts(&conn, "programming").unwrap();
792        assert_eq!(results.len(), 1);
793        assert_eq!(results[0], "fts-1");
794    }
795
796    // ── router_shape ──────────────────────────────────────────────────
797
798    #[tokio::test]
799    async fn router_shape_listed_routes_respond() {
800        let state = setup_state();
801        // Seed a node so /nodes/:id paths can resolve to 200.
802        {
803            let conn = state.conn.lock().unwrap();
804            storage::insert_node(&conn, "x", &sample_doc()).unwrap();
805        }
806        let app = build_router(state);
807
808        // Each (method, uri, body, allowed_status) tuple must produce a
809        // status that is NEITHER 404 (route missing) NOR 405 (wrong
810        // method). Specific behaviour is covered in the other tests.
811        let expectations: &[(&str, &str, &str, &[u16])] = &[
812            ("GET", "/nodes/x", "", &[200]),
813            ("PUT", "/nodes/x", r#"{"document":{"blocks":[]}}"#, &[200]),
814            ("DELETE", "/nodes/x", "", &[204]),
815            ("GET", "/nodes/x/neighbors", "", &[200]),
816            ("GET", "/nodes", "", &[200]),
817            (
818                "POST",
819                "/nodes",
820                r#"{"document":{"blocks":[]}}"#,
821                &[201, 409],
822            ),
823            ("GET", "/search?q=hi", "", &[200]),
824            ("GET", "/tags/api", "", &[200]),
825            ("GET", "/recent", "", &[200]),
826            ("POST", "/admin/relink", "", &[200]),
827        ];
828        for (method, uri, body, ok) in expectations {
829            let (status, _, _) = send(
830                app.clone(),
831                method,
832                uri,
833                if body.is_empty() {
834                    None
835                } else {
836                    Some("application/json")
837                },
838                Some("application/json"),
839                body.as_bytes().to_vec(),
840            )
841            .await;
842            assert!(
843                ok.contains(&status.as_u16()),
844                "{method} {uri} got {status}, expected one of {ok:?}"
845            );
846        }
847    }
848
849    #[tokio::test]
850    async fn router_shape_rejects_unknown_paths() {
851        let app = build_router(setup_state());
852        let (status, _, _) = send(app, "GET", "/no-such-route", None, None, vec![]).await;
853        assert_eq!(status, StatusCode::NOT_FOUND);
854    }
855
856    #[tokio::test]
857    async fn router_shape_rejects_wrong_method_on_admin_relink() {
858        // /admin/relink is POST-only; GET must not match.
859        let app = build_router(setup_state());
860        let (status, _, _) = send(app, "GET", "/admin/relink", None, None, vec![]).await;
861        assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
862    }
863
864    #[tokio::test]
865    async fn router_shape_rejects_post_on_recent() {
866        // /recent is GET-only.
867        let app = build_router(setup_state());
868        let (status, _, _) = send(app, "POST", "/recent", None, None, vec![]).await;
869        assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
870    }
871
872    // ── api_neighbors ─────────────────────────────────────────────────
873
874    #[tokio::test]
875    async fn api_neighbors_empty_for_isolated_node() {
876        let state = setup_state();
877        {
878            let conn = state.conn.lock().unwrap();
879            storage::insert_node(&conn, "lonely", &sample_doc()).unwrap();
880        }
881        let app = build_router(state);
882        let (status, _, body) =
883            send(app, "GET", "/nodes/lonely/neighbors", None, None, vec![]).await;
884        assert_eq!(status, StatusCode::OK);
885        let v = json_str(&body);
886        assert_eq!(v["outgoing"].as_array().unwrap().len(), 0);
887        assert_eq!(v["incoming"].as_array().unwrap().len(), 0);
888    }
889
890    #[tokio::test]
891    async fn api_neighbors_returns_200_with_empty_for_unknown_id() {
892        // Per criterion: unknown id returns 200 with empty arrays, NOT 404.
893        let app = build_router(setup_state());
894        let (status, _, body) = send(app, "GET", "/nodes/nope/neighbors", None, None, vec![]).await;
895        assert_eq!(status, StatusCode::OK);
896        let v = json_str(&body);
897        assert!(v["outgoing"].as_array().unwrap().is_empty());
898        assert!(v["incoming"].as_array().unwrap().is_empty());
899    }
900
901    #[tokio::test]
902    async fn api_neighbors_field_names_are_camelcase() {
903        let state = setup_state();
904        {
905            let conn = state.conn.lock().unwrap();
906            storage::insert_node(&conn, "a", &sample_doc()).unwrap();
907            storage::insert_node(&conn, "b", &sample_doc()).unwrap();
908            storage::insert_node(&conn, "c", &sample_doc()).unwrap();
909            // a -> b, c -> a
910            storage::relink_one(&conn, "a", &link_doc(&["b"])).unwrap();
911            storage::relink_one(&conn, "c", &link_doc(&["a"])).unwrap();
912        }
913        let app = build_router(state);
914        let (status, _, body) = send(app, "GET", "/nodes/a/neighbors", None, None, vec![]).await;
915        assert_eq!(status, StatusCode::OK);
916        let v = json_str(&body);
917        let outgoing = v["outgoing"].as_array().unwrap();
918        let incoming = v["incoming"].as_array().unwrap();
919        assert_eq!(outgoing.len(), 1);
920        assert_eq!(incoming.len(), 1);
921        assert_eq!(outgoing[0]["target"].as_str().unwrap(), "b");
922        assert_eq!(outgoing[0]["linkType"].as_str().unwrap(), "id");
923        assert_eq!(incoming[0]["target"].as_str().unwrap(), "c");
924        assert_eq!(incoming[0]["linkType"].as_str().unwrap(), "id");
925        // No snake_case "link_type" field should appear.
926        assert!(outgoing[0].get("link_type").is_none());
927    }
928
929    // ── api_list_all ──────────────────────────────────────────────────
930
931    #[tokio::test]
932    async fn api_list_all_orders_by_updated_at_desc() {
933        let state = setup_state();
934        {
935            let conn = state.conn.lock().unwrap();
936            storage::insert_node(&conn, "first", &sample_doc()).unwrap();
937            std::thread::sleep(std::time::Duration::from_millis(10));
938            storage::insert_node(&conn, "second", &sample_doc()).unwrap();
939            std::thread::sleep(std::time::Duration::from_millis(10));
940            storage::insert_node(&conn, "third", &sample_doc()).unwrap();
941        }
942        let app = build_router(state);
943        let (status, _, body) = send(app, "GET", "/nodes", None, None, vec![]).await;
944        assert_eq!(status, StatusCode::OK);
945        let v = json_str(&body);
946        let arr = v.as_array().unwrap();
947        assert_eq!(arr.len(), 3);
948        assert_eq!(arr[0]["id"].as_str().unwrap(), "third");
949        assert_eq!(arr[1]["id"].as_str().unwrap(), "second");
950        assert_eq!(arr[2]["id"].as_str().unwrap(), "first");
951    }
952
953    #[tokio::test]
954    async fn api_list_all_paginates_with_limit_and_offset() {
955        let state = setup_state();
956        {
957            let conn = state.conn.lock().unwrap();
958            for i in 0..5 {
959                storage::insert_node(&conn, &format!("n{i}"), &sample_doc()).unwrap();
960                std::thread::sleep(std::time::Duration::from_millis(2));
961            }
962        }
963        let app = build_router(state);
964        let (status, _, body) = send(
965            app.clone(),
966            "GET",
967            "/nodes?limit=2&offset=0",
968            None,
969            None,
970            vec![],
971        )
972        .await;
973        assert_eq!(status, StatusCode::OK);
974        let v = json_str(&body);
975        assert_eq!(v.as_array().unwrap().len(), 2);
976
977        let (_, _, body2) = send(app, "GET", "/nodes?limit=2&offset=2", None, None, vec![]).await;
978        let v2 = json_str(&body2);
979        assert_eq!(v2.as_array().unwrap().len(), 2);
980        // No overlap between the two pages.
981        let p1: Vec<&str> = v
982            .as_array()
983            .unwrap()
984            .iter()
985            .map(|n| n["id"].as_str().unwrap())
986            .collect();
987        let p2: Vec<&str> = v2
988            .as_array()
989            .unwrap()
990            .iter()
991            .map(|n| n["id"].as_str().unwrap())
992            .collect();
993        for id in &p1 {
994            assert!(!p2.contains(id));
995        }
996    }
997
998    #[tokio::test]
999    async fn api_list_all_offset_past_end_returns_empty() {
1000        let state = setup_state();
1001        {
1002            let conn = state.conn.lock().unwrap();
1003            storage::insert_node(&conn, "only", &sample_doc()).unwrap();
1004        }
1005        let app = build_router(state);
1006        let (status, _, body) = send(app, "GET", "/nodes?offset=999", None, None, vec![]).await;
1007        assert_eq!(status, StatusCode::OK);
1008        let v = json_str(&body);
1009        assert!(v.as_array().unwrap().is_empty());
1010    }
1011
1012    #[tokio::test]
1013    async fn api_list_all_default_limit_is_100() {
1014        // Haskell default: limit = 100. Insert 150 rows; default page
1015        // returns 100.
1016        let state = setup_state();
1017        {
1018            let conn = state.conn.lock().unwrap();
1019            for i in 0..150 {
1020                storage::insert_node(&conn, &format!("n{i:03}"), &sample_doc()).unwrap();
1021            }
1022        }
1023        let app = build_router(state);
1024        let (status, _, body) = send(app, "GET", "/nodes", None, None, vec![]).await;
1025        assert_eq!(status, StatusCode::OK);
1026        let v = json_str(&body);
1027        assert_eq!(v.as_array().unwrap().len(), 100);
1028    }
1029
1030    // ── api_relink ────────────────────────────────────────────────────
1031
1032    #[tokio::test]
1033    async fn api_relink_returns_node_and_link_counts() {
1034        let state = setup_state();
1035        {
1036            let conn = state.conn.lock().unwrap();
1037            // a links to b before b exists; b inserted with no links.
1038            storage::insert_node(&conn, "a", &link_doc(&["b"])).unwrap();
1039            storage::insert_node(&conn, "b", &sample_doc()).unwrap();
1040        }
1041        let app = build_router(state);
1042        let (status, _, body) = send(app, "POST", "/admin/relink", None, None, vec![]).await;
1043        assert_eq!(status, StatusCode::OK);
1044        let v = json_str(&body);
1045        // JSON field names: nodes, links.
1046        assert_eq!(v["nodes"].as_u64().unwrap(), 2);
1047        assert_eq!(v["links"].as_u64().unwrap(), 1);
1048        // No surprise extra fields.
1049        assert!(v.get("nodes_processed").is_none());
1050        assert!(v.get("links_written").is_none());
1051    }
1052
1053    #[tokio::test]
1054    async fn api_relink_works_on_empty_db() {
1055        let app = build_router(setup_state());
1056        let (status, _, body) = send(app, "POST", "/admin/relink", None, None, vec![]).await;
1057        assert_eq!(status, StatusCode::OK);
1058        let v = json_str(&body);
1059        assert_eq!(v["nodes"].as_u64().unwrap(), 0);
1060        assert_eq!(v["links"].as_u64().unwrap(), 0);
1061    }
1062
1063    // ── api_create_id_query ───────────────────────────────────────────
1064
1065    #[tokio::test]
1066    async fn api_create_id_query_uses_query_id_when_only_query_provided() {
1067        let app = build_router(setup_state());
1068        let body = serde_json::to_vec(&CreateNodeRequest {
1069            id: None,
1070            document: sample_doc(),
1071        })
1072        .unwrap();
1073        let (status, _, resp) = send(
1074            app,
1075            "POST",
1076            "/nodes?id=from-query",
1077            Some("application/json"),
1078            Some("application/json"),
1079            body,
1080        )
1081        .await;
1082        assert_eq!(status, StatusCode::CREATED);
1083        let v = json_str(&resp);
1084        assert_eq!(v["id"].as_str().unwrap(), "from-query");
1085    }
1086
1087    #[tokio::test]
1088    async fn api_create_id_query_uses_body_id_when_no_query() {
1089        let app = build_router(setup_state());
1090        let body = serde_json::to_vec(&CreateNodeRequest {
1091            id: Some("from-body".into()),
1092            document: sample_doc(),
1093        })
1094        .unwrap();
1095        let (status, _, resp) = send(
1096            app,
1097            "POST",
1098            "/nodes",
1099            Some("application/json"),
1100            Some("application/json"),
1101            body,
1102        )
1103        .await;
1104        assert_eq!(status, StatusCode::CREATED);
1105        let v = json_str(&resp);
1106        assert_eq!(v["id"].as_str().unwrap(), "from-body");
1107    }
1108
1109    #[tokio::test]
1110    async fn api_create_id_query_query_overrides_body() {
1111        // Per criterion: query ?id= wins over body.id.
1112        let app = build_router(setup_state());
1113        let body = serde_json::to_vec(&CreateNodeRequest {
1114            id: Some("from-body".into()),
1115            document: sample_doc(),
1116        })
1117        .unwrap();
1118        let (status, _, resp) = send(
1119            app,
1120            "POST",
1121            "/nodes?id=from-query",
1122            Some("application/json"),
1123            Some("application/json"),
1124            body,
1125        )
1126        .await;
1127        assert_eq!(status, StatusCode::CREATED);
1128        let v = json_str(&resp);
1129        assert_eq!(v["id"].as_str().unwrap(), "from-query");
1130    }
1131
1132    #[tokio::test]
1133    async fn api_create_id_query_mints_uuid_when_neither_supplied() {
1134        let app = build_router(setup_state());
1135        let body = serde_json::to_vec(&CreateNodeRequest {
1136            id: None,
1137            document: sample_doc(),
1138        })
1139        .unwrap();
1140        let (status, _, resp) = send(
1141            app,
1142            "POST",
1143            "/nodes",
1144            Some("application/json"),
1145            Some("application/json"),
1146            body,
1147        )
1148        .await;
1149        assert_eq!(status, StatusCode::CREATED);
1150        let v = json_str(&resp);
1151        let id = v["id"].as_str().unwrap();
1152        assert!(uuid::Uuid::parse_str(id).is_ok(), "expected UUID, got {id}");
1153    }
1154
1155    #[tokio::test]
1156    async fn api_create_id_query_conflict_via_query_returns_409() {
1157        let state = setup_state();
1158        {
1159            let conn = state.conn.lock().unwrap();
1160            storage::insert_node(&conn, "taken", &sample_doc()).unwrap();
1161        }
1162        let app = build_router(state);
1163        let body = serde_json::to_vec(&CreateNodeRequest {
1164            id: None,
1165            document: sample_doc(),
1166        })
1167        .unwrap();
1168        let (status, _, _) = send(
1169            app,
1170            "POST",
1171            "/nodes?id=taken",
1172            Some("application/json"),
1173            Some("application/json"),
1174            body,
1175        )
1176        .await;
1177        assert_eq!(status, StatusCode::CONFLICT);
1178    }
1179
1180    #[tokio::test]
1181    async fn api_create_id_query_conflict_via_body_returns_409() {
1182        let state = setup_state();
1183        {
1184            let conn = state.conn.lock().unwrap();
1185            storage::insert_node(&conn, "taken", &sample_doc()).unwrap();
1186        }
1187        let app = build_router(state);
1188        let body = serde_json::to_vec(&CreateNodeRequest {
1189            id: Some("taken".into()),
1190            document: sample_doc(),
1191        })
1192        .unwrap();
1193        let (status, _, _) = send(
1194            app,
1195            "POST",
1196            "/nodes",
1197            Some("application/json"),
1198            Some("application/json"),
1199            body,
1200        )
1201        .await;
1202        assert_eq!(status, StatusCode::CONFLICT);
1203    }
1204
1205    // ── api_orgtext_read ──────────────────────────────────────────────
1206
1207    #[tokio::test]
1208    async fn api_orgtext_read_returns_org_for_text_org_accept() {
1209        let state = setup_state();
1210        {
1211            let conn = state.conn.lock().unwrap();
1212            storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1213        }
1214        let app = build_router(state);
1215        let (status, headers, body) =
1216            send(app, "GET", "/nodes/n", None, Some("text/org"), vec![]).await;
1217        assert_eq!(status, StatusCode::OK);
1218        let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1219        assert!(ct.starts_with("text/org"), "content-type was {ct}");
1220        let text = String::from_utf8(body).unwrap();
1221        // Body is the org rendering with a leading kb metadata drawer,
1222        // NOT the JSON envelope.
1223        assert!(
1224            text.starts_with(":PROPERTIES:\n:ID: n\n"),
1225            "missing metadata drawer: {text}"
1226        );
1227        assert!(text.ends_with(&generator::generate(&sample_doc())));
1228        assert!(!text.starts_with('{'), "expected org body, got JSON-ish");
1229    }
1230
1231    #[tokio::test]
1232    async fn api_orgtext_read_accepts_text_plain_as_synonym() {
1233        let state = setup_state();
1234        {
1235            let conn = state.conn.lock().unwrap();
1236            storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1237        }
1238        let app = build_router(state);
1239        let (status, headers, body) =
1240            send(app, "GET", "/nodes/n", None, Some("text/plain"), vec![]).await;
1241        assert_eq!(status, StatusCode::OK);
1242        let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1243        assert!(ct.starts_with("text/org"), "content-type was {ct}");
1244        let text = String::from_utf8(body).unwrap();
1245        assert!(text.starts_with(":PROPERTIES:\n:ID: n\n"));
1246        assert!(text.ends_with(&generator::generate(&sample_doc())));
1247    }
1248
1249    #[tokio::test]
1250    async fn api_orgtext_read_returns_json_when_accept_application_json() {
1251        let state = setup_state();
1252        {
1253            let conn = state.conn.lock().unwrap();
1254            storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1255        }
1256        let app = build_router(state);
1257        let (status, headers, body) = send(
1258            app,
1259            "GET",
1260            "/nodes/n",
1261            None,
1262            Some("application/json"),
1263            vec![],
1264        )
1265        .await;
1266        assert_eq!(status, StatusCode::OK);
1267        let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1268        assert!(ct.starts_with("application/json"), "content-type was {ct}");
1269        let v = json_str(&body);
1270        assert_eq!(v["id"].as_str().unwrap(), "n");
1271    }
1272
1273    #[tokio::test]
1274    async fn api_orgtext_read_defaults_to_json_when_accept_absent() {
1275        let state = setup_state();
1276        {
1277            let conn = state.conn.lock().unwrap();
1278            storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1279        }
1280        let app = build_router(state);
1281        let (status, headers, body) = send(app, "GET", "/nodes/n", None, None, vec![]).await;
1282        assert_eq!(status, StatusCode::OK);
1283        let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1284        assert!(ct.starts_with("application/json"));
1285        let _ = json_str(&body);
1286    }
1287
1288    #[tokio::test]
1289    async fn api_orgtext_read_unknown_id_404_under_org_accept() {
1290        let app = build_router(setup_state());
1291        let (status, _, _) = send(app, "GET", "/nodes/nope", None, Some("text/org"), vec![]).await;
1292        assert_eq!(status, StatusCode::NOT_FOUND);
1293    }
1294
1295    #[tokio::test]
1296    async fn api_orgtext_read_unknown_id_404_under_json_accept() {
1297        let app = build_router(setup_state());
1298        let (status, _, _) = send(
1299            app,
1300            "GET",
1301            "/nodes/nope",
1302            None,
1303            Some("application/json"),
1304            vec![],
1305        )
1306        .await;
1307        assert_eq!(status, StatusCode::NOT_FOUND);
1308    }
1309
1310    // ── api_orgtext_write ─────────────────────────────────────────────
1311
1312    #[tokio::test]
1313    async fn api_orgtext_write_post_parses_org_body() {
1314        let app = build_router(setup_state());
1315        let body = b"* Hello\nworld\n".to_vec();
1316        let (status, _, resp) = send(
1317            app,
1318            "POST",
1319            "/nodes",
1320            Some("text/org"),
1321            Some("application/json"),
1322            body,
1323        )
1324        .await;
1325        assert_eq!(status, StatusCode::CREATED);
1326        let v = json_str(&resp);
1327        // Title comes from the parsed first heading.
1328        assert_eq!(v["title"].as_str().unwrap(), "Hello");
1329        // Server minted a UUID since text/org carries no body id.
1330        let id = v["id"].as_str().unwrap();
1331        assert!(uuid::Uuid::parse_str(id).is_ok());
1332    }
1333
1334    /// Whether `pandoc` is on `PATH` — markdown ingest tests skip without it.
1335    fn pandoc_available() -> bool {
1336        std::process::Command::new("pandoc")
1337            .arg("--version")
1338            .output()
1339            .is_ok_and(|o| o.status.success())
1340    }
1341
1342    #[tokio::test]
1343    async fn api_markdown_write_post_converts_via_pandoc() {
1344        if !pandoc_available() {
1345            eprintln!("skipping: pandoc not on PATH");
1346            return;
1347        }
1348        let app = build_router(setup_state());
1349        let body = b"# Markdown Title\n\nsome ~~struck~~ body\n".to_vec();
1350        let (status, _, resp) = send(
1351            app,
1352            "POST",
1353            "/nodes",
1354            Some("text/markdown"),
1355            Some("application/json"),
1356            body,
1357        )
1358        .await;
1359        assert_eq!(status, StatusCode::CREATED);
1360        let v = json_str(&resp);
1361        assert_eq!(v["title"].as_str().unwrap(), "Markdown Title");
1362    }
1363
1364    #[tokio::test]
1365    async fn api_markdown_write_put_converts_via_pandoc() {
1366        if !pandoc_available() {
1367            eprintln!("skipping: pandoc not on PATH");
1368            return;
1369        }
1370        let state = setup_state();
1371        {
1372            let conn = state.conn.lock().unwrap();
1373            storage::insert_node(&conn, "md-target", &sample_doc()).unwrap();
1374        }
1375        let app = build_router(state);
1376        let (status, _, resp) = send(
1377            app,
1378            "PUT",
1379            "/nodes/md-target",
1380            Some("text/markdown"),
1381            Some("application/json"),
1382            b"# Replaced\n\nnew body\n".to_vec(),
1383        )
1384        .await;
1385        assert_eq!(status, StatusCode::OK);
1386        let v = json_str(&resp);
1387        assert_eq!(v["id"].as_str().unwrap(), "md-target");
1388        assert_eq!(v["title"].as_str().unwrap(), "Replaced");
1389    }
1390
1391    #[tokio::test]
1392    async fn api_orgtext_write_post_accepts_text_plain_synonym() {
1393        let app = build_router(setup_state());
1394        let body = b"* Plain\nbody\n".to_vec();
1395        let (status, _, resp) = send(
1396            app,
1397            "POST",
1398            "/nodes",
1399            Some("text/plain"),
1400            Some("application/json"),
1401            body,
1402        )
1403        .await;
1404        assert_eq!(status, StatusCode::CREATED);
1405        let v = json_str(&resp);
1406        assert_eq!(v["title"].as_str().unwrap(), "Plain");
1407    }
1408
1409    #[tokio::test]
1410    async fn api_orgtext_write_post_uses_query_id_for_org_body() {
1411        // text/org carries no body id; the only client-supplied id channel
1412        // is the ?id= query parameter.
1413        let app = build_router(setup_state());
1414        let body = b"* From org\n".to_vec();
1415        let (status, _, resp) = send(
1416            app,
1417            "POST",
1418            "/nodes?id=org-import-1",
1419            Some("text/org"),
1420            Some("application/json"),
1421            body,
1422        )
1423        .await;
1424        assert_eq!(status, StatusCode::CREATED);
1425        let v = json_str(&resp);
1426        assert_eq!(v["id"].as_str().unwrap(), "org-import-1");
1427    }
1428
1429    #[tokio::test]
1430    async fn api_orgtext_write_post_strips_kb_metadata_drawer() {
1431        // An org body round-tripped from a prior export carries a leading
1432        // :PROPERTIES: drawer; it must not be persisted into the stored AST.
1433        let app = build_router(setup_state());
1434        let body = b":PROPERTIES:\n:ID: stale-id\n:CREATED: t0\n:END:\n* Heading\nbody\n".to_vec();
1435        let (status, _, resp) = send(
1436            app,
1437            "POST",
1438            "/nodes",
1439            Some("text/org"),
1440            Some("application/json"),
1441            body,
1442        )
1443        .await;
1444        assert_eq!(status, StatusCode::CREATED);
1445        let v = json_str(&resp);
1446        // The drawer id is informational — a fresh UUID is minted.
1447        let id = v["id"].as_str().unwrap();
1448        assert!(uuid::Uuid::parse_str(id).is_ok());
1449        // The stored document has no property drawer; the heading is first.
1450        let blocks = v["document"]["blocks"].as_array().unwrap();
1451        assert_eq!(blocks.len(), 1);
1452        assert!(
1453            blocks[0].get("Heading").is_some(),
1454            "drawer leaked into AST: {v}"
1455        );
1456    }
1457
1458    #[tokio::test]
1459    async fn api_orgtext_write_put_400_on_id_mismatch() {
1460        // A round-tripped org file whose drawer :ID: names a different
1461        // node must be rejected, not silently written to the path id.
1462        let state = setup_state();
1463        {
1464            let conn = state.conn.lock().unwrap();
1465            storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1466        }
1467        let app = build_router(state);
1468        let body = b":PROPERTIES:\n:ID: other-node\n:END:\n* Heading\n".to_vec();
1469        let (status, _, _) = send(
1470            app,
1471            "PUT",
1472            "/nodes/n",
1473            Some("text/org"),
1474            Some("application/json"),
1475            body,
1476        )
1477        .await;
1478        assert_eq!(status, StatusCode::BAD_REQUEST);
1479    }
1480
1481    #[tokio::test]
1482    async fn api_orgtext_write_put_accepts_matching_drawer_id() {
1483        let state = setup_state();
1484        {
1485            let conn = state.conn.lock().unwrap();
1486            storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1487        }
1488        let app = build_router(state);
1489        let body = b":PROPERTIES:\n:ID: n\n:END:\n* Updated\n".to_vec();
1490        let (status, _, resp) = send(
1491            app,
1492            "PUT",
1493            "/nodes/n",
1494            Some("text/org"),
1495            Some("application/json"),
1496            body,
1497        )
1498        .await;
1499        assert_eq!(status, StatusCode::OK);
1500        let v = json_str(&resp);
1501        assert_eq!(v["title"].as_str().unwrap(), "Updated");
1502        // The drawer did not leak into the stored AST.
1503        let blocks = v["document"]["blocks"].as_array().unwrap();
1504        assert!(blocks[0].get("Heading").is_some(), "drawer leaked: {v}");
1505    }
1506
1507    #[tokio::test]
1508    async fn api_orgtext_write_put_parses_org_body() {
1509        let state = setup_state();
1510        {
1511            let conn = state.conn.lock().unwrap();
1512            storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1513        }
1514        let app = build_router(state);
1515        let body = b"* Replaced\nbody\n".to_vec();
1516        let (status, _, resp) = send(
1517            app,
1518            "PUT",
1519            "/nodes/n",
1520            Some("text/org"),
1521            Some("application/json"),
1522            body,
1523        )
1524        .await;
1525        assert_eq!(status, StatusCode::OK);
1526        let v = json_str(&resp);
1527        assert_eq!(v["title"].as_str().unwrap(), "Replaced");
1528    }
1529
1530    #[tokio::test]
1531    async fn api_orgtext_write_put_400_on_invalid_utf8() {
1532        let state = setup_state();
1533        {
1534            let conn = state.conn.lock().unwrap();
1535            storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1536        }
1537        let app = build_router(state);
1538        // Non-UTF-8 bytes for an org body.
1539        let body = vec![0xff, 0xfe, 0xfd];
1540        let (status, _, _) = send(
1541            app,
1542            "PUT",
1543            "/nodes/n",
1544            Some("text/org"),
1545            Some("application/json"),
1546            body,
1547        )
1548        .await;
1549        assert_eq!(status, StatusCode::BAD_REQUEST);
1550    }
1551
1552    #[tokio::test]
1553    async fn api_orgtext_write_post_400_on_invalid_utf8() {
1554        let app = build_router(setup_state());
1555        let body = vec![0xff, 0xfe, 0xfd];
1556        let (status, _, _) = send(
1557            app,
1558            "POST",
1559            "/nodes",
1560            Some("text/org"),
1561            Some("application/json"),
1562            body,
1563        )
1564        .await;
1565        assert_eq!(status, StatusCode::BAD_REQUEST);
1566    }
1567
1568    // ── api_orgtext_write_response ────────────────────────────────────
1569
1570    #[tokio::test]
1571    async fn api_orgtext_write_response_post_returns_org_under_org_accept() {
1572        let app = build_router(setup_state());
1573        let body = b"* Hello\nworld\n".to_vec();
1574        let (status, headers, resp) = send(
1575            app,
1576            "POST",
1577            "/nodes",
1578            Some("text/org"),
1579            Some("text/org"),
1580            body,
1581        )
1582        .await;
1583        assert_eq!(status, StatusCode::CREATED);
1584        let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1585        assert!(ct.starts_with("text/org"), "content-type was {ct}");
1586        let text = String::from_utf8(resp).unwrap();
1587        // Body is org text, not JSON.
1588        assert!(!text.starts_with('{'));
1589        assert!(text.contains("Hello"));
1590    }
1591
1592    #[tokio::test]
1593    async fn api_orgtext_write_response_put_returns_org_under_org_accept() {
1594        let state = setup_state();
1595        {
1596            let conn = state.conn.lock().unwrap();
1597            storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1598        }
1599        let app = build_router(state);
1600        let body = serde_json::to_vec(&UpdateNodeRequest {
1601            document: Document {
1602                blocks: vec![Block::Heading {
1603                    level: 1,
1604                    title: Title("PutOrg".into()),
1605                    tags: vec![],
1606                    children: vec![],
1607                }],
1608            },
1609        })
1610        .unwrap();
1611        let (status, headers, resp) = send(
1612            app,
1613            "PUT",
1614            "/nodes/n",
1615            Some("application/json"),
1616            Some("text/org"),
1617            body,
1618        )
1619        .await;
1620        assert_eq!(status, StatusCode::OK);
1621        let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1622        assert!(ct.starts_with("text/org"), "content-type was {ct}");
1623        let text = String::from_utf8(resp).unwrap();
1624        assert!(!text.starts_with('{'));
1625        assert!(text.contains("PutOrg"));
1626    }
1627
1628    #[tokio::test]
1629    async fn api_orgtext_write_response_put_returns_json_under_json_accept() {
1630        let state = setup_state();
1631        {
1632            let conn = state.conn.lock().unwrap();
1633            storage::insert_node(&conn, "n", &sample_doc()).unwrap();
1634        }
1635        let app = build_router(state);
1636        let body = b"* Title\n".to_vec();
1637        let (status, headers, resp) = send(
1638            app,
1639            "PUT",
1640            "/nodes/n",
1641            Some("text/org"),
1642            Some("application/json"),
1643            body,
1644        )
1645        .await;
1646        assert_eq!(status, StatusCode::OK);
1647        let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1648        assert!(ct.starts_with("application/json"), "content-type was {ct}");
1649        let v = json_str(&resp);
1650        assert_eq!(v["title"].as_str().unwrap(), "Title");
1651    }
1652
1653    // ── search_endpoint_shape ─────────────────────────────────────────
1654
1655    /// `GET /search?q=…` continues to return a JSON array of `NodeSummary`
1656    /// objects (`{"id":..., "title":...}`), unchanged from v2/v3/v4. The
1657    /// backing call is now [`storage::search_hybrid`] but with no
1658    /// embedding hook configured it degrades to FTS, and the response
1659    /// envelope is identical.
1660    #[tokio::test]
1661    async fn search_endpoint_shape_returns_node_summary_array() {
1662        let state = setup_state();
1663        {
1664            let conn = state.conn.lock().unwrap();
1665            storage::insert_node(
1666                &conn,
1667                "match-1",
1668                &Document {
1669                    blocks: vec![Block::Heading {
1670                        level: 1,
1671                        title: Title("Rust Programming".into()),
1672                        tags: vec![],
1673                        children: vec![Block::Paragraph {
1674                            inlines: vec![Inline::Plain("systems language".into())],
1675                        }],
1676                    }],
1677                },
1678            )
1679            .unwrap();
1680        }
1681        let app = build_router(state);
1682        let (status, headers, body) = send(
1683            app,
1684            "GET",
1685            "/search?q=programming",
1686            None,
1687            Some("application/json"),
1688            vec![],
1689        )
1690        .await;
1691        assert_eq!(status, StatusCode::OK);
1692        let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
1693        assert!(ct.starts_with("application/json"), "content-type was {ct}");
1694        let v = json_str(&body);
1695        let arr = v.as_array().expect("response is a JSON array");
1696        assert_eq!(arr.len(), 1);
1697        // NodeSummary shape: exactly the keys "id" and "title", both strings.
1698        let item = &arr[0];
1699        let obj = item.as_object().expect("array entries are objects");
1700        let mut keys: Vec<&String> = obj.keys().collect();
1701        keys.sort();
1702        assert_eq!(
1703            keys,
1704            vec![&"id".to_string(), &"title".to_string()],
1705            "exactly id+title — no extra fields"
1706        );
1707        assert_eq!(item["id"].as_str().unwrap(), "match-1");
1708        assert_eq!(item["title"].as_str().unwrap(), "Rust Programming");
1709    }
1710
1711    #[tokio::test]
1712    async fn search_endpoint_shape_empty_query_returns_empty_array() {
1713        let app = build_router(setup_state());
1714        let (status, _, body) = send(
1715            app,
1716            "GET",
1717            "/search?q=",
1718            None,
1719            Some("application/json"),
1720            vec![],
1721        )
1722        .await;
1723        assert_eq!(status, StatusCode::OK);
1724        let v = json_str(&body);
1725        assert!(v.as_array().unwrap().is_empty());
1726    }
1727
1728    #[tokio::test]
1729    async fn search_endpoint_shape_no_q_param_returns_empty_array() {
1730        let app = build_router(setup_state());
1731        let (status, _, body) = send(
1732            app,
1733            "GET",
1734            "/search",
1735            None,
1736            Some("application/json"),
1737            vec![],
1738        )
1739        .await;
1740        assert_eq!(status, StatusCode::OK);
1741        let v = json_str(&body);
1742        assert!(v.as_array().unwrap().is_empty());
1743    }
1744}