diff --git a/Cargo.toml b/Cargo.toml index d719d6d..568d453 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ serde_yaml = "0.9" tantivy = "0.25" thiserror = "2" tokio = { version = "1", features = ["full"] } -tower = "0.5" +tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/app.rs b/src/app.rs index c41c4fc..cd1d0ea 100644 --- a/src/app.rs +++ b/src/app.rs @@ -36,7 +36,9 @@ use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; use tokio::task::JoinHandle; +use crate::http::{self as http_layer, HttpState}; use crate::index::NoteIndex; +use crate::store::NoteStore; use crate::vault::{self, Note}; use crate::watcher::run_debouncer; @@ -108,6 +110,10 @@ pub struct Anwesen { pub vault: PathBuf, pub bind: SocketAddr, pub counters: Arc, + /// Shared in-memory note store. The scanner populates it, the watcher + /// keeps it current via the writer's batches, and the HTTP layer reads + /// from it on every request. + pub store: Arc, } impl Anwesen { @@ -117,6 +123,7 @@ impl Anwesen { vault, bind, counters: RestartCounters::new(), + store: NoteStore::new(), } } } @@ -142,6 +149,7 @@ impl Application for Anwesen { let children = [ IndexWriter { counters: self.counters.clone(), + store: self.store.clone(), } .child_spec(), VaultScanner { @@ -157,6 +165,7 @@ impl Application for Anwesen { HttpServer { bind: self.bind, counters: self.counters.clone(), + store: self.store.clone(), } .child_spec(), ]; @@ -394,14 +403,17 @@ pub struct IndexBatch { #[derive(Clone)] pub struct IndexWriter { counters: Arc, + store: Arc, } impl IndexWriter { fn child_spec(self) -> ChildSpec { let counters = self.counters.clone(); + let store = self.store.clone(); ChildSpec::new(INDEX_WRITER_NAME).start(move || { IndexWriterState { counters: counters.clone(), + store: store.clone(), index: None, } .start_link(GenServerOptions::new().name(INDEX_WRITER_NAME)) @@ -411,9 +423,12 @@ impl IndexWriter { /// Runtime state for the [`IndexWriter`] process. Held in a separate struct /// so the `Clone`-friendly child-spec form (which doesn't carry the live -/// `NoteIndex`) stays simple. +/// `NoteIndex`) stays simple. Mirrors every write into the shared +/// [`NoteStore`] so the HTTP layer can serve read-one and listing +/// responses without consulting the index. pub(crate) struct IndexWriterState { counters: Arc, + store: Arc, /// Created lazily in `init` so a Tantivy construction failure surfaces /// as an `ExitReason` and triggers a supervisor restart, rather than /// poisoning the child spec. @@ -445,6 +460,7 @@ impl GenServer for IndexWriterState { tracing::error!(error = %e, "index_writer: rebuild failed"); return Err(ExitReason::from(format!("rebuild: {e}"))); } + self.store.replace(notes); tracing::info!(notes = count, "index_writer: rebuilt"); } IndexWriterMessage::Batch(batch) => { @@ -453,6 +469,7 @@ impl GenServer for IndexWriterState { tracing::error!(error = %e, "index_writer: batch apply failed"); return Err(ExitReason::from(format!("batch: {e}"))); } + self.store.apply_batch(batch.upserts, &batch.deletes); tracing::info!(upserts = u, deletes = d, "index_writer: batch applied"); } IndexWriterMessage::Upsert(note) => { @@ -461,6 +478,7 @@ impl GenServer for IndexWriterState { tracing::error!(%path, error = %e, "index_writer: upsert failed"); return Err(ExitReason::from(format!("upsert {path}: {e}"))); } + self.store.upsert(*note); tracing::debug!(%path, "index_writer: upserted"); } IndexWriterMessage::Delete(path) => { @@ -468,6 +486,7 @@ impl GenServer for IndexWriterState { tracing::error!(%path, error = %e, "index_writer: delete failed"); return Err(ExitReason::from(format!("delete {path}: {e}"))); } + self.store.delete(&path); tracing::debug!(%path, "index_writer: deleted"); } } @@ -483,12 +502,12 @@ impl GenServer for IndexWriterState { } } -// -- http_server (stub for ANW-13/14/15) ------------------------------------ +// -- http_server ------------------------------------------------------------ +/// The HTTP server has no inbound message protocol; the single `Noop` +/// variant satisfies Hydra's `Receivable` bound. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum HttpServerMessage { - /// Placeholder; replaced by the axum surface in - /// [ANW-13](https://crvrs.youtrack.cloud/issue/ANW-13) and following. Noop, } @@ -496,32 +515,62 @@ pub enum HttpServerMessage { pub struct HttpServer { bind: SocketAddr, counters: Arc, + store: Arc, } impl HttpServer { fn child_spec(self) -> ChildSpec { let bind = self.bind; let counters = self.counters.clone(); + let store = self.store.clone(); ChildSpec::new(HTTP_SERVER_NAME).start(move || { - HttpServer { + HttpServerState { bind, counters: counters.clone(), + store: store.clone(), + server: None, } .start_link(GenServerOptions::new().name(HTTP_SERVER_NAME)) }) } } -impl GenServer for HttpServer { +struct HttpServerState { + bind: SocketAddr, + counters: Arc, + store: Arc, + server: Option>, +} + +impl Drop for HttpServerState { + fn drop(&mut self) { + if let Some(h) = self.server.take() { + h.abort(); + } + } +} + +impl GenServer for HttpServerState { type Message = HttpServerMessage; async fn init(&mut self) -> Result<(), ExitReason> { let restart = self.counters.http_server.record_init(); - tracing::warn!( - restart, - bind = %self.bind, - "http_server: stub -- no port bound yet (real binding lands in ANW-13)" - ); + + let listener = tokio::net::TcpListener::bind(self.bind) + .await + .map_err(|e| ExitReason::from(format!("http_server: bind {}: {e}", self.bind)))?; + + let router = http_layer::router(HttpState { + store: self.store.clone(), + }); + let server = tokio::spawn(async move { + if let Err(e) = axum::serve(listener, router).await { + tracing::error!(error = %e, "http_server: serve loop exited with error"); + } + }); + self.server = Some(server); + + tracing::info!(restart, bind = %self.bind, "http_server: init"); Ok(()) } diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..fe84083 --- /dev/null +++ b/src/http.rs @@ -0,0 +1,358 @@ +//! HTTP surface for Anwesen. +//! +//! Implements the read-one-note endpoint per [ANW-13]: +//! +//! ```text +//! GET /notes/ -> JSON {path, frontmatter, body, last_modified, etag, size} +//! GET /notes/ + Accept: text/markdown -> raw file bytes +//! GET /notes/ + If-None-Match: "" -> 304 on match +//! ``` +//! +//! Subsequent issues bolt the folder index ([ANW-14]), query ([ANW-15]) and +//! `/health` ([ANW-8]) onto the same [`Router`]. + +use std::sync::Arc; + +use axum::Router; +use axum::body::Body; +use axum::extract::{Path as AxumPath, State}; +use axum::http::header::{ACCEPT, CONTENT_TYPE, ETAG, IF_NONE_MATCH}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use serde::Serialize; + +use crate::store::NoteStore; +use crate::vault::{Note, frontmatter_to_json}; + +/// Shared state injected into every handler. +#[derive(Clone)] +pub struct HttpState { + pub store: Arc, +} + +pub fn router(state: HttpState) -> Router { + Router::new() + .route("/notes/{*path}", get(get_note)) + .with_state(state) +} + +#[derive(Debug, Serialize)] +struct NoteResponse<'a> { + path: &'a str, + frontmatter: serde_json::Value, + body: &'a str, + last_modified: String, + etag: &'a str, + size: u64, +} + +impl<'a> From<&'a Note> for NoteResponse<'a> { + fn from(note: &'a Note) -> Self { + Self { + path: ¬e.path, + frontmatter: frontmatter_to_json(¬e.frontmatter), + body: ¬e.body, + last_modified: note.last_modified.to_rfc3339(), + etag: ¬e.etag, + size: note.size, + } + } +} + +async fn get_note( + State(state): State, + AxumPath(path): AxumPath, + headers: HeaderMap, +) -> Response { + match resolve_path(&path) { + Err(reason) => bad_request(reason).into_response(), + Ok(canonical) => match state.store.get(&canonical) { + None => not_found().into_response(), + Some(note) => respond_note(¬e, &headers).into_response(), + }, + } +} + +fn respond_note(note: &Note, headers: &HeaderMap) -> Response { + if let Some(client_etag) = headers.get(IF_NONE_MATCH) + && etag_matches(client_etag, ¬e.etag) + { + return Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(ETAG, note.etag.as_str()) + .body(Body::empty()) + .expect("static response"); + } + + if wants_markdown(headers) { + return Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "text/markdown; charset=utf-8") + .header(ETAG, note.etag.as_str()) + .body(Body::from(note.raw_bytes.clone())) + .expect("static response"); + } + + let body = serde_json::to_vec(&NoteResponse::from(note)).expect("note serializes"); + Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/json") + .header(ETAG, note.etag.as_str()) + .body(Body::from(body)) + .expect("static response") +} + +fn wants_markdown(headers: &HeaderMap) -> bool { + headers + .get_all(ACCEPT) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|s| s.split(',')) + .any(|raw| { + // Strip media-type parameters (`text/markdown; q=0.9`) before compare. + let mime = raw.split(';').next().unwrap_or(raw).trim(); + mime.eq_ignore_ascii_case("text/markdown") + }) +} + +fn etag_matches(client: &HeaderValue, server_etag: &str) -> bool { + let Ok(s) = client.to_str() else { + return false; + }; + // `If-None-Match` may carry one or more comma-separated entity-tags; + // we ignore weak validators (W/) since the server only emits strong. + s.split(',').any(|raw| { + let trimmed = raw.trim(); + let stripped = trimmed.strip_prefix("W/").unwrap_or(trimmed); + stripped == server_etag || trimmed == "*" + }) +} + +fn bad_request(reason: &str) -> (StatusCode, String) { + (StatusCode::BAD_REQUEST, format!("bad request: {reason}\n")) +} + +fn not_found() -> (StatusCode, &'static str) { + (StatusCode::NOT_FOUND, "not found\n") +} + +/// Normalize a request path per the User Manual: +/// +/// - URL-decode the entire path once; +/// - strip leading slashes; +/// - reject any `..` segment (or empty segment / `.`) with `400`. +/// +/// Returns the vault-relative form (forward-slash, no leading slash). +fn resolve_path(raw: &str) -> Result { + let decoded = percent_decode(raw).ok_or("invalid percent-encoding")?; + let stripped = decoded.trim_start_matches('/').to_string(); + if stripped.is_empty() { + return Err("path is empty"); + } + for seg in stripped.split('/') { + if seg.is_empty() || seg == "." || seg == ".." { + return Err("path contains forbidden segment"); + } + } + Ok(stripped) +} + +/// Tiny dependency-free single-pass percent-decoder. Anwesen URL-decodes +/// each path component exactly once per the User Manual contract; we keep +/// the decoder small rather than pull `percent-encoding` for one call. +fn percent_decode(input: &str) -> Option { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + if i + 2 >= bytes.len() { + return None; + } + let hi = hex_value(bytes[i + 1])?; + let lo = hex_value(bytes[i + 2])?; + out.push((hi << 4) | lo); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} + +fn hex_value(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::to_bytes; + use axum::http::Request; + use chrono::DateTime; + use std::collections::BTreeMap; + use tower::ServiceExt; + + use crate::vault::Value; + + fn note(path: &str, body: &str) -> Note { + let mut fm: BTreeMap = BTreeMap::new(); + fm.insert("tag".into(), Value::String("demo".into())); + let raw = format!("---\ntag: demo\n---\n{body}"); + let etag = format!("\"{}\"", blake3::hash(raw.as_bytes()).to_hex()); + let size = raw.len() as u64; + Note { + path: path.into(), + frontmatter: fm, + body: body.into(), + raw_bytes: raw.into_bytes(), + last_modified: DateTime::from_timestamp(0, 0).unwrap(), + etag, + size, + } + } + + fn router_with(notes: Vec) -> (Router, Arc) { + let store = NoteStore::new(); + store.replace(notes); + let r = router(HttpState { + store: store.clone(), + }); + (r, store) + } + + async fn send(router: Router, req: Request) -> Response { + router.oneshot(req).await.expect("oneshot") + } + + #[tokio::test] + async fn unknown_path_returns_404() { + let (r, _) = router_with(vec![]); + let req = Request::get("/notes/missing.md") + .body(Body::empty()) + .unwrap(); + let resp = send(r, req).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn double_dot_rejected_with_400() { + let (r, _) = router_with(vec![note("a.md", "x")]); + let req = Request::get("/notes/../etc/passwd") + .body(Body::empty()) + .unwrap(); + let resp = send(r, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn percent_encoded_path_decoded_once() { + let (r, _) = router_with(vec![note("dir with space/a.md", "x")]); + let req = Request::get("/notes/dir%20with%20space/a.md") + .body(Body::empty()) + .unwrap(); + let resp = send(r, req).await; + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn default_response_is_json_with_etag_header() { + let n = note("a.md", "body"); + let expected_etag = n.etag.clone(); + let (r, _) = router_with(vec![n]); + let req = Request::get("/notes/a.md").body(Body::empty()).unwrap(); + let resp = send(r, req).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(CONTENT_TYPE).unwrap(), + "application/json" + ); + assert_eq!(resp.headers().get(ETAG).unwrap(), expected_etag.as_str()); + let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["path"], "a.md"); + assert_eq!(v["body"], "body"); + assert!(v["frontmatter"].is_object()); + } + + #[tokio::test] + async fn accept_text_markdown_returns_raw_bytes() { + let n = note("a.md", "body"); + let raw = n.raw_bytes.clone(); + let (r, _) = router_with(vec![n]); + let req = Request::get("/notes/a.md") + .header(ACCEPT, "text/markdown") + .body(Body::empty()) + .unwrap(); + let resp = send(r, req).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(CONTENT_TYPE).unwrap(), + "text/markdown; charset=utf-8" + ); + let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap(); + assert_eq!(bytes.as_ref(), raw.as_slice()); + } + + #[tokio::test] + async fn if_none_match_returns_304() { + let n = note("a.md", "body"); + let etag = n.etag.clone(); + let (r, _) = router_with(vec![n]); + let req = Request::get("/notes/a.md") + .header(IF_NONE_MATCH, etag.clone()) + .body(Body::empty()) + .unwrap(); + let resp = send(r, req).await; + assert_eq!(resp.status(), StatusCode::NOT_MODIFIED); + assert_eq!(resp.headers().get(ETAG).unwrap(), etag.as_str()); + // 304 must not carry a body. + let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap(); + assert!(bytes.is_empty()); + } + + #[tokio::test] + async fn if_none_match_star_returns_304() { + let n = note("a.md", "body"); + let (r, _) = router_with(vec![n]); + let req = Request::get("/notes/a.md") + .header(IF_NONE_MATCH, "*") + .body(Body::empty()) + .unwrap(); + let resp = send(r, req).await; + assert_eq!(resp.status(), StatusCode::NOT_MODIFIED); + } + + #[tokio::test] + async fn if_none_match_with_different_etag_returns_full_body() { + let n = note("a.md", "body"); + let (r, _) = router_with(vec![n]); + let req = Request::get("/notes/a.md") + .header(IF_NONE_MATCH, "\"other\"") + .body(Body::empty()) + .unwrap(); + let resp = send(r, req).await; + assert_eq!(resp.status(), StatusCode::OK); + } + + #[test] + fn resolve_path_normalizations() { + assert_eq!(resolve_path("Notes/a.md").unwrap(), "Notes/a.md"); + assert_eq!(resolve_path("/Notes/a.md").unwrap(), "Notes/a.md"); + assert_eq!(resolve_path("////Notes/a.md").unwrap(), "Notes/a.md"); + assert_eq!(resolve_path("Notes/a%20b.md").unwrap(), "Notes/a b.md"); + assert!(resolve_path("..").is_err()); + assert!(resolve_path("a/../b").is_err()); + assert!(resolve_path("a//b").is_err()); + assert!(resolve_path("a/./b").is_err()); + assert!(resolve_path("").is_err()); + assert!(resolve_path("a/%ZZ.md").is_err()); + } +} diff --git a/src/index.rs b/src/index.rs index ed91ac1..2006f73 100644 --- a/src/index.rs +++ b/src/index.rs @@ -14,11 +14,11 @@ //! driven from the filesystem watcher in [ANW-16]. use anyhow::{Context, Result}; -use serde_json::{Map as JsonMap, Value as JsonValue, json}; +use serde_json::json; use tantivy::schema::{Field, IndexRecordOption, Schema, TextFieldIndexing, TextOptions}; use tantivy::{Index, IndexWriter, TantivyDocument, Term}; -use crate::vault::{Note, Value}; +use crate::vault::{Note, frontmatter_to_json}; /// 50 MB heap for the writer. Tantivy's documented minimum is 15 MB; this /// sits comfortably above that for low-thousands-of-notes vaults without @@ -181,37 +181,6 @@ impl NoteIndex { } } -/// Convert a typed [`Value`] tree to `serde_json::Value`. Typed dates and -/// datetimes are emitted as their ISO-8601 / RFC 3339 string forms so they -/// sort correctly under range queries with the `raw` tokenizer. -fn value_to_json(v: &Value) -> JsonValue { - match v { - Value::Null => JsonValue::Null, - Value::Bool(b) => JsonValue::Bool(*b), - Value::Int(i) => json!(i), - Value::Float(f) => json!(f), - Value::String(s) => JsonValue::String(s.clone()), - Value::Date(d) => JsonValue::String(d.format("%Y-%m-%d").to_string()), - Value::DateTime(dt) => JsonValue::String(dt.to_rfc3339()), - Value::Sequence(seq) => JsonValue::Array(seq.iter().map(value_to_json).collect()), - Value::Mapping(m) => { - let mut map = JsonMap::new(); - for (k, v) in m { - map.insert(k.clone(), value_to_json(v)); - } - JsonValue::Object(map) - } - } -} - -fn frontmatter_to_json(fm: &crate::vault::Frontmatter) -> JsonValue { - let mut map = JsonMap::new(); - for (k, v) in fm { - map.insert(k.clone(), value_to_json(v)); - } - JsonValue::Object(map) -} - #[cfg(test)] mod tests { use super::*; @@ -220,6 +189,8 @@ mod tests { use tantivy::collector::TopDocs; use tantivy::query::TermQuery; + use crate::vault::Value; + fn sample_note(path: &str, tag: &str) -> Note { let mut fm: BTreeMap = BTreeMap::new(); fm.insert( @@ -295,31 +266,4 @@ mod tests { idx.delete("never-indexed.md").unwrap(); assert_eq!(idx.document_count().unwrap(), 1); } - - #[test] - fn value_to_json_coerces_dates_to_iso_strings() { - let d = chrono::NaiveDate::from_ymd_opt(2026, 5, 14).unwrap(); - assert_eq!( - value_to_json(&Value::Date(d)), - JsonValue::String("2026-05-14".into()) - ); - - let dt = chrono::DateTime::parse_from_rfc3339("2026-05-14T10:14:22Z").unwrap(); - assert_eq!( - value_to_json(&Value::DateTime(dt)), - JsonValue::String("2026-05-14T10:14:22+00:00".into()) - ); - } - - #[test] - fn value_to_json_handles_nested_structures() { - let mut inner: BTreeMap = BTreeMap::new(); - inner.insert("name".into(), Value::String("brn".into())); - let v = Value::Mapping(inner); - let j = value_to_json(&v); - let JsonValue::Object(obj) = j else { - panic!("expected object"); - }; - assert_eq!(obj.get("name"), Some(&JsonValue::String("brn".into()))); - } } diff --git a/src/lib.rs b/src/lib.rs index 7aaa5f2..9c3aa09 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,8 @@ //! supervisor tree, and the HTTP surface as those issues land. pub mod app; +pub mod http; pub mod index; +pub mod store; pub mod vault; pub mod watcher; diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..4f353d8 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,155 @@ +//! In-memory note store. Holds the authoritative `vault::Note` record set +//! that the scanner produces and the watcher maintains; consumed by the +//! HTTP layer ([ANW-13] / [ANW-14] / [ANW-15]) for read-one and listing +//! responses. +//! +//! Per [[ADR-002 Tantivy as Frontmatter Index]] / [ANW-11], the daemon +//! rereads from disk only on a watcher event for that path. The store is +//! that in-memory cache; the index in [`crate::index`] sits beside it, +//! shaped for frontmatter queries rather than full-record retrieval. + +use std::collections::BTreeMap; +use std::sync::{Arc, RwLock}; + +use crate::vault::Note; + +/// Shared, thread-safe handle to the note set keyed by vault-relative path. +#[derive(Debug, Default)] +pub struct NoteStore { + inner: RwLock>, +} + +impl NoteStore { + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Replace the entire record set. Used at startup and after overflow + /// recovery. + /// + /// # Panics + /// Panics if the inner `RwLock` has been poisoned by a panic in a writer. + /// Anwesen does not call user code under the lock, so this is unreachable + /// in practice. + pub fn replace(&self, notes: Vec) { + let mut guard = self.inner.write().expect("note_store: write lock poisoned"); + guard.clear(); + for note in notes { + guard.insert(note.path.clone(), note); + } + } + + /// Apply one debounce-window's worth of changes. Mirrors the order in + /// [`crate::index::NoteIndex::apply_batch`]: deletes first, then upserts. + /// + /// # Panics + /// Panics if the inner `RwLock` has been poisoned. + pub fn apply_batch(&self, upserts: Vec, deletes: &[String]) { + let mut guard = self.inner.write().expect("note_store: write lock poisoned"); + for path in deletes { + guard.remove(path); + } + for note in upserts { + guard.insert(note.path.clone(), note); + } + } + + /// # Panics + /// Panics if the inner `RwLock` has been poisoned. + pub fn upsert(&self, note: Note) { + let mut guard = self.inner.write().expect("note_store: write lock poisoned"); + guard.insert(note.path.clone(), note); + } + + /// # Panics + /// Panics if the inner `RwLock` has been poisoned. + pub fn delete(&self, path: &str) { + let mut guard = self.inner.write().expect("note_store: write lock poisoned"); + guard.remove(path); + } + + /// Clone a single record by path, or `None` if absent. The read lock is + /// held only for the lookup; the returned `Note` is independent. + /// + /// # Panics + /// Panics if the inner `RwLock` has been poisoned. + #[must_use] + pub fn get(&self, path: &str) -> Option { + let guard = self.inner.read().expect("note_store: read lock poisoned"); + guard.get(path).cloned() + } + + /// # Panics + /// Panics if the inner `RwLock` has been poisoned. + #[must_use] + pub fn len(&self) -> usize { + self.inner + .read() + .expect("note_store: read lock poisoned") + .len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::DateTime; + use std::collections::BTreeMap as Bm; + + fn note(path: &str) -> Note { + Note { + path: path.into(), + frontmatter: Bm::new(), + body: String::new(), + raw_bytes: b"hi".to_vec(), + last_modified: DateTime::from_timestamp(0, 0).unwrap(), + etag: "\"abc\"".into(), + size: 2, + } + } + + #[test] + fn replace_clears_previous() { + let s = NoteStore::new(); + s.replace(vec![note("a.md"), note("b.md")]); + assert_eq!(s.len(), 2); + s.replace(vec![note("c.md")]); + assert_eq!(s.len(), 1); + assert!(s.get("a.md").is_none()); + assert!(s.get("c.md").is_some()); + } + + #[test] + fn apply_batch_deletes_then_upserts() { + let s = NoteStore::new(); + s.replace(vec![note("a.md"), note("b.md")]); + s.apply_batch(vec![note("c.md")], &["a.md".to_string()]); + assert_eq!(s.len(), 2); + assert!(s.get("a.md").is_none()); + assert!(s.get("b.md").is_some()); + assert!(s.get("c.md").is_some()); + } + + #[test] + fn upsert_replaces_by_path() { + let s = NoteStore::new(); + s.upsert(note("a.md")); + let mut n2 = note("a.md"); + n2.etag = "\"def\"".into(); + s.upsert(n2); + assert_eq!(s.get("a.md").unwrap().etag, "\"def\""); + assert_eq!(s.len(), 1); + } + + #[test] + fn get_unknown_path_is_none() { + let s = NoteStore::new(); + assert!(s.get("nope.md").is_none()); + } +} diff --git a/src/vault.rs b/src/vault.rs index dcf916a..3d0a8f5 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -20,6 +20,7 @@ use std::path::{Path, PathBuf}; use chrono::{DateTime, FixedOffset, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; +use serde_json::{Map as JsonMap, Value as JsonValue, json}; use thiserror::Error; use walkdir::WalkDir; @@ -45,6 +46,11 @@ pub type Frontmatter = BTreeMap; /// Frontmatter value with the type-coercion contract from /// [[ADR-005 Frontmatter Contract Type Coercion and Cross-Note Shapes]] applied. +/// +/// The derived `Serialize` / `Deserialize` is used for Hydra messaging +/// (binary-format internal transport). HTTP responses must emit the flat, +/// YAML-natural shape promised by the User Manual; call [`Value::to_json`] +/// for that. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum Value { Null, @@ -58,6 +64,45 @@ pub enum Value { Mapping(BTreeMap), } +impl Value { + /// Convert to a plain [`serde_json::Value`] tree -- the form the User + /// Manual promises HTTP consumers (e.g. `"tags": ["a", "b"]` rather than + /// `{"Sequence": [{"String": "a"}, ...]}`). Typed dates and datetimes + /// emit as their ISO-8601 / RFC 3339 string forms so they sort correctly + /// under range queries against the index. + #[must_use] + pub fn to_json(&self) -> JsonValue { + match self { + Value::Null => JsonValue::Null, + Value::Bool(b) => JsonValue::Bool(*b), + Value::Int(i) => json!(i), + Value::Float(f) => json!(f), + Value::String(s) => JsonValue::String(s.clone()), + Value::Date(d) => JsonValue::String(d.format("%Y-%m-%d").to_string()), + Value::DateTime(dt) => JsonValue::String(dt.to_rfc3339()), + Value::Sequence(seq) => JsonValue::Array(seq.iter().map(Value::to_json).collect()), + Value::Mapping(m) => { + let mut map = JsonMap::new(); + for (k, v) in m { + map.insert(k.clone(), v.to_json()); + } + JsonValue::Object(map) + } + } + } +} + +/// Convert a whole [`Frontmatter`] tree to a [`serde_json::Value`] object +/// suitable for HTTP responses or for Tantivy ingestion. +#[must_use] +pub fn frontmatter_to_json(fm: &Frontmatter) -> JsonValue { + let mut map = JsonMap::new(); + for (k, v) in fm { + map.insert(k.clone(), v.to_json()); + } + JsonValue::Object(map) +} + #[derive(Debug)] pub struct ScanResult { pub notes: Vec, @@ -427,4 +472,40 @@ mod tests { assert!(yaml.is_empty()); assert!(body.starts_with("---\n")); } + + #[test] + fn value_to_json_coerces_dates_to_iso_strings() { + let d = NaiveDate::from_ymd_opt(2026, 5, 14).unwrap(); + assert_eq!( + Value::Date(d).to_json(), + JsonValue::String("2026-05-14".into()) + ); + + let dt = DateTime::parse_from_rfc3339("2026-05-14T10:14:22Z").unwrap(); + assert_eq!( + Value::DateTime(dt).to_json(), + JsonValue::String("2026-05-14T10:14:22+00:00".into()) + ); + } + + #[test] + fn value_to_json_emits_flat_yaml_natural_shape() { + // The User Manual contract: tags: [a, b] -> JSON ["a", "b"], not the + // tagged-enum form `{"Sequence": [{"String": "a"}, ...]}`. + let v = Value::Sequence(vec![Value::String("a".into()), Value::String("b".into())]); + let j = v.to_json(); + assert_eq!(j, json!(["a", "b"])); + } + + #[test] + fn value_to_json_handles_nested_structures() { + let mut inner: BTreeMap = BTreeMap::new(); + inner.insert("name".into(), Value::String("brn".into())); + let v = Value::Mapping(inner); + let j = v.to_json(); + let JsonValue::Object(obj) = j else { + panic!("expected object"); + }; + assert_eq!(obj.get("name"), Some(&JsonValue::String("brn".into()))); + } }