Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1eada39ab | |||
| 6e07bff08a | |||
| 30bca24b5c |
13 changed files with 547 additions and 233 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
38
README.md
38
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
13
src/app.rs
13
src/app.rs
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
61
src/cli.rs
61
src/cli.rs
|
|
@ -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!(
|
||||||
|
|
|
||||||
10
src/http.rs
10
src/http.rs
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
34
src/main.rs
34
src/main.rs
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
205
src/merge.rs
205
src/merge.rs
|
|
@ -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
354
src/oneshot.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/query.rs
12
src/query.rs
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
33
tests/serve_start_failure.rs
Normal file
33
tests/serve_start_failure.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue