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:
parent
03cc12e640
commit
ed1759da59
4 changed files with 293 additions and 5 deletions
74
src/cli.rs
74
src/cli.rs
|
|
@ -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!(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
18
src/main.rs
18
src/main.rs
|
|
@ -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
205
src/merge.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue