Compare commits

..

3 commits
v0.3.0 ... main

Author SHA1 Message Date
e1eada39ab ANW-47 Release: bump version to 0.4.0
Some checks failed
release / aarch64-linux (push) Has been cancelled
release / aarch64-macos (push) Has been cancelled
release / x86_64-linux (push) Has been cancelled
release / publish (push) Has been cancelled
2026-07-31 16:22:25 +03:00
6e07bff08a ANW-43 CLI: add a query subcommand writing the /query JSON offline
The README claims the query engine is available offline, but only the
merge half was: no subcommand produced the path / frontmatter /
last_modified / etag / size projection that GET /query returns.

`anwesen query` takes the same --vault and --query flags as merge and
writes that document to stdout, compact, one line. src/merge.rs becomes
src/oneshot.rs and holds both: one parse-and-load path, so the two
subcommands cannot drift in grammar or in how strictly they read a
vault. cli.rs shares one VaultQueryArgs between them for the same
reason. rfc3339_z moves from http.rs to query.rs, next to the projection
whose timestamp dialect it is.

Assumed a trailing newline on stdout is wanted here even though merge
writes none: the JSON body is one line for jq and shell pipelines, and
the newline terminates it rather than joining the document. Flag if
wrong.

The hurl harness now compares `anwesen query` against GET /query on the
fixture vault for four queries before running the suite, so a drift
between the two surfaces fails CI.
2026-07-31 15:59:18 +03:00
30bca24b5c ANW-45 Serve: exit nonzero when the supervisor tree fails to start
Hydra's Application::run logs a start failure and returns normally, so
serve exited 0 after the tree never came up. systemd read that as a clean
start and Restart=on-failure never retried; anwesen was unreachable on ap
for 12 hours with NRestarts=0.

Anwesen carries a started flag that start() sets once the supervisor is
up, and main exits 1 when it is still false after run() returns.

Assumed the flag is the only seam available: run() consumes self and
returns (), so the Err is not observable at the call site. Flag if a newer
hydra exposes the result.
2026-07-29 22:22:24 +03:00
13 changed files with 547 additions and 233 deletions

2
Cargo.lock generated
View file

@ -72,7 +72,7 @@ dependencies = [
[[package]] [[package]]
name = "anwesen" name = "anwesen"
version = "0.3.0" version = "0.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",

View file

@ -1,7 +1,7 @@
[package] [package]
name = "anwesen" name = "anwesen"
description = "Read-only HTTP daemon over a markdown vault, querying YAML frontmatter." description = "Read-only HTTP daemon over a markdown vault, querying YAML frontmatter."
version = "0.3.0" version = "0.4.0"
edition = "2024" edition = "2024"
rust-version = "1.95" rust-version = "1.95"
license = "BSD-3-Clause" license = "BSD-3-Clause"

View file

@ -22,7 +22,7 @@ Anwesen answers three kinds of question over HTTP:
The frontmatter index is built once at startup and kept current by watching the vault directory. The index lives in memory; a restart rebuilds it, and there is nothing on disk to corrupt or migrate. The frontmatter index is built once at startup and kept current by watching the vault directory. The index lives in memory; a restart rebuilds it, and there is nothing on disk to corrupt or migrate.
The same query-and-merge engine also runs offline, with no server: `anwesen merge` walks a directory, evaluates a query, and writes the merged markdown to stdout (see [Local generation](#local-generation)). The same query-and-merge engine also runs offline, with no server. `anwesen merge` walks a directory, evaluates a query, and writes the merged markdown to stdout; `anwesen query` writes the same JSON document `GET /query` returns (see [Local generation](#local-generation)).
## Quick start ## Quick start
@ -52,6 +52,12 @@ Build one file out of many notes, without starting the daemon:
anwesen merge --vault /path/to/vault --query 'tags=adr&__anw-order=title' > ADRs.md anwesen merge --vault /path/to/vault --query 'tags=adr&__anw-order=title' > ADRs.md
``` ```
Ask which notes match, and what their frontmatter holds, without starting the daemon:
```
anwesen query --vault /path/to/vault --query 'tags=adr' | jq -r '.results[].path'
```
## CLI ## CLI
``` ```
@ -59,6 +65,7 @@ anwesen serve --vault <path> [--bind <addr:port>] [--log-level <level>]
[--otlp-slow-request-ms <n>] [--otlp-slow-request-ms <n>]
anwesen doctor --vault <path> anwesen doctor --vault <path>
anwesen merge --vault <path> --query <query-string> anwesen merge --vault <path> --query <query-string>
anwesen query --vault <path> --query <query-string>
anwesen version anwesen version
``` ```
@ -67,7 +74,7 @@ anwesen version
| `--vault <path>` | `ANWESEN_VAULT` | _required_ | Path to the vault root. | | `--vault <path>` | `ANWESEN_VAULT` | _required_ | Path to the vault root. |
| `--bind <addr:port>` | `ANWESEN_BIND` | `127.0.0.1:8080` | Listen address for `serve`. | | `--bind <addr:port>` | `ANWESEN_BIND` | `127.0.0.1:8080` | Listen address for `serve`. |
| `--log-level <level>` | `ANWESEN_LOG_LEVEL` | `info` | `error`, `warn`, `info`, `debug`, or `trace`. | | `--log-level <level>` | `ANWESEN_LOG_LEVEL` | `info` | `error`, `warn`, `info`, `debug`, or `trace`. |
| `--query <query-string>` | -- | _required for `merge`_ | A `/query` query string: frontmatter predicates plus `__anw-` controls. | | `--query <query-string>` | `ANWESEN_QUERY` | empty (match all) | A `/query` query string: frontmatter predicates plus `__anw-` controls. `merge` and `query` only. |
| `--otlp-slow-request-ms` | `ANWESEN_OTLP_SLOW_REQUEST_MS` | `500` | Requests at or over this duration, or answering 5xx, also export a span. | | `--otlp-slow-request-ms` | `ANWESEN_OTLP_SLOW_REQUEST_MS` | `500` | Requests at or over this duration, or answering 5xx, also export a span. |
Every flag has a matching `ANWESEN_<UPPER>` environment variable. CLI flags win Every flag has a matching `ANWESEN_<UPPER>` environment variable. CLI flags win
@ -79,6 +86,7 @@ exported is configured entirely through the standard `OTEL_` variables below.
- **`serve`** -- run the daemon: walk the vault, build the index, watch for changes, serve the API. - **`serve`** -- run the daemon: walk the vault, build the index, watch for changes, serve the API.
- **`doctor`** -- walk the vault once and report what would stop clean ingestion: unreadable files, unparseable YAML, path collisions on the HTTP surface, and frontmatter type drift (the same key carrying incompatible types across notes). Read-only; non-zero exit if any issue is found. - **`doctor`** -- walk the vault once and report what would stop clean ingestion: unreadable files, unparseable YAML, path collisions on the HTTP surface, and frontmatter type drift (the same key carrying incompatible types across notes). Read-only; non-zero exit if any issue is found.
- **`merge`** -- one-shot local generation: walk the vault, evaluate `--query`, and write the merged markdown document to stdout. No server, no HTTP. See [Local generation](#local-generation). - **`merge`** -- one-shot local generation: walk the vault, evaluate `--query`, and write the merged markdown document to stdout. No server, no HTTP. See [Local generation](#local-generation).
- **`query`** -- the same one-shot walk, writing the JSON document `GET /query` returns: which notes match, and what their frontmatter, `last_modified`, `etag` and `size` hold. No server, no HTTP. See [Local generation](#local-generation).
- **`version`** -- print version and exit. - **`version`** -- print version and exit.
## Telemetry ## Telemetry
@ -173,7 +181,7 @@ ISO-8601 dates and RFC 3339 datetimes are coerced to typed dates at read time, s
| `__anw-order=<key>[:asc\|:desc]` | path order | Order fragments (merge mode only). | | `__anw-order=<key>[:asc\|:desc]` | path order | Order fragments (merge mode only). |
| `__anw-kind=<key>` | off | Refuse a mixed merge unless every matched note shares one value for the key (merge mode only). | | `__anw-kind=<key>` | off | Refuse a mixed merge unless every matched note shares one value for the key (merge mode only). |
By default `/query` returns metadata only; fetch bodies with `/notes/<path>`. By default `/query` returns metadata only; fetch bodies with `/notes/<path>`. The same JSON document is available offline, without the daemon, via `anwesen query` (see [Local generation](#local-generation)).
#### Markdown-merge mode #### Markdown-merge mode
@ -201,7 +209,11 @@ Returns vault path, note count, last index/event timestamps, watcher state, an i
## Local generation ## Local generation
`anwesen merge` produces the markdown-merge document on the command line, with no server and no HTTP round-trip. It walks the vault, evaluates the query, and writes the merged document to stdout: Two subcommands answer a query on the command line, with no server and no HTTP round-trip: `merge` writes the merged markdown document, `query` writes the JSON projection. Both take the same `--vault` and `--query` flags, both walk the vault once, and both run the engine the endpoint runs -- so the output matches what the daemon would have returned for the same vault and query.
### `merge`
`anwesen merge` walks the vault, evaluates the query, and writes the merged document to stdout:
``` ```
anwesen merge --vault /path/to/vault --query 'tags=adr&__anw-order=title&__anw-kind=kind' anwesen merge --vault /path/to/vault --query 'tags=adr&__anw-order=title&__anw-kind=kind'
@ -211,6 +223,24 @@ The `--query` string is the exact `/query` grammar: frontmatter predicates plus
This is the materialization path: build a `CLAUDE.md`, a skill bundle, or any single file assembled from many notes, driven from a script or a one-off shell. This is the materialization path: build a `CLAUDE.md`, a skill bundle, or any single file assembled from many notes, driven from a script or a one-off shell.
### `query`
`anwesen query` answers the other half: which notes match, and what their frontmatter holds. It writes the same JSON document `GET /query` returns -- `results`, `total`, `truncated`, with `path`, `frontmatter`, `last_modified`, `etag` and `size` per row -- on one line, ready for `jq`:
```
anwesen query --vault /path/to/vault --query 'kind=PDR&__anw-limit=1'
```
```json
{"results":[{"path":"Projects/PDR-001-intro.md","frontmatter":{"kind":"PDR","num":1,"title":"PDR-001 Intro"},"last_modified":"2026-05-14T17:05:08Z","etag":"\"f657eab5...\"","size":57}],"total":2,"truncated":true}
```
`total` counts the full match set; `truncated` says the cap cut it.
Bodies are elided here as they are on the endpoint; `merge` is the way to get them offline. `__anw-order` and `__anw-kind` are merge-mode controls and do not affect this output. A malformed query or an unreadable vault exits non-zero with the reason on stderr; an empty match set is an empty `results` list and exit `0`.
A script can therefore move between the daemon and the CLI without a second parser: same shape, same field names, same timestamp dialect.
## Design notes ## Design notes
- **In place, read-only.** Anwesen reads the same directory Obsidian writes to and never writes back. The vault stays editable in Obsidian with no coordination, and there is no write API by design. - **In place, read-only.** Anwesen reads the same directory Obsidian writes to and never writes back. The vault stays editable in Obsidian with no coordination, and there is no write API by design.

View file

@ -121,6 +121,12 @@ pub struct Anwesen {
/// Request-level telemetry handle ([ANW-37]). `None` disables export and /// Request-level telemetry handle ([ANW-37]). `None` disables export and
/// the request middleware entirely. /// the request middleware entirely.
pub telemetry: Option<Arc<Telemetry>>, pub telemetry: Option<Arc<Telemetry>>,
/// Set once the supervisor tree is up. Hydra's `Application::run` logs a
/// start failure and returns normally, so `serve` cannot tell a clean
/// shutdown from a tree that never came up. `main` reads this after `run`
/// and exits nonzero when it is still false, which is what lets systemd
/// retry ([ANW-45](https://crvrs.youtrack.cloud/issue/ANW-45)).
pub started: Arc<AtomicBool>,
} }
impl Anwesen { impl Anwesen {
@ -133,6 +139,7 @@ impl Anwesen {
store: NoteStore::new(), store: NoteStore::new(),
health: HealthState::new(), health: HealthState::new(),
telemetry, telemetry,
started: Arc::new(AtomicBool::new(false)),
} }
} }
} }
@ -187,10 +194,12 @@ impl Application for Anwesen {
.child_spec(), .child_spec(),
]; ];
Supervisor::with_children(children) let pid = Supervisor::with_children(children)
.strategy(SupervisionStrategy::OneForOne) .strategy(SupervisionStrategy::OneForOne)
.start_link(SupervisorOptions::new().name("anwesen_root")) .start_link(SupervisorOptions::new().name("anwesen_root"))
.await .await?;
self.started.store(true, Ordering::Release);
Ok(pid)
} }
} }

View file

@ -7,9 +7,14 @@
//! [--otlp-slow-request-ms <n>] //! [--otlp-slow-request-ms <n>]
//! anwesen doctor --vault <path> [--log-level <level>] //! anwesen doctor --vault <path> [--log-level <level>]
//! anwesen merge --vault <path> [--query <string>] [--log-level <level>] //! anwesen merge --vault <path> [--query <string>] [--log-level <level>]
//! anwesen query --vault <path> [--query <string>] [--log-level <level>]
//! anwesen version //! anwesen version
//! ``` //! ```
//! //!
//! `merge` and `query` differ only in what they write -- the merged markdown
//! document or the `/query` JSON projection ([ANW-43]) -- so they share one
//! [`VaultQueryArgs`] flag set and cannot drift.
//!
//! `--bind` and `--otlp-slow-request-ms` are `serve`-only ([ANW-37]); //! `--bind` and `--otlp-slow-request-ms` are `serve`-only ([ANW-37]);
//! `doctor` and `merge` do not bind a port; `version` takes no flags. Each //! `doctor` and `merge` do not bind a port; `version` takes no flags. Each
//! flag has a matching `ANWESEN_<UPPER>` environment variable and CLI wins //! flag has a matching `ANWESEN_<UPPER>` environment variable and CLI wins
@ -41,7 +46,11 @@ pub enum Command {
Doctor(DoctorArgs), Doctor(DoctorArgs),
/// Walk the vault once, evaluate the query, and write the merged markdown /// Walk the vault once, evaluate the query, and write the merged markdown
/// document to stdout. One-shot; no server. Read-only. /// document to stdout. One-shot; no server. Read-only.
Merge(MergeArgs), Merge(VaultQueryArgs),
/// Walk the vault once, evaluate the query, and write the same JSON
/// document `GET /query` returns to stdout. One-shot; no server.
/// Read-only.
Query(VaultQueryArgs),
/// Print the version and exit. /// Print the version and exit.
Version, Version,
} }
@ -97,8 +106,9 @@ pub struct DoctorArgs {
pub log_level: LogLevel, pub log_level: LogLevel,
} }
/// Flags shared by the two one-shot subcommands, `merge` and `query`.
#[derive(Debug, clap::Args)] #[derive(Debug, clap::Args)]
pub struct MergeArgs { pub struct VaultQueryArgs {
/// Path to the vault root. /// Path to the vault root.
#[arg(long, env = "ANWESEN_VAULT")] #[arg(long, env = "ANWESEN_VAULT")]
pub vault: PathBuf, pub vault: PathBuf,
@ -106,12 +116,12 @@ pub struct MergeArgs {
/// Query in the `/query` query-string grammar, for example /// Query in the `/query` query-string grammar, for example
/// `tags=anwesen&__anw-kind=skill&__anw-order=order`. The `__anw-kind` /// `tags=anwesen&__anw-kind=skill&__anw-order=order`. The `__anw-kind`
/// homogeneity guard and `__anw-order` fragment ordering ride inside this /// homogeneity guard and `__anw-order` fragment ordering ride inside this
/// string -- there are no separate flags. Empty merges every note under /// string -- there are no separate flags. `__anw-kind` and `__anw-order`
/// the vault root. /// apply to `merge` only. Empty matches every note under the vault root.
#[arg(long, env = "ANWESEN_QUERY", default_value = "")] #[arg(long, env = "ANWESEN_QUERY", default_value = "")]
pub query: String, pub query: String,
/// Log verbosity. Logs go to stderr; the merged document goes to stdout. /// Log verbosity. Logs go to stderr; the document goes to stdout.
#[arg(long, env = "ANWESEN_LOG_LEVEL", default_value = "info")] #[arg(long, env = "ANWESEN_LOG_LEVEL", default_value = "info")]
pub log_level: LogLevel, pub log_level: LogLevel,
} }
@ -271,6 +281,47 @@ mod tests {
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
} }
#[test]
fn query_takes_the_same_flags_as_merge() {
let cli = parse(&[
"query",
"--vault",
"/tmp/v",
"--query",
"tags=anwesen&__anw-limit=5",
"--log-level",
"warn",
])
.expect("parse");
match cli.command {
Command::Query(a) => {
assert_eq!(a.vault, PathBuf::from("/tmp/v"));
assert_eq!(a.query, "tags=anwesen&__anw-limit=5");
assert!(matches!(a.log_level, LogLevel::Warn));
}
_ => panic!("expected query"),
}
}
#[test]
fn query_requires_vault_and_defaults_the_query() {
assert!(parse(&["query"]).is_err());
match parse(&["query", "--vault", "/tmp/v"])
.expect("parse")
.command
{
Command::Query(a) => assert_eq!(a.query, ""),
_ => panic!("expected query"),
}
}
#[test]
fn query_rejects_bind() {
// --bind is serve-only; query does not listen.
let err = parse(&["query", "--vault", "/tmp/v", "--bind", "0.0.0.0:9000"]).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test] #[test]
fn version_takes_no_flags() { fn version_takes_no_flags() {
assert!(matches!( assert!(matches!(

View file

@ -22,7 +22,7 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::middleware::{Next, from_fn, from_fn_with_state}; use axum::middleware::{Next, from_fn, from_fn_with_state};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::get; use axum::routing::get;
use chrono::{DateTime, SecondsFormat, Utc}; use chrono::{DateTime, Utc};
use http_body::Body as _; use http_body::Body as _;
use hydra::Process; use hydra::Process;
use serde::Serialize; use serde::Serialize;
@ -32,17 +32,11 @@ use std::path::PathBuf;
use crate::app::RestartCounters; use crate::app::RestartCounters;
use crate::health::HealthState; use crate::health::HealthState;
use crate::query::rfc3339_z;
use crate::store::NoteStore; use crate::store::NoteStore;
use crate::telemetry::{self, Telemetry, TraceHeaders}; use crate::telemetry::{self, Telemetry, TraceHeaders};
use crate::vault::{Note, frontmatter_to_json}; 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<Utc>) -> String {
dt.to_rfc3339_opts(SecondsFormat::Secs, true)
}
/// Shared state injected into every handler. /// Shared state injected into every handler.
#[derive(Clone)] #[derive(Clone)]
pub struct HttpState { pub struct HttpState {

View file

@ -10,7 +10,7 @@ pub mod app;
pub mod doctor; pub mod doctor;
pub mod health; pub mod health;
pub mod http; pub mod http;
pub mod merge; pub mod oneshot;
pub mod query; pub mod query;
pub mod store; pub mod store;
pub mod telemetry; pub mod telemetry;

View file

@ -1,15 +1,16 @@
//! Anwesen: read-only HTTP daemon over a markdown vault. //! Anwesen: read-only HTTP daemon over a markdown vault.
//! //!
//! This module wires the CLI to the `serve`, `doctor`, `merge`, and //! This module wires the CLI to the `serve`, `doctor`, `merge`, `query`, and
//! `version` subcommands. //! `version` subcommands.
mod cli; mod cli;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::Ordering;
use anwesen::app::Anwesen; use anwesen::app::Anwesen;
use anwesen::doctor; use anwesen::doctor;
use anwesen::merge; use anwesen::oneshot;
use anwesen::telemetry::{self, OtelEnv, RawTelemetryArgs, TelemetryConfig}; use anwesen::telemetry::{self, OtelEnv, RawTelemetryArgs, TelemetryConfig};
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
@ -46,12 +47,22 @@ fn main() -> Result<()> {
telemetry = telemetry.is_some(), telemetry = telemetry.is_some(),
"anwesen serve: starting supervisor tree" "anwesen serve: starting supervisor tree"
); );
let app = Anwesen::new(args.vault, args.bind, telemetry.clone());
let started = app.started.clone();
// Blocks until the supervisor exits (SIGTERM / SIGINT / crash). // Blocks until the supervisor exits (SIGTERM / SIGINT / crash).
Anwesen::new(args.vault, args.bind, telemetry.clone()).run(); app.run();
// Flush and shut down exporters after the server loop returns. // Flush and shut down exporters after the server loop returns.
if let Some(telemetry) = telemetry { if let Some(telemetry) = telemetry {
telemetry.shutdown(); telemetry.shutdown();
} }
// `run` returns normally whether the tree came up or never
// started, so a failed start would otherwise look like a clean
// exit and systemd's `Restart=on-failure` would not retry
// (ANW-45). Exit nonzero when the tree never came up.
if !started.load(Ordering::Acquire) {
tracing::error!("anwesen serve: supervisor tree failed to start");
std::process::exit(1);
}
} }
Command::Doctor(args) => { Command::Doctor(args) => {
init_logging(args.log_level); init_logging(args.log_level);
@ -63,13 +74,26 @@ fn main() -> Result<()> {
} }
Command::Merge(args) => { Command::Merge(args) => {
init_logging(args.log_level); init_logging(args.log_level);
match merge::run(&args.vault, &args.query) { match oneshot::merge(&args.vault, &args.query) {
// `print!`, not `println!`: the merged document is byte-stable // `print!`, not `println!`: the merged document is byte-stable
// and byte-identical to the HTTP merge body, which carries no // and byte-identical to the HTTP merge body, which carries no
// trailing newline. An empty match set prints nothing, exit 0. // trailing newline. An empty match set prints nothing, exit 0.
Ok(doc) => print!("{doc}"), Ok(doc) => print!("{doc}"),
Err(e) => { Err(e) => {
eprint!("{}", e.render()); eprint!("{}", e.render("merge"));
std::process::exit(1);
}
}
}
Command::Query(args) => {
init_logging(args.log_level);
match oneshot::query_json(&args.vault, &args.query) {
// `println!` here, unlike `merge`: the JSON body carries no
// trailing newline either, but stdout is one document per
// line for the shells and `jq` pipelines this exists for.
Ok(doc) => println!("{doc}"),
Err(e) => {
eprint!("{}", e.render("query"));
std::process::exit(1); std::process::exit(1);
} }
} }

View file

@ -1,205 +0,0 @@
//! One-shot local markdown-merge for the `anwesen merge` subcommand ([ANW-27]).
//!
//! Walks a vault directory once (the same [`vault::scan`] as `doctor`),
//! evaluates the `--query` string, and assembles the merged markdown document
//! with [`query::execute_merge`] -- the very engine the HTTP `/query` merge
//! mode ([ANW-26]) uses. CLI and HTTP output are therefore byte-identical for
//! the same vault and query. No HTTP, no watcher, no persistent index.
use std::fmt::Write as _;
use std::path::Path;
use crate::query::{self, MergeError, QueryError};
use crate::store::NoteStore;
use crate::vault::{self, ScanIssue};
/// Why a one-shot merge could not produce a document.
#[derive(Debug)]
pub enum MergeCliError {
/// The `--query` string did not parse. Same grammar, same message as the
/// HTTP `/query` `400`.
Query(QueryError),
/// One or more files could not be read, or their frontmatter did not
/// parse. Same hard-failure posture as `doctor`; soft warnings (e.g. a
/// non-mapping frontmatter root) are ignored, matching what `serve`
/// ingests.
Scan(Vec<ScanIssue>),
/// The `__anw-kind` homogeneity guard rejected the matched set. The
/// `String` is the same naming message the HTTP path returns as `400`,
/// surfaced here on stderr instead.
Kind(String),
}
impl MergeCliError {
/// Render the error for stderr. One concern per line, deterministic so the
/// stderr surface is golden-testable.
#[must_use]
pub fn render(&self) -> String {
match self {
Self::Query(e) => format!("{e}\n"),
Self::Scan(issues) => {
let mut s = String::from("merge: cannot read vault\n");
for issue in issues {
let _ = writeln!(s, " {}: {}", issue.path.display(), issue.kind);
}
s
}
Self::Kind(msg) => msg.clone(),
}
}
}
/// Walk `vault_root`, evaluate `raw_query`, and return the merged document.
///
/// On success the returned `String` is byte-identical to the HTTP merge body
/// for the same vault and query. An empty match set yields an empty string.
///
/// # Errors
/// - [`MergeCliError::Query`] when `raw_query` is malformed;
/// - [`MergeCliError::Scan`] when the directory is unreadable or any file's
/// frontmatter fails to parse;
/// - [`MergeCliError::Kind`] when the `__anw-kind` homogeneity guard fails.
pub fn run(vault_root: &Path, raw_query: &str) -> Result<String, MergeCliError> {
let parsed = query::parse(raw_query).map_err(MergeCliError::Query)?;
let scan = vault::scan(vault_root);
if !scan.issues.is_empty() {
return Err(MergeCliError::Scan(scan.issues));
}
// The merge engine reads from a NoteStore exactly as the HTTP path does;
// a one-shot `replace` is the whole "index" this subcommand needs.
let store = NoteStore::new();
store.replace(scan.notes);
query::execute_merge(&store, &parsed)
.map_err(|MergeError::KindGuard(msg)| MergeCliError::Kind(msg))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn write(root: &Path, rel: &str, body: &str) {
let p = root.join(rel);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(p, body).unwrap();
}
#[test]
fn merges_bodies_with_source_markers() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nnum: 1\n---\nalpha\n");
write(tmp.path(), "b.md", "---\nnum: 2\n---\nbeta\n");
let out = run(tmp.path(), "").unwrap();
// Body is frontmatter-stripped; fragments join with a blank line and
// there is no trailing newline -- byte-identical to the HTTP body.
assert_eq!(
out,
"<!-- source: a.md -->\nalpha\n\n\n<!-- source: b.md -->\nbeta\n"
);
}
#[test]
fn order_desc_then_path_tiebreak() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nnum: 1\n---\nlow\n");
write(tmp.path(), "b.md", "---\nnum: 3\n---\nhigh\n");
write(tmp.path(), "c.md", "---\nnum: 2\n---\nmid\n");
let out = run(tmp.path(), "__anw-order=num:desc").unwrap();
let bodies: Vec<&str> = out
.lines()
.filter(|l| !l.starts_with("<!--") && !l.is_empty())
.collect();
assert_eq!(bodies, vec!["high", "mid", "low"]);
}
#[test]
fn empty_match_set_is_empty_string() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\ntags: [x]\n---\nbody\n");
// Predicate matches nothing.
assert_eq!(run(tmp.path(), "tags=nope").unwrap(), "");
}
#[test]
fn empty_vault_is_empty_string() {
let tmp = TempDir::new().unwrap();
assert_eq!(run(tmp.path(), "").unwrap(), "");
}
#[test]
fn kind_guard_passes_when_uniform() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nkind: skill\n---\nA\n");
write(tmp.path(), "b.md", "---\nkind: skill\n---\nB\n");
assert!(run(tmp.path(), "__anw-kind=kind").is_ok());
}
#[test]
fn kind_guard_rejects_distinct_values_naming_offenders() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nkind: skill\n---\nA\n");
write(tmp.path(), "b.md", "---\nkind: note\n---\nB\n");
let err = run(tmp.path(), "__anw-kind=kind").unwrap_err();
let msg = err.render();
assert!(msg.contains("distinct values found"));
assert!(msg.contains("a.md"));
assert!(msg.contains("b.md"));
}
#[test]
fn kind_guard_rejects_missing_key() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nkind: skill\n---\nA\n");
write(tmp.path(), "b.md", "---\n---\nB\n");
let err = run(tmp.path(), "__anw-kind=kind").unwrap_err();
assert!(matches!(err, MergeCliError::Kind(_)));
assert!(err.render().contains("notes missing the key"));
}
#[test]
fn malformed_query_is_reported() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\n---\nbody\n");
let err = run(tmp.path(), "x__bogus=1").unwrap_err();
assert!(matches!(err, MergeCliError::Query(_)));
// Non-empty stderr surface.
assert!(!err.render().is_empty());
}
#[test]
fn unparseable_frontmatter_is_a_scan_error() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "bad.md", "---\n:: :: ::\n---\n");
let err = run(tmp.path(), "").unwrap_err();
assert!(matches!(err, MergeCliError::Scan(_)));
assert!(err.render().contains("cannot read vault"));
assert!(err.render().contains("bad.md"));
}
#[test]
fn output_matches_http_engine_for_same_vault_and_query() {
// The shared-engine check: the CLI path and a directly-built store
// feeding execute_merge must produce the identical document.
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nnum: 2\n---\nA body\n");
write(tmp.path(), "b.md", "---\nnum: 1\n---\nB body\n");
let raw = "__anw-order=num:asc";
let cli = run(tmp.path(), raw).unwrap();
let scan = vault::scan(tmp.path());
let store = NoteStore::new();
store.replace(scan.notes);
let parsed = query::parse(raw).unwrap();
let direct = query::execute_merge(&store, &parsed).unwrap();
assert_eq!(cli, direct);
}
}

354
src/oneshot.rs Normal file
View file

@ -0,0 +1,354 @@
//! One-shot local query evaluation for the `anwesen merge` ([ANW-27]) and
//! `anwesen query` ([ANW-43]) subcommands.
//!
//! Both walk a vault directory once (the same [`vault::scan`] as `doctor`),
//! evaluate the `--query` string, and hand the resulting store to the very
//! engine the HTTP `/query` endpoint uses: [`query::execute_merge`] for the
//! merged markdown document ([ANW-26]), [`query::execute`] for the JSON
//! projection. CLI and HTTP output are therefore byte-identical for the same
//! vault and query. No HTTP, no watcher, no persistent index.
use std::fmt::Write as _;
use std::path::Path;
use std::sync::Arc;
use crate::query::{self, MergeError, ParsedQuery, QueryError};
use crate::store::NoteStore;
use crate::vault::{self, ScanIssue};
/// Why a one-shot evaluation could not produce a document.
#[derive(Debug)]
pub enum OneshotError {
/// The `--query` string did not parse. Same grammar, same message as the
/// HTTP `/query` `400`.
Query(QueryError),
/// One or more files could not be read, or their frontmatter did not
/// parse. Same hard-failure posture as `doctor`; soft warnings (e.g. a
/// non-mapping frontmatter root) are ignored, matching what `serve`
/// ingests.
Scan(Vec<ScanIssue>),
/// The `__anw-kind` homogeneity guard rejected the matched set. The
/// `String` is the same naming message the HTTP path returns as `400`,
/// surfaced here on stderr instead. Merge mode only -- the JSON
/// projection does not run the guard, exactly as the endpoint does not.
Kind(String),
}
impl OneshotError {
/// Render the error for stderr. One concern per line, deterministic so the
/// stderr surface is golden-testable. `subcommand` names the caller so the
/// scan header reads `merge:` or `query:` as the operator invoked it.
#[must_use]
pub fn render(&self, subcommand: &str) -> String {
match self {
Self::Query(e) => format!("{e}\n"),
Self::Scan(issues) => {
let mut s = format!("{subcommand}: cannot read vault\n");
for issue in issues {
let _ = writeln!(s, " {}: {}", issue.path.display(), issue.kind);
}
s
}
Self::Kind(msg) => msg.clone(),
}
}
}
/// Parse `raw_query` and load `vault_root` into a one-shot store.
///
/// The single path both subcommands take, so the two cannot drift in how they
/// parse a query or how strictly they read a vault.
fn load(vault_root: &Path, raw_query: &str) -> Result<(ParsedQuery, Arc<NoteStore>), OneshotError> {
let parsed = query::parse(raw_query).map_err(OneshotError::Query)?;
let scan = vault::scan(vault_root);
if !scan.issues.is_empty() {
return Err(OneshotError::Scan(scan.issues));
}
// The engine reads from a NoteStore exactly as the HTTP path does; a
// one-shot `replace` is the whole "index" these subcommands need.
let store = NoteStore::new();
store.replace(scan.notes);
Ok((parsed, store))
}
/// Walk `vault_root`, evaluate `raw_query`, and return the merged document.
///
/// On success the returned `String` is byte-identical to the HTTP merge body
/// for the same vault and query. An empty match set yields an empty string.
///
/// # Errors
/// - [`OneshotError::Query`] when `raw_query` is malformed;
/// - [`OneshotError::Scan`] when the directory is unreadable or any file's
/// frontmatter fails to parse;
/// - [`OneshotError::Kind`] when the `__anw-kind` homogeneity guard fails.
pub fn merge(vault_root: &Path, raw_query: &str) -> Result<String, OneshotError> {
let (parsed, store) = load(vault_root, raw_query)?;
query::execute_merge(&store, &parsed)
.map_err(|MergeError::KindGuard(msg)| OneshotError::Kind(msg))
}
/// Walk `vault_root`, evaluate `raw_query`, and return the JSON document
/// `GET /query` returns for the same vault and query -- same projection, same
/// `results` / `total` / `truncated` shape, same compact serialization, so a
/// script moving between daemon and CLI needs no second parser ([ANW-43]).
///
/// Result ordering and the `__anw-limit` semantics are the endpoint's,
/// because this is the endpoint's [`query::execute`]: paths ascending, the
/// cap applied after `total` is counted.
///
/// # Errors
/// - [`OneshotError::Query`] when `raw_query` is malformed;
/// - [`OneshotError::Scan`] when the directory is unreadable or any file's
/// frontmatter fails to parse.
///
/// # Panics
/// If the response fails to serialize -- unreachable, as the HTTP handler's
/// identical `to_vec` call also treats it: every field is a string, a number,
/// or frontmatter already converted to `serde_json::Value`.
pub fn query_json(vault_root: &Path, raw_query: &str) -> Result<String, OneshotError> {
let (parsed, store) = load(vault_root, raw_query)?;
let response = query::execute(&store, &parsed, query::rfc3339_z);
Ok(serde_json::to_string(&response).expect("query response serializes"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn write(root: &Path, rel: &str, body: &str) {
let p = root.join(rel);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(p, body).unwrap();
}
#[test]
fn merges_bodies_with_source_markers() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nnum: 1\n---\nalpha\n");
write(tmp.path(), "b.md", "---\nnum: 2\n---\nbeta\n");
let out = merge(tmp.path(), "").unwrap();
// Body is frontmatter-stripped; fragments join with a blank line and
// there is no trailing newline -- byte-identical to the HTTP body.
assert_eq!(
out,
"<!-- source: a.md -->\nalpha\n\n\n<!-- source: b.md -->\nbeta\n"
);
}
#[test]
fn order_desc_then_path_tiebreak() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nnum: 1\n---\nlow\n");
write(tmp.path(), "b.md", "---\nnum: 3\n---\nhigh\n");
write(tmp.path(), "c.md", "---\nnum: 2\n---\nmid\n");
let out = merge(tmp.path(), "__anw-order=num:desc").unwrap();
let bodies: Vec<&str> = out
.lines()
.filter(|l| !l.starts_with("<!--") && !l.is_empty())
.collect();
assert_eq!(bodies, vec!["high", "mid", "low"]);
}
#[test]
fn empty_match_set_is_empty_string() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\ntags: [x]\n---\nbody\n");
// Predicate matches nothing.
assert_eq!(merge(tmp.path(), "tags=nope").unwrap(), "");
}
#[test]
fn empty_vault_is_empty_string() {
let tmp = TempDir::new().unwrap();
assert_eq!(merge(tmp.path(), "").unwrap(), "");
}
#[test]
fn kind_guard_passes_when_uniform() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nkind: skill\n---\nA\n");
write(tmp.path(), "b.md", "---\nkind: skill\n---\nB\n");
assert!(merge(tmp.path(), "__anw-kind=kind").is_ok());
}
#[test]
fn kind_guard_rejects_distinct_values_naming_offenders() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nkind: skill\n---\nA\n");
write(tmp.path(), "b.md", "---\nkind: note\n---\nB\n");
let err = merge(tmp.path(), "__anw-kind=kind").unwrap_err();
let msg = err.render("merge");
assert!(msg.contains("distinct values found"));
assert!(msg.contains("a.md"));
assert!(msg.contains("b.md"));
}
#[test]
fn kind_guard_rejects_missing_key() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nkind: skill\n---\nA\n");
write(tmp.path(), "b.md", "---\n---\nB\n");
let err = merge(tmp.path(), "__anw-kind=kind").unwrap_err();
assert!(matches!(err, OneshotError::Kind(_)));
assert!(err.render("merge").contains("notes missing the key"));
}
#[test]
fn malformed_query_is_reported() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\n---\nbody\n");
let err = merge(tmp.path(), "x__bogus=1").unwrap_err();
assert!(matches!(err, OneshotError::Query(_)));
// Non-empty stderr surface.
assert!(!err.render("merge").is_empty());
}
#[test]
fn unparseable_frontmatter_is_a_scan_error() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "bad.md", "---\n:: :: ::\n---\n");
let err = merge(tmp.path(), "").unwrap_err();
assert!(matches!(err, OneshotError::Scan(_)));
assert!(err.render("merge").contains("cannot read vault"));
assert!(err.render("merge").contains("bad.md"));
}
#[test]
fn output_matches_http_engine_for_same_vault_and_query() {
// The shared-engine check: the CLI path and a directly-built store
// feeding execute_merge must produce the identical document.
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nnum: 2\n---\nA body\n");
write(tmp.path(), "b.md", "---\nnum: 1\n---\nB body\n");
let raw = "__anw-order=num:asc";
let cli = merge(tmp.path(), raw).unwrap();
let scan = vault::scan(tmp.path());
let store = NoteStore::new();
store.replace(scan.notes);
let parsed = query::parse(raw).unwrap();
let direct = query::execute_merge(&store, &parsed).unwrap();
assert_eq!(cli, direct);
}
// --- `query` (ANW-43) --------------------------------------------------
fn json(doc: &str) -> serde_json::Value {
serde_json::from_str(doc).expect("query output parses as JSON")
}
#[test]
fn query_projects_path_frontmatter_and_file_facts() {
let tmp = TempDir::new().unwrap();
write(
tmp.path(),
"Projects/alpha.md",
"---\ntags: [project]\nversion: 2\n---\nbody\n",
);
let doc = json(&query_json(tmp.path(), "").unwrap());
assert_eq!(doc["total"], 1);
assert_eq!(doc["truncated"], false);
let row = &doc["results"][0];
assert_eq!(row["path"], "Projects/alpha.md");
assert_eq!(row["frontmatter"]["version"], 2);
assert_eq!(row["frontmatter"]["tags"][0], "project");
assert_eq!(row["size"], 40);
// The endpoint's timestamp dialect, seconds precision with a Z suffix.
let lm = row["last_modified"].as_str().unwrap();
assert!(lm.ends_with('Z'), "{lm}");
assert!(row["etag"].as_str().unwrap().starts_with('"'));
// The body is elided here as it is on the endpoint.
assert!(row.get("body").is_none());
}
#[test]
fn query_predicates_filter_and_limit_truncates_after_total() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nstatus: draft\n---\nA\n");
write(tmp.path(), "b.md", "---\nstatus: draft\n---\nB\n");
write(tmp.path(), "c.md", "---\nstatus: done\n---\nC\n");
let filtered = json(&query_json(tmp.path(), "status=draft").unwrap());
assert_eq!(filtered["total"], 2);
assert_eq!(filtered["results"].as_array().unwrap().len(), 2);
let capped = json(&query_json(tmp.path(), "status=draft&__anw-limit=1").unwrap());
// `total` counts the full match set; the cap keeps the first row.
assert_eq!(capped["total"], 2);
assert_eq!(capped["truncated"], true);
assert_eq!(capped["results"].as_array().unwrap().len(), 1);
assert_eq!(capped["results"][0]["path"], "a.md");
}
#[test]
fn query_empty_match_set_is_an_empty_result_list() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\ntags: [x]\n---\nbody\n");
let doc = json(&query_json(tmp.path(), "tags=nope").unwrap());
assert_eq!(doc["total"], 0);
assert_eq!(doc["truncated"], false);
assert!(doc["results"].as_array().unwrap().is_empty());
}
#[test]
fn query_malformed_query_is_reported_naming_the_subcommand() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\n---\nbody\n");
let err = query_json(tmp.path(), "x__bogus=1").unwrap_err();
assert!(matches!(err, OneshotError::Query(_)));
assert!(!err.render("query").is_empty());
}
#[test]
fn query_unparseable_frontmatter_is_a_scan_error() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "bad.md", "---\n:: :: ::\n---\n");
let err = query_json(tmp.path(), "").unwrap_err();
assert!(err.render("query").starts_with("query: cannot read vault"));
assert!(err.render("query").contains("bad.md"));
}
#[test]
fn query_kind_guard_does_not_apply() {
// `__anw-kind` guards merge output only. Parsing it must not make the
// JSON path fail on a mixed set, matching the endpoint.
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nkind: skill\n---\nA\n");
write(tmp.path(), "b.md", "---\nkind: note\n---\nB\n");
assert!(merge(tmp.path(), "__anw-kind=kind").is_err());
let doc = json(&query_json(tmp.path(), "__anw-kind=kind").unwrap());
assert_eq!(doc["total"], 2);
}
#[test]
fn query_output_matches_the_http_projection_byte_for_byte() {
// The shared-engine check for the JSON surface: same store, same
// `execute`, same serialization as the `/query` handler.
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.md", "---\nnum: 2\n---\nA body\n");
write(tmp.path(), "b.md", "---\nnum: 1\n---\nB body\n");
let raw = "__anw-limit=1";
let cli = query_json(tmp.path(), raw).unwrap();
let scan = vault::scan(tmp.path());
let store = NoteStore::new();
store.replace(scan.notes);
let parsed = query::parse(raw).unwrap();
let direct =
serde_json::to_string(&query::execute(&store, &parsed, crate::query::rfc3339_z))
.unwrap();
assert_eq!(cli, direct);
}
}

View file

@ -18,7 +18,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use chrono::{DateTime, NaiveDate}; use chrono::{DateTime, NaiveDate, SecondsFormat, Utc};
use regex::Regex; use regex::Regex;
use serde::Serialize; use serde::Serialize;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
@ -280,6 +280,16 @@ fn hex_value(b: u8) -> Option<u8> {
} }
} }
/// Canonical RFC 3339 form with a `Z` suffix -- the shape the User Manual
/// example uses for `last_modified`. It lives next to [`ResultEntry`] because
/// it is the projection's dialect: both the HTTP handlers and the offline
/// `anwesen query` subcommand ([ANW-43]) format timestamps with it, so the
/// two surfaces cannot drift.
#[must_use]
pub fn rfc3339_z(dt: DateTime<Utc>) -> String {
dt.to_rfc3339_opts(SecondsFormat::Secs, true)
}
/// One result row in the `/query` response. Per User Manual the body is /// One result row in the `/query` response. Per User Manual the body is
/// elided -- consumers fetch bodies via `/notes/<path>` if needed. /// elided -- consumers fetch bodies via `/notes/<path>` if needed.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]

View file

@ -54,6 +54,20 @@ if [[ $ready -ne 1 ]]; then
exit 1 exit 1
fi fi
# ANW-43: the offline `query` subcommand must return the document the endpoint
# returns. Both surfaces read the same fixture vault, so the two outputs are
# compared byte for byte before the contract suite runs.
for q in "" "tags=project" "__anw-path=Projects&__anw-limit=1" "status__exists=true"; do
cli="$("$BIN" query --vault "$VAULT" --query "$q" --log-level error)"
http="$(curl -sf "http://$HOST:$PORT/query?$q")"
if [[ "$cli" != "$http" ]]; then
echo "anwesen query and GET /query disagree for query '$q'" >&2
echo " cli: $cli" >&2
echo " http: $http" >&2
exit 1
fi
done
# Run every *.hurl. Glob into an array so we can fail loud if there are none. # Run every *.hurl. Glob into an array so we can fail loud if there are none.
shopt -s globstar nullglob shopt -s globstar nullglob
files=("$ROOT"/tests/hurl/**/*.hurl) files=("$ROOT"/tests/hurl/**/*.hurl)

View file

@ -0,0 +1,33 @@
//! `anwesen serve` must exit nonzero when the supervisor tree fails to start
//! ([ANW-45](https://crvrs.youtrack.cloud/issue/ANW-45)). Hydra's
//! `Application::run` logs the failure and returns normally, so without an
//! explicit check the process exits 0 and systemd's `Restart=on-failure`
//! never retries -- the vault stayed unreachable for 12 hours on ap.
//!
//! The forced failure is a taken bind address: `http_server` binds eagerly in
//! its child spec, so the address-in-use error fails the whole start.
use std::net::TcpListener;
use std::process::Command;
#[test]
fn serve_exits_nonzero_when_the_tree_fails_to_start() {
let vault = tempfile::tempdir().expect("tempdir");
// Hold the port for the lifetime of the child so its bind cannot succeed.
let held = TcpListener::bind("127.0.0.1:0").expect("bind probe port");
let addr = held.local_addr().expect("probe addr");
let status = Command::new(env!("CARGO_BIN_EXE_anwesen"))
.arg("serve")
.arg("--vault")
.arg(vault.path())
.arg("--bind")
.arg(addr.to_string())
.status()
.expect("spawn anwesen serve");
assert!(
!status.success(),
"serve exited {status} after a failed supervisor start; systemd reads that as a clean exit"
);
}