ANW-27 CLI: anwesen merge -- one-shot local markdown-merge to stdout

Thin local front-end over ANW-26's merge engine (query::execute_merge):
walk the vault once (vault::scan, same as doctor), evaluate the --query
string, assemble the merged document, write it to stdout. No HTTP, no
watcher, no index. Byte-identical to the HTTP merge mode for the same
vault and query.
This commit is contained in:
Andreas Brenner 2026-06-14 21:18:06 +03:00
parent 03cc12e640
commit ed1759da59
4 changed files with 293 additions and 5 deletions

View file

@ -5,12 +5,13 @@
//! ```text //! ```text
//! anwesen serve --vault <path> [--bind <addr:port>] [--log-level <level>] //! anwesen serve --vault <path> [--bind <addr:port>] [--log-level <level>]
//! anwesen doctor --vault <path> [--log-level <level>] //! anwesen doctor --vault <path> [--log-level <level>]
//! anwesen merge --vault <path> [--query <string>] [--log-level <level>]
//! anwesen version //! anwesen version
//! ``` //! ```
//! //!
//! `--bind` is `serve`-only; `doctor` does not bind a port; `version` takes //! `--bind` is `serve`-only; `doctor` and `merge` do not bind a port;
//! no flags. Each flag has a matching `ANWESEN_<UPPER>` environment variable //! `version` takes no flags. Each flag has a matching `ANWESEN_<UPPER>`
//! and CLI wins over env per the manual. //! environment variable and CLI wins over env per the manual.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::PathBuf; use std::path::PathBuf;
@ -30,6 +31,9 @@ pub enum Command {
Serve(ServeArgs), Serve(ServeArgs),
/// Walk the vault once and report ingestion blockers. Read-only. /// Walk the vault once and report ingestion blockers. Read-only.
Doctor(DoctorArgs), Doctor(DoctorArgs),
/// Walk the vault once, evaluate the query, and write the merged markdown
/// document to stdout. One-shot; no server. Read-only.
Merge(MergeArgs),
/// Print the version and exit. /// Print the version and exit.
Version, Version,
} }
@ -60,6 +64,25 @@ pub struct DoctorArgs {
pub log_level: LogLevel, pub log_level: LogLevel,
} }
#[derive(Debug, clap::Args)]
pub struct MergeArgs {
/// Path to the vault root.
#[arg(long, env = "ANWESEN_VAULT")]
pub vault: PathBuf,
/// Query in the `/query` query-string grammar, for example
/// `tags=anwesen&__anw-kind=skill&__anw-order=order`. The `__anw-kind`
/// homogeneity guard and `__anw-order` fragment ordering ride inside this
/// string -- there are no separate flags. Empty merges every note under
/// the vault root.
#[arg(long, env = "ANWESEN_QUERY", default_value = "")]
pub query: String,
/// Log verbosity. Logs go to stderr; the merged document goes to stdout.
#[arg(long, env = "ANWESEN_LOG_LEVEL", default_value = "info")]
pub log_level: LogLevel,
}
#[derive(Debug, Clone, Copy, ValueEnum)] #[derive(Debug, Clone, Copy, ValueEnum)]
#[value(rename_all = "lower")] #[value(rename_all = "lower")]
pub enum LogLevel { pub enum LogLevel {
@ -142,6 +165,51 @@ mod tests {
} }
} }
#[test]
fn merge_requires_vault() {
assert!(parse(&["merge"]).is_err());
}
#[test]
fn merge_query_defaults_to_empty() {
let cli = parse(&["merge", "--vault", "/tmp/v"]).expect("parse");
match cli.command {
Command::Merge(a) => {
assert_eq!(a.vault, PathBuf::from("/tmp/v"));
assert_eq!(a.query, "");
}
_ => panic!("expected merge"),
}
}
#[test]
fn merge_accepts_vault_query_and_log_level() {
let cli = parse(&[
"merge",
"--vault",
"/tmp/v",
"--query",
"tags=anwesen&__anw-order=order",
"--log-level",
"warn",
])
.expect("parse");
match cli.command {
Command::Merge(a) => {
assert_eq!(a.query, "tags=anwesen&__anw-order=order");
assert!(matches!(a.log_level, LogLevel::Warn));
}
_ => panic!("expected merge"),
}
}
#[test]
fn merge_rejects_bind() {
// --bind is serve-only; merge must not accept it.
let err = parse(&["merge", "--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

@ -10,6 +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 query; pub mod query;
pub mod store; pub mod store;
pub mod vault; pub mod vault;

View file

@ -1,12 +1,13 @@
//! 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 (still-stub) `serve` and `doctor` //! This module wires the CLI to the `serve`, `doctor`, `merge`, and
//! subcommands. The full daemon arrives over [ANW-11..ANW-19]. //! `version` subcommands.
mod cli; mod cli;
use anwesen::app::Anwesen; use anwesen::app::Anwesen;
use anwesen::doctor; use anwesen::doctor;
use anwesen::merge;
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use hydra::Application; use hydra::Application;
@ -38,6 +39,19 @@ fn main() -> Result<()> {
print!("{rendered}"); print!("{rendered}");
std::process::exit(exit); std::process::exit(exit);
} }
Command::Merge(args) => {
init_logging(args.log_level);
match merge::run(&args.vault, &args.query) {
// `print!`, not `println!`: the merged document is byte-stable
// and byte-identical to the HTTP merge body, which carries no
// trailing newline. An empty match set prints nothing, exit 0.
Ok(doc) => print!("{doc}"),
Err(e) => {
eprint!("{}", e.render());
std::process::exit(1);
}
}
}
Command::Version => { Command::Version => {
println!("{}", env!("CARGO_PKG_VERSION")); println!("{}", env!("CARGO_PKG_VERSION"));
} }

205
src/merge.rs Normal file
View file

@ -0,0 +1,205 @@
//! 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);
}
}