diff --git a/src/cli.rs b/src/cli.rs index 4c9d665..c3b1f21 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,12 +5,13 @@ //! ```text //! anwesen serve --vault [--bind ] [--log-level ] //! anwesen doctor --vault [--log-level ] +//! anwesen merge --vault [--query ] [--log-level ] //! anwesen version //! ``` //! -//! `--bind` is `serve`-only; `doctor` does not bind a port; `version` takes -//! no flags. Each flag has a matching `ANWESEN_` environment variable -//! and CLI wins over env per the manual. +//! `--bind` is `serve`-only; `doctor` and `merge` do not bind a port; +//! `version` takes no flags. Each flag has a matching `ANWESEN_` +//! environment variable and CLI wins over env per the manual. use std::net::SocketAddr; use std::path::PathBuf; @@ -30,6 +31,9 @@ pub enum Command { Serve(ServeArgs), /// Walk the vault once and report ingestion blockers. Read-only. 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. Version, } @@ -60,6 +64,25 @@ pub struct DoctorArgs { 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)] #[value(rename_all = "lower")] 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] fn version_takes_no_flags() { assert!(matches!( diff --git a/src/lib.rs b/src/lib.rs index 2fa823f..0111390 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod app; pub mod doctor; pub mod health; pub mod http; +pub mod merge; pub mod query; pub mod store; pub mod vault; diff --git a/src/main.rs b/src/main.rs index 8f9997d..f265ced 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,13 @@ //! Anwesen: read-only HTTP daemon over a markdown vault. //! -//! This module wires the CLI to the (still-stub) `serve` and `doctor` -//! subcommands. The full daemon arrives over [ANW-11..ANW-19]. +//! This module wires the CLI to the `serve`, `doctor`, `merge`, and +//! `version` subcommands. mod cli; use anwesen::app::Anwesen; use anwesen::doctor; +use anwesen::merge; use anyhow::Result; use clap::Parser; use hydra::Application; @@ -38,6 +39,19 @@ fn main() -> Result<()> { print!("{rendered}"); 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 => { println!("{}", env!("CARGO_PKG_VERSION")); } diff --git a/src/merge.rs b/src/merge.rs new file mode 100644 index 0000000..a9b6d2b --- /dev/null +++ b/src/merge.rs @@ -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), + /// 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 { + 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, + "\nalpha\n\n\n\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("