ANW-26 HTTP /query markdown-merge mode
Accept: text/markdown on /query concatenates matched note bodies into one markdown document, each fragment preceded by an HTML-comment source marker. Adds __anw-order (frontmatter-key ordering, asc/desc, missing-key-last) and __anw-kind (homogeneity guard, 400 on a mixed merge). Both evaluate over the full matched set before __anw-limit truncates. Merge mode uses its own body-cloning projection; the JSON path is unchanged (no body clone).
This commit is contained in:
parent
0c461be698
commit
03cc12e640
6 changed files with 529 additions and 1 deletions
98
src/http.rs
98
src/http.rs
|
|
@ -168,12 +168,30 @@ async fn get_health(State(state): State<HttpState>) -> Response {
|
||||||
.expect("static response")
|
.expect("static response")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_query(State(state): State<HttpState>, OriginalUri(uri): OriginalUri) -> Response {
|
async fn get_query(
|
||||||
|
State(state): State<HttpState>,
|
||||||
|
OriginalUri(uri): OriginalUri,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Response {
|
||||||
let raw = uri.query().unwrap_or("");
|
let raw = uri.query().unwrap_or("");
|
||||||
let parsed = match crate::query::parse(raw) {
|
let parsed = match crate::query::parse(raw) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => return bad_request(&e.to_string()).into_response(),
|
Err(e) => return bad_request(&e.to_string()).into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `Accept: text/markdown` selects merge mode: concatenated note bodies
|
||||||
|
// ([ANW-26]). Without it, `/query` returns the JSON list as before.
|
||||||
|
if wants_markdown(&headers) {
|
||||||
|
return match crate::query::execute_merge(&state.store, &parsed) {
|
||||||
|
Ok(body) => Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(CONTENT_TYPE, "text/markdown; charset=utf-8")
|
||||||
|
.body(Body::from(body))
|
||||||
|
.expect("static response"),
|
||||||
|
Err(crate::query::MergeError::KindGuard(msg)) => bad_request(&msg).into_response(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let resp = crate::query::execute(&state.store, &parsed, rfc3339_z);
|
let resp = crate::query::execute(&state.store, &parsed, rfc3339_z);
|
||||||
let bytes = serde_json::to_vec(&resp).expect("query response serializes");
|
let bytes = serde_json::to_vec(&resp).expect("query response serializes");
|
||||||
Response::builder()
|
Response::builder()
|
||||||
|
|
@ -648,6 +666,84 @@ mod tests {
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Note builder with caller-chosen frontmatter and body, for the
|
||||||
|
/// [ANW-26] merge-mode tests.
|
||||||
|
fn note_fm(path: &str, fm: BTreeMap<String, Value>, body: &str) -> Note {
|
||||||
|
let raw = format!("---\n# fm\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 kind_fm(kind: &str) -> BTreeMap<String, Value> {
|
||||||
|
let mut fm = BTreeMap::new();
|
||||||
|
fm.insert("kind".into(), Value::String(kind.into()));
|
||||||
|
fm
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn query_accept_markdown_merges_bodies() {
|
||||||
|
let (r, _) = router_with(vec![note("a.md", "Body A."), note("b.md", "Body B.")]);
|
||||||
|
let req = Request::get("/query")
|
||||||
|
.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();
|
||||||
|
let text = String::from_utf8(bytes.to_vec()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
text,
|
||||||
|
"<!-- source: a.md -->\nBody A.\n\n<!-- source: b.md -->\nBody B."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn query_without_accept_is_json_no_body() {
|
||||||
|
let (r, _) = router_with(vec![note("a.md", "Body A.")]);
|
||||||
|
let req = Request::get("/query").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"
|
||||||
|
);
|
||||||
|
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["total"], 1);
|
||||||
|
assert!(v["results"][0].get("body").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn query_markdown_kind_guard_returns_400() {
|
||||||
|
let (r, _) = router_with(vec![
|
||||||
|
note_fm("a.md", kind_fm("PDR"), "a"),
|
||||||
|
note_fm("b.md", kind_fm("ADR"), "b"),
|
||||||
|
]);
|
||||||
|
let req = Request::get("/query?__anw-kind=kind")
|
||||||
|
.header(ACCEPT, "text/markdown")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = send(r, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
|
||||||
|
let text = String::from_utf8(bytes.to_vec()).unwrap();
|
||||||
|
assert!(text.contains("PDR"));
|
||||||
|
assert!(text.contains("ADR"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_path_normalizations() {
|
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");
|
||||||
|
|
|
||||||
398
src/query.rs
398
src/query.rs
|
|
@ -16,6 +16,8 @@
|
||||||
//! [[ADR-009 Reverse ADR-002 In-Memory Evaluation No Tantivy]] for the
|
//! [[ADR-009 Reverse ADR-002 In-Memory Evaluation No Tantivy]] for the
|
||||||
//! call to keep evaluation in-memory rather than carrying a Tantivy index.
|
//! call to keep evaluation in-memory rather than carrying a Tantivy index.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use chrono::{DateTime, NaiveDate};
|
use chrono::{DateTime, NaiveDate};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
@ -81,6 +83,21 @@ pub struct ParsedQuery {
|
||||||
pub recursive: bool,
|
pub recursive: bool,
|
||||||
pub path_prefix: Option<String>,
|
pub path_prefix: Option<String>,
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
|
/// `__anw-order` -- fragment ordering for markdown-merge mode ([ANW-26]).
|
||||||
|
/// `None` orders by path. Meaningful only in merge mode; the JSON path
|
||||||
|
/// ignores it.
|
||||||
|
pub order: Option<OrderSpec>,
|
||||||
|
/// `__anw-kind` -- homogeneity-guard key for markdown-merge mode
|
||||||
|
/// ([ANW-26]). Meaningful only in merge mode.
|
||||||
|
pub kind_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed `__anw-order=<frontmatter-key>[:asc|:desc]`. `desc` is `false`
|
||||||
|
/// (ascending) unless the value carries an explicit `:desc` suffix.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OrderSpec {
|
||||||
|
pub key: String,
|
||||||
|
pub desc: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ParsedQuery {
|
impl ParsedQuery {
|
||||||
|
|
@ -90,6 +107,8 @@ impl ParsedQuery {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
path_prefix: None,
|
path_prefix: None,
|
||||||
limit: None,
|
limit: None,
|
||||||
|
order: None,
|
||||||
|
kind_key: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -190,11 +209,40 @@ fn apply_control(q: &mut ParsedQuery, name: &str, value: &str) -> Result<(), Que
|
||||||
.map_err(|_| QueryError::BadControl(format!("limit={value}")))?;
|
.map_err(|_| QueryError::BadControl(format!("limit={value}")))?;
|
||||||
q.limit = Some(n);
|
q.limit = Some(n);
|
||||||
}
|
}
|
||||||
|
"order" => {
|
||||||
|
q.order = Some(parse_order(value)?);
|
||||||
|
}
|
||||||
|
"kind" => {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(QueryError::BadControl("kind: empty key".to_string()));
|
||||||
|
}
|
||||||
|
q.kind_key = Some(value.to_string());
|
||||||
|
}
|
||||||
unknown => return Err(QueryError::BadControl(unknown.to_string())),
|
unknown => return Err(QueryError::BadControl(unknown.to_string())),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split `__anw-order` into its key and direction. Only the literal `:asc`
|
||||||
|
/// and `:desc` suffixes are recognized; any other value is taken whole as the
|
||||||
|
/// key. An empty key is `400`.
|
||||||
|
fn parse_order(value: &str) -> Result<OrderSpec, QueryError> {
|
||||||
|
let (key, desc) = if let Some(k) = value.strip_suffix(":desc") {
|
||||||
|
(k, true)
|
||||||
|
} else if let Some(k) = value.strip_suffix(":asc") {
|
||||||
|
(k, false)
|
||||||
|
} else {
|
||||||
|
(value, false)
|
||||||
|
};
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err(QueryError::BadControl(format!("order={value}")));
|
||||||
|
}
|
||||||
|
Ok(OrderSpec {
|
||||||
|
key: key.to_string(),
|
||||||
|
desc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn percent_decode(input: &str) -> Option<String> {
|
fn percent_decode(input: &str) -> Option<String> {
|
||||||
let bytes = input.as_bytes();
|
let bytes = input.as_bytes();
|
||||||
let mut out = Vec::with_capacity(bytes.len());
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
|
@ -291,6 +339,197 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single matched note projected for markdown-merge mode ([ANW-26]).
|
||||||
|
/// Unlike the JSON path, this deliberately clones the note `body` -- merge
|
||||||
|
/// mode materializes bodies. The order/kind keys are pulled out under the
|
||||||
|
/// read lock so the lock is not held during sort/assembly.
|
||||||
|
struct Fragment {
|
||||||
|
path: String,
|
||||||
|
body: String,
|
||||||
|
order_value: Option<Value>,
|
||||||
|
kind_value: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure modes of [`execute_merge`].
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum MergeError {
|
||||||
|
/// The `__anw-kind` homogeneity guard rejected the matched set. The
|
||||||
|
/// `String` is the ready-to-send `400` body naming the offenders.
|
||||||
|
KindGuard(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a parsed query in markdown-merge mode: concatenate the bodies of all
|
||||||
|
/// matched notes into one document, each fragment preceded by an HTML-comment
|
||||||
|
/// source marker. See [ANW-26].
|
||||||
|
///
|
||||||
|
/// `__anw-kind` and `__anw-order` both apply to the full matched set, before
|
||||||
|
/// `__anw-limit` truncates: the guard's pass/fail is independent of the cap,
|
||||||
|
/// and the cap keeps the top-N by the order key.
|
||||||
|
///
|
||||||
|
/// Returns the assembled document, or [`MergeError::KindGuard`] when the
|
||||||
|
/// homogeneity guard fails. An empty match set yields an empty document.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`MergeError::KindGuard`] when `__anw-kind` is set and the matched notes
|
||||||
|
/// do not all carry one and the same value for that key.
|
||||||
|
pub fn execute_merge(store: &NoteStore, query: &ParsedQuery) -> Result<String, MergeError> {
|
||||||
|
let prefix = query.path_prefix.as_deref().unwrap_or("");
|
||||||
|
let mut frags = store.with_read(|notes| {
|
||||||
|
let mut out: Vec<Fragment> = Vec::new();
|
||||||
|
for (path, note) in notes {
|
||||||
|
if !path_matches(path, prefix, query.recursive) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !predicates_match(&query.predicates, ¬e.frontmatter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let order_value = query
|
||||||
|
.order
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|o| lookup_dotted(¬e.frontmatter, &o.key).cloned());
|
||||||
|
let kind_value = query
|
||||||
|
.kind_key
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|k| lookup_dotted(¬e.frontmatter, k).cloned());
|
||||||
|
out.push(Fragment {
|
||||||
|
path: note.path.clone(),
|
||||||
|
body: note.body.clone(),
|
||||||
|
order_value,
|
||||||
|
kind_value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
});
|
||||||
|
|
||||||
|
// Homogeneity guard over the full matched set, before __anw-limit.
|
||||||
|
if let Some(kind_key) = &query.kind_key {
|
||||||
|
let mut missing: Vec<&str> = Vec::new();
|
||||||
|
let mut by_value: BTreeMap<String, Vec<&str>> = BTreeMap::new();
|
||||||
|
for f in &frags {
|
||||||
|
match &f.kind_value {
|
||||||
|
None => missing.push(&f.path),
|
||||||
|
Some(v) => by_value
|
||||||
|
.entry(canonical_string(v))
|
||||||
|
.or_default()
|
||||||
|
.push(&f.path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !missing.is_empty() || by_value.len() > 1 {
|
||||||
|
return Err(MergeError::KindGuard(format_kind_error(
|
||||||
|
kind_key, &missing, &by_value,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order before __anw-limit so the cap keeps the top-N by the order key,
|
||||||
|
// not a path-ordered prefix. Tie-break by path (ascending) for byte
|
||||||
|
// stability regardless of direction.
|
||||||
|
match &query.order {
|
||||||
|
Some(spec) => frags.sort_by(|a, b| {
|
||||||
|
order_cmp(a.order_value.as_ref(), b.order_value.as_ref(), spec.desc)
|
||||||
|
.then_with(|| a.path.cmp(&b.path))
|
||||||
|
}),
|
||||||
|
None => frags.sort_by(|a, b| a.path.cmp(&b.path)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(limit) = query.limit {
|
||||||
|
frags.truncate(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(assemble(&frags))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concatenate fragments into one markdown document. Each fragment is its
|
||||||
|
/// source marker line followed by the verbatim, frontmatter-stripped body;
|
||||||
|
/// fragments are joined with a blank line so the result stays valid markdown.
|
||||||
|
fn assemble(frags: &[Fragment]) -> String {
|
||||||
|
frags
|
||||||
|
.iter()
|
||||||
|
.map(|f| format!("<!-- source: {} -->\n{}", f.path, f.body))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the `400` body for a failed `__anw-kind` guard. Distinct values are
|
||||||
|
/// listed in sorted order, each with its paths; notes missing the key are
|
||||||
|
/// named separately. Deterministic for byte-stable error responses.
|
||||||
|
fn format_kind_error(
|
||||||
|
key: &str,
|
||||||
|
missing: &[&str],
|
||||||
|
by_value: &BTreeMap<String, Vec<&str>>,
|
||||||
|
) -> String {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
let mut s = format!(
|
||||||
|
"__anw-kind={key}: merge requires every matched note to share one value for `{key}`\n"
|
||||||
|
);
|
||||||
|
if by_value.len() > 1 {
|
||||||
|
s.push_str("distinct values found:\n");
|
||||||
|
for (val, paths) in by_value {
|
||||||
|
let _ = writeln!(s, " {val}: {}", paths.join(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !missing.is_empty() {
|
||||||
|
let _ = writeln!(s, "notes missing the key: {}", missing.join(", "));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Order two optional frontmatter values for `__anw-order`. Present values
|
||||||
|
/// sort before missing ones regardless of direction; `desc` reverses only the
|
||||||
|
/// comparison among present values.
|
||||||
|
fn order_cmp(a: Option<&Value>, b: Option<&Value>, desc: bool) -> std::cmp::Ordering {
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
match (a, b) {
|
||||||
|
(Some(x), Some(y)) => {
|
||||||
|
let base = compare_values(x, y);
|
||||||
|
if desc { base.reverse() } else { base }
|
||||||
|
}
|
||||||
|
(Some(_), None) => Ordering::Less,
|
||||||
|
(None, Some(_)) => Ordering::Greater,
|
||||||
|
(None, None) => Ordering::Equal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare two frontmatter values using the same coercion ladder as the
|
||||||
|
/// range operators (date -> datetime -> int -> float -> string); see
|
||||||
|
/// [`ordered_compare`]. Falls back to canonical-string comparison when no
|
||||||
|
/// rung matches both sides.
|
||||||
|
fn compare_values(a: &Value, b: &Value) -> std::cmp::Ordering {
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
if let (Some(x), Some(y)) = (try_as_date(a), try_as_date(b)) {
|
||||||
|
return x.cmp(&y);
|
||||||
|
}
|
||||||
|
if let (Some(x), Some(y)) = (try_as_datetime(a), try_as_datetime(b)) {
|
||||||
|
return x.cmp(&y);
|
||||||
|
}
|
||||||
|
if let (Some(x), Some(y)) = (try_as_int(a), try_as_int(b)) {
|
||||||
|
return x.cmp(&y);
|
||||||
|
}
|
||||||
|
if let (Some(x), Some(y)) = (try_as_float(a), try_as_float(b)) {
|
||||||
|
return x.partial_cmp(&y).unwrap_or(Ordering::Equal);
|
||||||
|
}
|
||||||
|
canonical_string(a).cmp(&canonical_string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a value to its canonical string -- the same dialect
|
||||||
|
/// [`matches_scalar_eq`] compares against. Used as the grouping key for the
|
||||||
|
/// kind guard and as the string-rung fallback for ordering.
|
||||||
|
fn canonical_string(v: &Value) -> String {
|
||||||
|
use chrono::SecondsFormat;
|
||||||
|
match v {
|
||||||
|
Value::Null => "null".to_string(),
|
||||||
|
Value::Bool(b) => b.to_string(),
|
||||||
|
Value::Int(i) => i.to_string(),
|
||||||
|
Value::Float(f) => f.to_string(),
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
Value::Date(d) => d.format("%Y-%m-%d").to_string(),
|
||||||
|
Value::DateTime(dt) => dt
|
||||||
|
.with_timezone(&chrono::Utc)
|
||||||
|
.to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||||
|
Value::Sequence(_) | Value::Mapping(_) => format!("{v:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn path_matches(path: &str, prefix: &str, recursive: bool) -> bool {
|
fn path_matches(path: &str, prefix: &str, recursive: bool) -> bool {
|
||||||
if prefix.is_empty() {
|
if prefix.is_empty() {
|
||||||
return recursive || !path.contains('/');
|
return recursive || !path.contains('/');
|
||||||
|
|
@ -762,4 +1001,163 @@ mod tests {
|
||||||
let err = parse("__anw-flarble=1").unwrap_err();
|
let err = parse("__anw-flarble=1").unwrap_err();
|
||||||
assert!(matches!(err, QueryError::BadControl(_)));
|
assert!(matches!(err, QueryError::BadControl(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- [ANW-26] markdown-merge mode ---
|
||||||
|
|
||||||
|
fn note_body(path: &str, fm: Frontmatter, body: &str) -> Note {
|
||||||
|
Note {
|
||||||
|
path: path.into(),
|
||||||
|
frontmatter: fm,
|
||||||
|
body: body.into(),
|
||||||
|
raw_bytes: Vec::new(),
|
||||||
|
last_modified: Utc.with_ymd_and_hms(2026, 5, 14, 12, 0, 0).unwrap(),
|
||||||
|
etag: "\"x\"".into(),
|
||||||
|
size: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge(q: &ParsedQuery, s: &NoteStore) -> Result<String, MergeError> {
|
||||||
|
execute_merge(s, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn order_parses_direction_suffix() {
|
||||||
|
assert!(!parse("__anw-order=num").unwrap().order.unwrap().desc);
|
||||||
|
let asc = parse("__anw-order=num:asc").unwrap().order.unwrap();
|
||||||
|
assert_eq!(asc.key, "num");
|
||||||
|
assert!(!asc.desc);
|
||||||
|
let desc = parse("__anw-order=num:desc").unwrap().order.unwrap();
|
||||||
|
assert_eq!(desc.key, "num");
|
||||||
|
assert!(desc.desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn order_empty_key_is_bad_control() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse("__anw-order=:desc").unwrap_err(),
|
||||||
|
QueryError::BadControl(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse("__anw-kind=").unwrap_err(),
|
||||||
|
QueryError::BadControl(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_concatenates_bodies_with_source_markers() {
|
||||||
|
let q = parse("").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note_body("a.md", fm(&[]), "Body A."),
|
||||||
|
note_body("b.md", fm(&[]), "Body B."),
|
||||||
|
]);
|
||||||
|
let out = merge(&q, &s).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
"<!-- source: a.md -->\nBody A.\n\n<!-- source: b.md -->\nBody B."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_empty_match_set_is_empty_document() {
|
||||||
|
let q = parse("status=nope").unwrap();
|
||||||
|
let s = store_with(vec![note_body("a.md", fm(&[]), "Body A.")]);
|
||||||
|
assert_eq!(merge(&q, &s).unwrap(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_order_applies_before_limit() {
|
||||||
|
// Three notes ordered by `num` descending, capped to 2 -> keeps the
|
||||||
|
// top-2 by num (3, 2), not a path-ordered prefix.
|
||||||
|
let q = parse("__anw-order=num:desc&__anw-limit=2").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note_body("a.md", fm(&[("num", Value::Int(1))]), "one"),
|
||||||
|
note_body("b.md", fm(&[("num", Value::Int(3))]), "three"),
|
||||||
|
note_body("c.md", fm(&[("num", Value::Int(2))]), "two"),
|
||||||
|
]);
|
||||||
|
let out = merge(&q, &s).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
"<!-- source: b.md -->\nthree\n\n<!-- source: c.md -->\ntwo"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_order_missing_key_sorts_last_then_by_path() {
|
||||||
|
let q = parse("__anw-order=num").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note_body("z.md", fm(&[("num", Value::Int(5))]), "five"),
|
||||||
|
note_body("m.md", fm(&[]), "no-num-m"),
|
||||||
|
note_body("a.md", fm(&[]), "no-num-a"),
|
||||||
|
]);
|
||||||
|
let out = merge(&q, &s).unwrap();
|
||||||
|
// num=5 first; the two key-less notes follow, tie-broken by path.
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
"<!-- source: z.md -->\nfive\n\n<!-- source: a.md -->\nno-num-a\n\n<!-- source: m.md -->\nno-num-m"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_kind_guard_passes_when_uniform() {
|
||||||
|
let q = parse("__anw-kind=kind").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note_body("a.md", fm(&[("kind", Value::String("PDR".into()))]), "a"),
|
||||||
|
note_body("b.md", fm(&[("kind", Value::String("PDR".into()))]), "b"),
|
||||||
|
]);
|
||||||
|
assert!(merge(&q, &s).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_kind_guard_rejects_distinct_values() {
|
||||||
|
let q = parse("__anw-kind=kind").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note_body("a.md", fm(&[("kind", Value::String("PDR".into()))]), "a"),
|
||||||
|
note_body("b.md", fm(&[("kind", Value::String("ADR".into()))]), "b"),
|
||||||
|
]);
|
||||||
|
let err = merge(&q, &s).unwrap_err();
|
||||||
|
let MergeError::KindGuard(msg) = err;
|
||||||
|
assert!(msg.contains("ADR"));
|
||||||
|
assert!(msg.contains("PDR"));
|
||||||
|
assert!(msg.contains("a.md"));
|
||||||
|
assert!(msg.contains("b.md"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_kind_guard_rejects_missing_key() {
|
||||||
|
let q = parse("__anw-kind=kind").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note_body("a.md", fm(&[("kind", Value::String("PDR".into()))]), "a"),
|
||||||
|
note_body("b.md", fm(&[]), "b"),
|
||||||
|
]);
|
||||||
|
let err = merge(&q, &s).unwrap_err();
|
||||||
|
let MergeError::KindGuard(msg) = err;
|
||||||
|
assert!(msg.contains("missing the key"));
|
||||||
|
assert!(msg.contains("b.md"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_kind_guard_evaluated_over_full_set_before_limit() {
|
||||||
|
// The limit would keep only the first note (uniform), but the guard
|
||||||
|
// must still see the mismatched second note and reject.
|
||||||
|
let q = parse("__anw-kind=kind&__anw-limit=1&__anw-order=num").unwrap();
|
||||||
|
let s = store_with(vec![
|
||||||
|
note_body(
|
||||||
|
"a.md",
|
||||||
|
fm(&[
|
||||||
|
("kind", Value::String("PDR".into())),
|
||||||
|
("num", Value::Int(1)),
|
||||||
|
]),
|
||||||
|
"a",
|
||||||
|
),
|
||||||
|
note_body(
|
||||||
|
"b.md",
|
||||||
|
fm(&[
|
||||||
|
("kind", Value::String("ADR".into())),
|
||||||
|
("num", Value::Int(2)),
|
||||||
|
]),
|
||||||
|
"b",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert!(merge(&q, &s).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
tests/hurl/query/merge_kind_guard_400.hurl
Normal file
8
tests/hurl/query/merge_kind_guard_400.hurl
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# [ANW-26] __anw-kind=status rejects a mixed merge: alpha (draft) and anwesen
|
||||||
|
# (active) carry distinct status values, so the guard returns 400.
|
||||||
|
GET {{host}}/query?__anw-path=Projects&status__exists=true&__anw-kind=status
|
||||||
|
Accept: text/markdown
|
||||||
|
HTTP 400
|
||||||
|
[Asserts]
|
||||||
|
body contains "draft"
|
||||||
|
body contains "active"
|
||||||
8
tests/hurl/query/merge_kind_guard_ok.hurl
Normal file
8
tests/hurl/query/merge_kind_guard_ok.hurl
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# [ANW-26] __anw-kind=kind passes when every matched note carries the same
|
||||||
|
# value (both PDRs have kind: PDR).
|
||||||
|
GET {{host}}/query?__anw-path=Projects&kind=PDR&__anw-kind=kind
|
||||||
|
Accept: text/markdown
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
body contains "First PDR."
|
||||||
|
body contains "Second PDR."
|
||||||
11
tests/hurl/query/merge_markdown.hurl
Normal file
11
tests/hurl/query/merge_markdown.hurl
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# [ANW-26] Accept: text/markdown on /query merges matched note bodies into
|
||||||
|
# one document, each fragment preceded by its source marker.
|
||||||
|
GET {{host}}/query?__anw-path=Projects&kind=PDR
|
||||||
|
Accept: text/markdown
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
header "Content-Type" == "text/markdown; charset=utf-8"
|
||||||
|
body contains "<!-- source: Projects/PDR-001-intro.md -->"
|
||||||
|
body contains "<!-- source: Projects/PDR-002-followup.md -->"
|
||||||
|
body contains "First PDR."
|
||||||
|
body contains "Second PDR."
|
||||||
7
tests/hurl/query/merge_order_desc.hurl
Normal file
7
tests/hurl/query/merge_order_desc.hurl
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# [ANW-26] __anw-order=num:desc orders fragments by the `num` key descending,
|
||||||
|
# so PDR-002 (num 2) precedes PDR-001 (num 1) in the merged body.
|
||||||
|
GET {{host}}/query?__anw-path=Projects&kind=PDR&__anw-order=num:desc
|
||||||
|
Accept: text/markdown
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
body matches /(?s)Second PDR.*First PDR/
|
||||||
Loading…
Add table
Add a link
Reference in a new issue