From b045efd1f08354ad0c6e692eda0d30bf6c82d13f Mon Sep 17 00:00:00 2001 From: Andreas Brenner Date: Thu, 14 May 2026 16:08:13 +0200 Subject: [PATCH] ANW-15 /query endpoint + restart-replay pin + UTC canonicalization --- Cargo.lock | 1 + Cargo.toml | 1 + src/app.rs | 20 +- src/http.rs | 33 ++- src/lib.rs | 1 + src/query.rs | 719 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/vault.rs | 18 +- 7 files changed, 784 insertions(+), 9 deletions(-) create mode 100644 src/query.rs diff --git a/Cargo.lock b/Cargo.lock index 1931280..a28bdad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,7 @@ dependencies = [ "clap", "hydra", "notify", + "regex", "serde", "serde_json", "serde_yaml", diff --git a/Cargo.toml b/Cargo.toml index 568d453..ce6c31f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ chrono = { version = "0.4", default-features = false, features = ["std", "serde" clap = { version = "4", features = ["derive", "env"] } hydra = "0.1" notify = "8" +regex = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/src/app.rs b/src/app.rs index cd1d0ea..6ecaf9c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -443,7 +443,25 @@ impl GenServer for IndexWriterState { let index = NoteIndex::new() .map_err(|e| ExitReason::from(format!("index_writer: NoteIndex::new failed: {e}")))?; self.index = Some(index); - tracing::info!(restart, "index_writer: init"); + + if restart > 0 { + // Writer-only restart: a fresh empty index has come up while the + // watcher keeps streaming batches at it. Ask `vault_scanner` for + // a full walk via the same `rescan_now` path the inotify-overflow + // recovery uses. Per kaa's pin (ADR-004 amendment 2026-05-14), + // rescan upserts are idempotent on path so in-flight watcher + // batches converge with the rescan. + VaultScanner::cast( + Dest::from(VAULT_SCANNER_NAME), + VaultScannerMessage::RescanNow, + ); + tracing::info!( + restart, + "index_writer: init -- rescan_now dispatched to vault_scanner" + ); + } else { + tracing::info!(restart, "index_writer: init"); + } Ok(()) } diff --git a/src/http.rs b/src/http.rs index 0fb6ff1..80a23a1 100644 --- a/src/http.rs +++ b/src/http.rs @@ -15,18 +15,25 @@ use std::sync::Arc; use axum::Router; use axum::body::Body; -use axum::extract::{Path as AxumPath, State}; +use axum::extract::{OriginalUri, 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 chrono::{DateTime, Utc}; +use chrono::{DateTime, SecondsFormat, Utc}; use serde::Serialize; use std::collections::BTreeMap; use crate::store::NoteStore; use crate::vault::{Note, frontmatter_to_json}; +/// Canonical RFC 3339 form with a `Z` suffix -- the shape the User Manual +/// example uses for `last_modified`. Centralized here so every HTTP +/// `last_modified` field stays in the same dialect. +fn rfc3339_z(dt: DateTime) -> String { + dt.to_rfc3339_opts(SecondsFormat::Secs, true) +} + /// Shared state injected into every handler. #[derive(Clone)] pub struct HttpState { @@ -41,6 +48,7 @@ pub fn router(state: HttpState) -> Router { Router::new() .route("/notes/", get(list_root_folder)) .route("/notes/{*path}", get(get_notes)) + .route("/query", get(get_query)) .with_state(state) } @@ -60,7 +68,7 @@ impl<'a> From<&'a Note> for NoteResponse<'a> { path: ¬e.path, frontmatter: frontmatter_to_json(¬e.frontmatter), body: ¬e.body, - last_modified: note.last_modified.to_rfc3339(), + last_modified: rfc3339_z(note.last_modified), etag: ¬e.etag, size: note.size, } @@ -93,6 +101,21 @@ async fn list_root_folder(State(state): State) -> Response { list_folder(&state, "").into_response() } +async fn get_query(State(state): State, OriginalUri(uri): OriginalUri) -> Response { + let raw = uri.query().unwrap_or(""); + let parsed = match crate::query::parse(raw) { + Ok(p) => p, + Err(e) => return bad_request(&e.to_string()).into_response(), + }; + let resp = crate::query::execute(&state.store, &parsed, rfc3339_z); + let bytes = serde_json::to_vec(&resp).expect("query response serializes"); + Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(bytes)) + .expect("static 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) @@ -225,7 +248,7 @@ fn list_folder(state: &HttpState, folder: &str) -> Response { entries.push(FolderEntry { name, kind: "file", - last_modified: lm.to_rfc3339(), + last_modified: rfc3339_z(lm), size: Some(size), }); } @@ -233,7 +256,7 @@ fn list_folder(state: &HttpState, folder: &str) -> Response { entries.push(FolderEntry { name, kind: "dir", - last_modified: lm.to_rfc3339(), + last_modified: rfc3339_z(lm), size: None, }); } diff --git a/src/lib.rs b/src/lib.rs index 9c3aa09..9b992ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod app; pub mod http; pub mod index; +pub mod query; pub mod store; pub mod vault; pub mod watcher; diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 0000000..77dbdb3 --- /dev/null +++ b/src/query.rs @@ -0,0 +1,719 @@ +//! `/query` parsing + execution for [ANW-15]. +//! +//! The User Manual exposes: +//! +//! - field predicates with operator suffixes (`__in`, `__all`, `__not`, +//! `__exists`, `__regex`, `__prefix`, `__gt`/`__gte`/`__lt`/`__lte`); +//! - control parameters under the `__anw-` namespace +//! (`__anw-recursive`, `__anw-path`, `__anw-limit`). +//! +//! Different field predicates AND together; multiple values under +//! `__in` / `__all` are comma-separated; unknown operators are `400`. +//! +//! v1 evaluates predicates by iterating the in-memory [`NoteStore`] and +//! applying each [`Predicate::matches`] in turn. The Tantivy index from +//! [[ADR-002 Tantivy as Frontmatter Index]] is still built and maintained, +//! but query-time filtering doesn't use it yet. At the documented scale +//! (low-thousands-of-notes vaults) this is sub-millisecond; migration to a +//! Tantivy-driven candidate set is a future optimization once a real +//! consumer pushes throughput. + +use chrono::{DateTime, NaiveDate}; +use regex::Regex; +use serde::Serialize; +use serde_json::Value as JsonValue; + +use crate::store::NoteStore; +use crate::vault::{Frontmatter, Note, Value, frontmatter_to_json}; + +#[derive(Debug, PartialEq, Eq)] +pub enum QueryError { + UnknownOperator(String), + BadValue(String), + BadControl(String), +} + +impl std::fmt::Display for QueryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnknownOperator(s) => write!(f, "unknown operator: {s}"), + Self::BadValue(s) => write!(f, "bad value: {s}"), + Self::BadControl(s) => write!(f, "bad control parameter: {s}"), + } + } +} + +impl std::error::Error for QueryError {} + +/// Operator suffix attached to a field predicate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Operator { + /// Exact equality; "array contains" if the frontmatter field is a list. + Eq, + /// `__in` -- one-of (any listed value matches). + In, + /// `__all` -- array contains every listed value. + All, + /// `__not` -- negate exact equality. + Not, + /// `__exists` -- presence (`true`) or absence (`false`) of the key. + Exists, + /// `__regex` -- regex over a scalar value. + Regex, + /// `__prefix` -- string prefix. + Prefix, + Gt, + Gte, + Lt, + Lte, +} + +#[derive(Debug, Clone)] +pub struct Predicate { + pub field: String, + pub op: Operator, + pub value: String, +} + +#[derive(Debug, Default, Clone)] +pub struct ParsedQuery { + pub predicates: Vec, + /// `true` by default; `__anw-recursive=false` limits results to direct + /// children of `__anw-path`. + pub recursive: bool, + pub path_prefix: Option, + pub limit: Option, +} + +impl ParsedQuery { + fn new() -> Self { + Self { + predicates: Vec::new(), + recursive: true, + path_prefix: None, + limit: None, + } + } +} + +/// Parse the raw query string (`a=b&c__in=x,y&__anw-limit=5`) into a +/// [`ParsedQuery`]. Returns `QueryError` for unknown operators, malformed +/// control parameters, or unparseable bool/int values. +/// +/// # Errors +/// - [`QueryError::UnknownOperator`] for unrecognized `__` suffixes; +/// - [`QueryError::BadControl`] for unrecognized or unparseable +/// `__anw-...` parameters; +/// - [`QueryError::BadValue`] for percent-encoding that fails to decode. +pub fn parse(raw: &str) -> Result { + let mut q = ParsedQuery::new(); + if raw.is_empty() { + return Ok(q); + } + for pair in raw.split('&') { + if pair.is_empty() { + continue; + } + let (k, v) = match pair.split_once('=') { + Some((k, v)) => (k, v), + None => (pair, ""), + }; + let key = percent_decode(k).ok_or_else(|| QueryError::BadValue(k.to_string()))?; + let value = percent_decode(v).ok_or_else(|| QueryError::BadValue(v.to_string()))?; + + if let Some(name) = key.strip_prefix("__anw-") { + apply_control(&mut q, name, &value)?; + continue; + } + + let (field, op) = split_operator(&key)?; + q.predicates.push(Predicate { + field: field.to_string(), + op, + value, + }); + } + Ok(q) +} + +fn split_operator(key: &str) -> Result<(&str, Operator), QueryError> { + if let Some(idx) = key.rfind("__") { + let suffix = &key[idx + 2..]; + let op = match suffix { + "in" => Operator::In, + "all" => Operator::All, + "not" => Operator::Not, + "exists" => Operator::Exists, + "regex" => Operator::Regex, + "prefix" => Operator::Prefix, + "gt" => Operator::Gt, + "gte" => Operator::Gte, + "lt" => Operator::Lt, + "lte" => Operator::Lte, + // Reject unknown suffixes (e.g. `__contians`) rather than + // silently treating them as part of the field name -- the User + // Manual mandates 400 here. + unknown => return Err(QueryError::UnknownOperator(unknown.to_string())), + }; + Ok((&key[..idx], op)) + } else { + Ok((key, Operator::Eq)) + } +} + +fn apply_control(q: &mut ParsedQuery, name: &str, value: &str) -> Result<(), QueryError> { + match name { + "recursive" => { + q.recursive = value + .parse::() + .map_err(|_| QueryError::BadControl(format!("recursive={value}")))?; + } + "path" => { + q.path_prefix = Some(value.trim_start_matches('/').to_string()); + } + "limit" => { + let n: usize = value + .parse() + .map_err(|_| QueryError::BadControl(format!("limit={value}")))?; + q.limit = Some(n); + } + unknown => return Err(QueryError::BadControl(unknown.to_string())), + } + Ok(()) +} + +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() { + match bytes[i] { + b'+' => { + out.push(b' '); + i += 1; + } + 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; + } + c => { + out.push(c); + 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, + } +} + +/// One result row in the `/query` response. Per User Manual the body is +/// elided -- consumers fetch bodies via `/notes/` if needed. +#[derive(Debug, Serialize)] +pub struct ResultEntry { + pub path: String, + pub frontmatter: JsonValue, + pub last_modified: String, + pub etag: String, + pub size: u64, +} + +#[derive(Debug, Serialize)] +pub struct QueryResponse { + pub results: Vec, + pub total: usize, + pub truncated: bool, +} + +/// Run a parsed query against the in-memory note set. +pub fn execute(store: &NoteStore, query: &ParsedQuery, format_ts: F) -> QueryResponse +where + F: Fn(chrono::DateTime) -> String, +{ + let prefix = query.path_prefix.as_deref().unwrap_or(""); + let matches = store.with_read(|notes| { + let mut out = Vec::new(); + for (path, note) in notes { + if !path_matches(path, prefix, query.recursive) { + continue; + } + if !predicates_match(&query.predicates, ¬e.frontmatter) { + continue; + } + out.push(note.clone()); + } + out + }); + let total = matches.len(); + let (results, truncated): (Vec, bool) = match query.limit { + Some(limit) if matches.len() > limit => (matches.into_iter().take(limit).collect(), true), + _ => (matches, false), + }; + let entries = results + .into_iter() + .map(|n| ResultEntry { + path: n.path, + frontmatter: frontmatter_to_json(&n.frontmatter), + last_modified: format_ts(n.last_modified), + etag: n.etag, + size: n.size, + }) + .collect(); + QueryResponse { + results: entries, + total, + truncated, + } +} + +fn path_matches(path: &str, prefix: &str, recursive: bool) -> bool { + if prefix.is_empty() { + return recursive || !path.contains('/'); + } + let scoped = match path.strip_prefix(prefix) { + Some(rest) => rest.strip_prefix('/').unwrap_or(rest), + None => return false, + }; + if scoped.is_empty() { + // The prefix itself names a note; consider it in-scope. + return true; + } + if recursive { + true + } else { + !scoped.contains('/') + } +} + +fn predicates_match(predicates: &[Predicate], fm: &Frontmatter) -> bool { + predicates.iter().all(|p| predicate_match(p, fm)) +} + +fn predicate_match(p: &Predicate, fm: &Frontmatter) -> bool { + let target = lookup_dotted(fm, &p.field); + match p.op { + Operator::Exists => { + let want = p.value == "true"; + target.is_some() == want + } + Operator::Eq => target.is_some_and(|v| matches_eq(v, &p.value)), + Operator::Not => target.is_none_or(|v| !matches_eq(v, &p.value)), + Operator::In => { + let needles = comma_list(&p.value); + target.is_some_and(|v| needles.iter().any(|n| matches_eq(v, n))) + } + Operator::All => { + let needles = comma_list(&p.value); + // __all requires the value to be a list containing every needle. + let Some(Value::Sequence(seq)) = target else { + return false; + }; + needles + .iter() + .all(|n| seq.iter().any(|item| matches_scalar_eq(item, n))) + } + Operator::Regex => match target { + Some(Value::String(s)) => Regex::new(&p.value).is_ok_and(|re| re.is_match(s)), + _ => false, + }, + Operator::Prefix => match target { + Some(Value::String(s)) => s.starts_with(&p.value), + _ => false, + }, + Operator::Gt | Operator::Gte | Operator::Lt | Operator::Lte => { + target.is_some_and(|v| ordered_compare(v, &p.value, p.op)) + } + } +} + +fn comma_list(s: &str) -> Vec { + s.split(',').map(|t| t.trim().to_string()).collect() +} + +/// True if `v` equals `needle` -- with the User Manual's contract for +/// scalar-list distinction: +/// +/// - if `v` is a scalar, compare value-as-string; +/// - if `v` is a list, *array contains* `needle`. +/// +/// `__in` operator handles list-OR semantics separately. +fn matches_eq(v: &Value, needle: &str) -> bool { + match v { + Value::Sequence(seq) => seq.iter().any(|item| matches_scalar_eq(item, needle)), + scalar => matches_scalar_eq(scalar, needle), + } +} + +fn matches_scalar_eq(v: &Value, needle: &str) -> bool { + match v { + Value::Null => needle == "null", + Value::Bool(b) => needle == b.to_string(), + Value::Int(i) => needle == i.to_string(), + Value::Float(f) => needle == f.to_string(), + Value::String(s) => s == needle, + Value::Date(d) => d.format("%Y-%m-%d").to_string() == needle, + Value::DateTime(dt) => { + // Canonical form per the index contract. + use chrono::SecondsFormat; + let canon = dt + .with_timezone(&chrono::Utc) + .to_rfc3339_opts(SecondsFormat::Secs, true); + canon == needle + } + Value::Sequence(_) | Value::Mapping(_) => false, + } +} + +fn ordered_compare(v: &Value, needle: &str, op: Operator) -> bool { + // Try date -> datetime -> int -> float -> string in turn. Stop at the + // first parser that matches both sides. + if let (Some(a), Some(b)) = ( + try_as_date(v), + NaiveDate::parse_from_str(needle, "%Y-%m-%d").ok(), + ) { + return ord(a.cmp(&b), op); + } + if let (Some(a), Some(b)) = ( + try_as_datetime(v), + DateTime::parse_from_rfc3339(needle).ok(), + ) { + return ord(a.cmp(&b.with_timezone(&chrono::Utc)), op); + } + if let (Some(a), Some(b)) = (try_as_int(v), needle.parse::().ok()) { + return ord(a.cmp(&b), op); + } + if let (Some(a), Some(b)) = (try_as_float(v), needle.parse::().ok()) { + return ord(a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal), op); + } + if let Some(a) = try_as_string(v) { + return ord(a.as_str().cmp(needle), op); + } + false +} + +fn ord(c: std::cmp::Ordering, op: Operator) -> bool { + use std::cmp::Ordering; + match op { + Operator::Gt => c == Ordering::Greater, + Operator::Gte => c != Ordering::Less, + Operator::Lt => c == Ordering::Less, + Operator::Lte => c != Ordering::Greater, + _ => false, + } +} + +fn try_as_date(v: &Value) -> Option { + match v { + Value::Date(d) => Some(*d), + _ => None, + } +} + +fn try_as_datetime(v: &Value) -> Option> { + match v { + Value::DateTime(dt) => Some(dt.with_timezone(&chrono::Utc)), + _ => None, + } +} + +fn try_as_int(v: &Value) -> Option { + match v { + Value::Int(i) => Some(*i), + _ => None, + } +} + +fn try_as_float(v: &Value) -> Option { + match v { + Value::Float(f) => Some(*f), + // i64 -> f64 loses precision above 2^53 -- the User Manual's + // numeric operators are documented for "typed dates and integers", + // and a frontmatter integer wider than 2^53 is not a v1 concern. + #[allow(clippy::cast_precision_loss)] + Value::Int(i) => Some(*i as f64), + _ => None, + } +} + +fn try_as_string(v: &Value) -> Option { + match v { + Value::String(s) => Some(s.clone()), + _ => None, + } +} + +/// Traverse a dotted key path (`author.name`) into the frontmatter tree. +/// Returns `None` if any intermediate key is missing or addresses a non-map. +fn lookup_dotted<'a>(fm: &'a Frontmatter, path: &str) -> Option<&'a Value> { + let mut parts = path.split('.'); + let head = parts.next()?; + let mut current: &Value = fm.get(head)?; + for part in parts { + let Value::Mapping(map) = current else { + return None; + }; + current = map.get(part)?; + } + Some(current) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use std::collections::BTreeMap; + + fn note(path: &str, fm: Frontmatter) -> Note { + Note { + path: path.into(), + frontmatter: fm, + body: String::new(), + raw_bytes: Vec::new(), + last_modified: Utc.with_ymd_and_hms(2026, 5, 14, 12, 0, 0).unwrap(), + etag: "\"x\"".into(), + size: 0, + } + } + + fn fm(pairs: &[(&str, Value)]) -> Frontmatter { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect() + } + + fn store_with(notes: Vec) -> std::sync::Arc { + let s = NoteStore::new(); + s.replace(notes); + s + } + + fn run(q: &ParsedQuery, s: &NoteStore) -> QueryResponse { + execute(s, q, |_dt| String::new()) + } + + #[test] + fn unknown_operator_returns_error() { + let err = parse("title__contians=foo").unwrap_err(); + assert!(matches!(err, QueryError::UnknownOperator(_))); + } + + #[test] + fn empty_query_string_returns_default_parse() { + let q = parse("").unwrap(); + assert!(q.predicates.is_empty()); + assert!(q.recursive); + assert!(q.path_prefix.is_none()); + assert!(q.limit.is_none()); + } + + #[test] + fn eq_on_scalar_and_list() { + let q = parse("tags=python").unwrap(); + let s = store_with(vec![ + note("a.md", fm(&[("tags", Value::String("python".into()))])), + note( + "b.md", + fm(&[( + "tags", + Value::Sequence(vec![ + Value::String("python".into()), + Value::String("go".into()), + ]), + )]), + ), + note("c.md", fm(&[("tags", Value::String("go".into()))])), + ]); + let r = run(&q, &s); + assert_eq!(r.total, 2); + let paths: Vec<&str> = r.results.iter().map(|x| x.path.as_str()).collect(); + assert_eq!(paths, vec!["a.md", "b.md"]); + } + + #[test] + fn in_operator_matches_any() { + let q = parse("tags__in=python,go").unwrap(); + let s = store_with(vec![ + note("a.md", fm(&[("tags", Value::String("python".into()))])), + note("b.md", fm(&[("tags", Value::String("rust".into()))])), + note("c.md", fm(&[("tags", Value::String("go".into()))])), + ]); + let r = run(&q, &s); + assert_eq!(r.total, 2); + } + + #[test] + fn all_operator_requires_every_value() { + let q = parse("tags__all=python,fastapi").unwrap(); + let s = store_with(vec![ + note( + "ok.md", + fm(&[( + "tags", + Value::Sequence(vec![ + Value::String("python".into()), + Value::String("fastapi".into()), + Value::String("web".into()), + ]), + )]), + ), + note( + "miss.md", + fm(&[( + "tags", + Value::Sequence(vec![Value::String("python".into())]), + )]), + ), + ]); + let r = run(&q, &s); + assert_eq!(r.total, 1); + assert_eq!(r.results[0].path, "ok.md"); + } + + #[test] + fn not_operator_excludes_match() { + let q = parse("status__not=draft").unwrap(); + let s = store_with(vec![ + note("d.md", fm(&[("status", Value::String("draft".into()))])), + note("p.md", fm(&[("status", Value::String("published".into()))])), + note("u.md", fm(&[])), + ]); + let r = run(&q, &s); + let paths: Vec<&str> = r.results.iter().map(|x| x.path.as_str()).collect(); + assert_eq!(paths, vec!["p.md", "u.md"]); // missing field is "not draft" + } + + #[test] + fn exists_operator_true_and_false() { + let q_true = parse("deprecated__exists=true").unwrap(); + let q_false = parse("deprecated__exists=false").unwrap(); + let s = store_with(vec![ + note("a.md", fm(&[("deprecated", Value::Bool(true))])), + note("b.md", fm(&[])), + ]); + assert_eq!(run(&q_true, &s).total, 1); + assert_eq!(run(&q_false, &s).total, 1); + } + + #[test] + fn regex_operator_on_scalar() { + let q = parse("title__regex=%5EPDR-%5Cd%2B").unwrap(); + let s = store_with(vec![ + note("a.md", fm(&[("title", Value::String("PDR-007".into()))])), + note("b.md", fm(&[("title", Value::String("ADR-001".into()))])), + ]); + assert_eq!(run(&q, &s).total, 1); + } + + #[test] + fn prefix_operator_on_scalar() { + let q = parse("title__prefix=PDR-").unwrap(); + let s = store_with(vec![ + note("a.md", fm(&[("title", Value::String("PDR-007".into()))])), + note("b.md", fm(&[("title", Value::String("ADR-001".into()))])), + ]); + assert_eq!(run(&q, &s).total, 1); + } + + #[test] + fn range_operators_on_dates() { + let q = parse("date__gte=2026-05-01").unwrap(); + let s = store_with(vec![ + note( + "old.md", + fm(&[( + "date", + Value::Date(NaiveDate::from_ymd_opt(2026, 4, 1).unwrap()), + )]), + ), + note( + "new.md", + fm(&[( + "date", + Value::Date(NaiveDate::from_ymd_opt(2026, 6, 1).unwrap()), + )]), + ), + ]); + let r = run(&q, &s); + assert_eq!(r.total, 1); + assert_eq!(r.results[0].path, "new.md"); + } + + #[test] + fn control_param_limit_truncates_and_sets_total() { + let q = parse("__anw-limit=2").unwrap(); + let s = store_with(vec![ + note("a.md", fm(&[])), + note("b.md", fm(&[])), + note("c.md", fm(&[])), + note("d.md", fm(&[])), + ]); + let r = run(&q, &s); + assert_eq!(r.total, 4); + assert_eq!(r.results.len(), 2); + assert!(r.truncated); + } + + #[test] + fn control_param_path_filters_prefix() { + let q = parse("__anw-path=Projects/anwesen").unwrap(); + let s = store_with(vec![ + note("Projects/anwesen/x.md", fm(&[])), + note("Projects/other/y.md", fm(&[])), + note("Top.md", fm(&[])), + ]); + let r = run(&q, &s); + assert_eq!(r.total, 1); + assert_eq!(r.results[0].path, "Projects/anwesen/x.md"); + } + + #[test] + fn control_param_recursive_false_limits_to_direct_children() { + let q = parse("__anw-path=Projects&__anw-recursive=false").unwrap(); + let s = store_with(vec![ + note("Projects/a.md", fm(&[])), + note("Projects/sub/b.md", fm(&[])), + ]); + let r = run(&q, &s); + assert_eq!(r.total, 1); + assert_eq!(r.results[0].path, "Projects/a.md"); + } + + #[test] + fn nested_dotted_keys() { + let mut author: BTreeMap = BTreeMap::new(); + author.insert("name".into(), Value::String("brn".into())); + let q = parse("author.name=brn").unwrap(); + let s = store_with(vec![ + note("a.md", fm(&[("author", Value::Mapping(author))])), + note("b.md", fm(&[("author", Value::String("someone".into()))])), + ]); + assert_eq!(run(&q, &s).total, 1); + } + + #[test] + fn comma_escape_via_percent_encoding() { + // Escaping a literal comma -- ensures __in doesn't split inside an + // intentional value. + let q = parse("kind__in=a%2Cb,c").unwrap(); + assert_eq!(q.predicates[0].value, "a,b,c"); + } + + #[test] + fn unknown_control_param_returns_error() { + let err = parse("__anw-flarble=1").unwrap_err(); + assert!(matches!(err, QueryError::BadControl(_))); + } +} diff --git a/src/vault.rs b/src/vault.rs index 3d0a8f5..ab9e871 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -18,7 +18,7 @@ use std::collections::BTreeMap; use std::io; use std::path::{Path, PathBuf}; -use chrono::{DateTime, FixedOffset, NaiveDate, Utc}; +use chrono::{DateTime, FixedOffset, NaiveDate, SecondsFormat, Utc}; use serde::{Deserialize, Serialize}; use serde_json::{Map as JsonMap, Value as JsonValue, json}; use thiserror::Error; @@ -79,7 +79,13 @@ impl Value { 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()), + // Canonicalize to UTC with a `Z` suffix so two notes with the + // same instant but different source offsets serialize identically + // and compare equal under range queries on the index. + Value::DateTime(dt) => JsonValue::String( + dt.with_timezone(&Utc) + .to_rfc3339_opts(SecondsFormat::Secs, true), + ), Value::Sequence(seq) => JsonValue::Array(seq.iter().map(Value::to_json).collect()), Value::Mapping(m) => { let mut map = JsonMap::new(); @@ -484,7 +490,13 @@ mod tests { 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()) + JsonValue::String("2026-05-14T10:14:22Z".into()) + ); + // Different source offset, same instant -> same canonical form. + let dt2 = DateTime::parse_from_rfc3339("2026-05-14T12:14:22+02:00").unwrap(); + assert_eq!( + Value::DateTime(dt).to_json(), + Value::DateTime(dt2).to_json() ); }