diff --git a/Cargo.lock b/Cargo.lock index b3caf22..5d9e787 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3491,6 +3491,7 @@ dependencies = [ "dateparser", "fs-err", "futures-util", + "globset", "moka", "regex", "reqwest", @@ -3516,7 +3517,6 @@ dependencies = [ "crossterm 0.28.1", "filetime", "futures-util", - "globset", "homedir", "libc", "ratatui", diff --git a/docs/json-rpc-methods.md b/docs/json-rpc-methods.md index 479cc19..b9cfb84 100644 --- a/docs/json-rpc-methods.md +++ b/docs/json-rpc-methods.md @@ -74,7 +74,7 @@ Path rules: | `tail` | `{ path, lines? }` — omit for default 10 last lines; `lines: 0` returns no lines; string `"+N"` = from line _N_ | `{ text }` | | `slice` | `{ path, start_line, end_line }` | `{ text }` (1-based inclusive lines) | | `grep` | `{ path, pattern, max_matches? }` | `[{ line, text }]` (`max_matches` `0` = unlimited) | -| `wc` | `{ path }` | `{ bytes, lines, words, chars }` | +| `wc` | `{ path, recursive?, lines?, words?, bytes?, chars? }` — `path` may use a final-segment glob (`/docs/*`); bare directory errors `wc: PATH: is a directory` unless `recursive: true`. With `recursive`, glob matches on directories expand to all files in each subtree; a bare directory path walks the full tree. Metric booleans select fields (omit for all four). **One resolved file** → flat counts; **two or more files** → `{ files: [{ path, …counts }], total: { …counts } }`. POSIX `bytes` = UTF-8 byte length; `chars` = Unicode scalar count. | Single: `{ bytes, lines, words, chars }` (subset when flags set). Multi: `{ files, total }`. | | `stat` | `{ path }` | `{ id, path, size_bytes, line_count, …timestamps }` | | `test` | `{}` (no keys) | `{ product_name, product_version, uptime, authenticate_api }` — diagnostics; `product_name` is `"tabularium"`, `product_version` is the **server** crate compile-time version, `uptime` is process uptime in nanoseconds (`u64`, saturates at max), `authenticate_api` tells clients whether the main HTTP surface currently requires auth | | `wait` | `{ path }` | `null` when document body changes after the call begins; `-32602` with `"wait timed out"` at server long-poll ceiling | diff --git a/tabularium-cli/Cargo.toml b/tabularium-cli/Cargo.toml index c42f3fe..91ff010 100644 --- a/tabularium-cli/Cargo.toml +++ b/tabularium-cli/Cargo.toml @@ -14,7 +14,6 @@ clap = { version = "4", features = ["derive"] } crossterm = { version = "0.28", features = ["bracketed-paste", "event-stream"] } filetime = "0.2" futures-util = "0.3" -globset = "0.4" homedir = "0.3.6" ratatui = { version = "0.29", default-features = false, features = ["crossterm"] } regex = "1" diff --git a/tabularium-cli/src/execute.rs b/tabularium-cli/src/execute.rs index 6e2500e..e9becf9 100644 --- a/tabularium-cli/src/execute.rs +++ b/tabularium-cli/src/execute.rs @@ -7,17 +7,18 @@ use std::process::{Command as StdCommand, Stdio}; use std::time::UNIX_EPOCH; use filetime::{FileTime, set_file_mtime}; -use globset::GlobBuilder; use regex::Regex; use serde_json::{Value, json}; use tabularium::Error; use tabularium::TailMode; use tabularium::Timestamp; +use tabularium::glob_path::{compile_name_glob, parent_and_glob_pattern, path_has_glob_metachar}; use tabularium::parse_acl_json; use tabularium::resource_path::{normalize_user_path, parent_and_final_name}; -use tabularium::rpc::{Client, ListedEntryRow, SearchHitRow, StatRow}; +use tabularium::rpc::{Client, ListedEntryRow, SearchHitRow, StatRow, WcMetrics, WcResponse}; use tabularium::validate_chat_speaker_id; use tabularium::validate_entity_name; +use tabularium::wc::WcMetricsFilter; use tabularium::ws::RecvMessage; use tokio::io::AsyncBufReadExt; @@ -26,6 +27,14 @@ use crate::render::mad_skin; pub(crate) type BoxErr = Box; +pub(crate) fn print_cli_message(msg: &str) { + let msg = msg + .strip_prefix("invalid input: ") + .or_else(|| msg.strip_prefix("not found: ")) + .unwrap_or(msg); + eprintln!("{msg}"); +} + #[derive(Debug, PartialEq, Eq)] pub(crate) enum ChatSubmitAction { Ignore, @@ -134,27 +143,12 @@ async fn expand_final_segment_glob( client: &Client, normalized_path: &str, ) -> Result>, BoxErr> { - if normalized_path == "/" { - return Ok(None); - } - let (parent, last) = normalized_path - .rsplit_once('/') - .map(|(p, n)| { - let p = if p.is_empty() { - "/".to_string() - } else { - p.to_string() - }; - (p, n.to_string()) - }) - .ok_or_else(|| -> BoxErr { "invalid path".into() })?; - if !path_has_glob_metachar(&last) { + let Some((parent, pattern)) = + parent_and_glob_pattern(normalized_path).map_err(|e| -> BoxErr { e.to_string().into() })? + else { return Ok(None); - } - if path_has_glob_metachar(&parent) { - return Err("wildcards are only allowed in the final path segment".into()); - } - let matcher = compile_name_glob(&last)?; + }; + let matcher = compile_name_glob(&pattern).map_err(|e| -> BoxErr { e.to_string().into() })?; let rows = client.list_directory(&parent).await?; let mut out: Vec<_> = rows .into_iter() @@ -168,6 +162,77 @@ async fn expand_final_segment_glob( Ok(Some(out)) } +fn wc_metric_values(m: &WcMetrics, filter: WcMetricsFilter) -> Vec { + filter.format_optional_counts(m.lines(), m.words(), m.bytes(), m.chars()) +} + +fn print_wc_gnu_line(width: usize, values: &[String], label: &str) { + let counts = values.join("\t"); + if label.is_empty() { + println!("{counts}"); + } else { + println!("{counts:>width$} {label}", width = width.max(1)); + } +} + +fn print_wc_output(resp: &WcResponse, filter: WcMetricsFilter) -> Result<(), BoxErr> { + let tty = io::stdout().is_terminal(); + let multi = resp.is_batch(); + + if multi { + let files = resp.files().unwrap_or(&[]); + let total = resp.total().cloned().unwrap_or_default(); + let mut rows_for_width: Vec> = Vec::new(); + for f in files { + rows_for_width.push(wc_metric_values(f.metrics(), filter)); + } + rows_for_width.push(wc_metric_values(&total, filter)); + let width = rows_for_width + .iter() + .flat_map(|r| r.iter().map(String::len)) + .max() + .unwrap_or(1); + + if tty && filter.active_columns() > 1 { + let headers = filter.column_labels(); + let mut table_rows: Vec> = files + .iter() + .map(|f| { + let mut row = vec![f.path().to_string()]; + row.extend(wc_metric_values(f.metrics(), filter)); + row + }) + .collect(); + let mut total_row = vec!["total".to_string()]; + total_row.extend(wc_metric_values(&total, filter)); + table_rows.push(total_row); + let mut hdr = vec!["path"]; + hdr.extend(headers); + print_cli_table(true, &hdr, &table_rows); + return Ok(()); + } + + for f in files { + print_wc_gnu_line(width, &wc_metric_values(f.metrics(), filter), f.path()); + } + print_wc_gnu_line(width, &wc_metric_values(&total, filter), "total"); + return Ok(()); + } + + let m = resp.single_metrics(); + let values = wc_metric_values(&m, filter); + if tty && filter.active_columns() > 1 { + print_cli_table(true, &filter.column_labels(), &[values]); + return Ok(()); + } + if filter.active_columns() == 1 { + println!("{}", values.first().map(String::as_str).unwrap_or("0")); + return Ok(()); + } + print_wc_gnu_line(0, &values, ""); + Ok(()) +} + async fn resolve_read_file_paths(client: &Client, user_path: &str) -> Result, BoxErr> { let norm = normalize_user_path(user_path.trim()).map_err(|e| -> BoxErr { e.to_string().into() })?; @@ -1257,71 +1322,17 @@ pub(crate) async fn execute( validate_entity_name(name.trim()).map_err(|e| -> BoxErr { e.to_string().into() })?; client.psk_destroy(name.trim()).await?; } - Command::Wc { path } => { - let paths = resolve_read_file_paths(client, path.trim()).await?; - let multi = paths.len() > 1; - if io::stdout().is_terminal() { - let mut table_rows: Vec> = Vec::new(); - let mut sum_b = 0u64; - let mut sum_l = 0usize; - let mut sum_w = 0usize; - let mut sum_c = 0usize; - for p in &paths { - let w = client.document_wc(p).await?; - sum_b += w.bytes(); - sum_l += w.lines(); - sum_w += w.words(); - sum_c += w.chars(); - if multi { - table_rows.push(vec![ - p.clone(), - w.bytes().to_string(), - w.lines().to_string(), - w.words().to_string(), - w.chars().to_string(), - ]); - } else { - table_rows.push(vec![ - w.bytes().to_string(), - w.lines().to_string(), - w.words().to_string(), - w.chars().to_string(), - ]); - } - } - if multi { - table_rows.push(vec![ - "total".to_string(), - sum_b.to_string(), - sum_l.to_string(), - sum_w.to_string(), - sum_c.to_string(), - ]); - print_cli_table( - true, - &["path", "bytes", "lines", "words", "chars"], - &table_rows, - ); - } else { - print_cli_table(true, &["bytes", "lines", "words", "chars"], &table_rows); - } - } else { - for p in &paths { - let w = client.document_wc(p).await?; - if multi { - println!( - "{}\t{}\t{}\t{}\t{}", - p, - w.bytes(), - w.lines(), - w.words(), - w.chars() - ); - } else { - println!("{}\t{}\t{}\t{}", w.bytes(), w.lines(), w.words(), w.chars()); - } - } - } + Command::Wc { + path, + lines, + words, + bytes, + chars, + recursive, + } => { + let filter = WcMetricsFilter::from_cli_flags(lines, words, bytes, chars); + let resp = client.wc(path.trim(), filter, recursive).await?; + print_wc_output(&resp, filter)?; } Command::Edit { path } => { let path = @@ -1514,18 +1525,6 @@ struct FindRow { description: Option, } -fn path_has_glob_metachar(path: &str) -> bool { - path.chars().any(|c| matches!(c, '*' | '?' | '[')) -} - -fn compile_name_glob(pat: &str) -> Result { - Ok(GlobBuilder::new(pat) - .literal_separator(true) - .build() - .map_err(|e| -> BoxErr { format!("invalid glob pattern: {e}").into() })? - .compile_matcher()) -} - async fn cmd_rm(client: &Client, path: &str, recursive: bool) -> Result<(), BoxErr> { if !path_has_glob_metachar(path) { return cmd_rm_literal(client, path, recursive).await; diff --git a/tabularium-cli/src/main.rs b/tabularium-cli/src/main.rs index 2b17dd2..89e1317 100644 --- a/tabularium-cli/src/main.rs +++ b/tabularium-cli/src/main.rs @@ -298,8 +298,21 @@ pub(crate) enum Command { Touch { path: String, time: Option }, /// Block until `DIR/FILE` changes (server long-poll timeout applies). Wait { path: String }, - /// Byte/line/word/char counts. - Wc { path: String }, + /// Line/word/byte/char counts (`-l`/`-w`/`-c`/`-m`; default GNU columns: lines, words, bytes). Final-segment glob (`/docs/*`) counts files in one directory level; `-r` walks subtrees (glob directories or a bare directory path). + Wc { + path: String, + #[arg(short = 'l')] + lines: bool, + #[arg(short = 'w')] + words: bool, + #[arg(short = 'c')] + bytes: bool, + #[arg(short = 'm')] + chars: bool, + /// Recursive file walk (required for bare directories; with globs, expands into matching directory subtrees). + #[arg(short = 'r', long = "recursive")] + recursive: bool, + }, /// Show resolved ACL for the current key (`whoami` RPC). Whoami, } @@ -337,7 +350,10 @@ async fn main() -> Result<(), Box> { match execute::execute(&client, cmd, execute::ExecuteContext::Cli, None, opts).await { Ok(execute::ExecuteOutcome::Ok) => {} Ok(execute::ExecuteOutcome::Interrupted) => std::process::exit(130), - Err(e) => return Err(e), + Err(e) => { + execute::print_cli_message(&e.to_string()); + std::process::exit(1); + } } } } diff --git a/tabularium-cli/src/shell.rs b/tabularium-cli/src/shell.rs index 7bc917b..01e2d5a 100644 --- a/tabularium-cli/src/shell.rs +++ b/tabularium-cli/src/shell.rs @@ -23,7 +23,7 @@ use tokio::runtime::Handle; use crate::execute::{ ExecuteContext, ExecuteOutcome, ShellChildRpc, cmd_mcd, execute, execute_opts_from_command, - join_abs_dir_entry, + join_abs_dir_entry, print_cli_message, }; use crate::shell_path::{ resolve_ls_directory, resolve_shell_doc_path, resolve_shell_rm_path, resolve_shell_tree_scope, @@ -168,6 +168,7 @@ fn flags_for(sub: &str) -> &'static [&'static str] { "find" | "search" => &["-d", "--directory"], "grep" => &["-m", "-v", "--invert-match"], "head" => &["-n", "--raw"], + "wc" => &["-l", "-w", "-c", "-m", "-r", "--recursive"], "import" => &["-n", "--name"], "l" | "ll" | "ls" => &["-t", "--time", "-r", "--reverse"], "lt" => &["-r", "--reverse"], @@ -1434,8 +1435,20 @@ pub(crate) fn apply_shell_cwd(cmd: Command, shell_cwd: Option<&str>) -> Result Command::Stat { path: resolve_shell_doc_path(&path, shell_cwd)?, }, - Command::Wc { path } => Command::Wc { + Command::Wc { + path, + lines, + words, + bytes, + chars, + recursive, + } => Command::Wc { path: resolve_shell_doc_path(&path, shell_cwd)?, + lines, + words, + bytes, + chars, + recursive, }, Command::Edit { path } => Command::Edit { path: resolve_shell_doc_path(&path, shell_cwd)?, @@ -1863,7 +1876,7 @@ fn run_shell_blocking( "help" | "h" | "?" => { push_history(&mut rl, &history_path, trimmed)?; if let Err(e) = print_shell_help() { - eprintln!("{e}"); + print_cli_message(&e.to_string()); } continue; } @@ -1881,7 +1894,7 @@ fn run_shell_blocking( push_history(&mut rl, &history_path, trimmed)?; } Err(e) => { - eprintln!("{e}"); + print_cli_message(&e.to_string()); push_history(&mut rl, &history_path, trimmed)?; } } @@ -1894,7 +1907,7 @@ fn run_shell_blocking( push_history(&mut rl, &history_path, trimmed)?; } Err(e) => { - eprintln!("{e}"); + print_cli_message(&e.to_string()); push_history(&mut rl, &history_path, trimmed)?; } } @@ -1913,7 +1926,7 @@ fn run_shell_blocking( push_history(&mut rl, &history_path, trimmed)?; } Err(e) => { - eprintln!("{e}"); + print_cli_message(&e.to_string()); push_history(&mut rl, &history_path, trimmed)?; } } @@ -1929,7 +1942,7 @@ fn run_shell_blocking( continue; } if let Err(e) = exec_shell_line(script) { - eprintln!("{e}"); + print_cli_message(&e.to_string()); } push_history(&mut rl, &history_path, trimmed)?; continue; @@ -1946,7 +1959,7 @@ fn run_shell_blocking( ) { Ok(p) => p, Err(e) => { - eprintln!("{e}"); + print_cli_message(&e.to_string()); push_history(&mut rl, &history_path, trimmed)?; continue; } @@ -1959,7 +1972,7 @@ fn run_shell_blocking( match apply_shell_cwd(parsed.command, g.cwd.as_deref()) { Ok(c) => c, Err(e) => { - eprintln!("{e}"); + print_cli_message(&e.to_string()); push_history(&mut rl, &history_path, trimmed)?; continue; } @@ -1988,7 +2001,7 @@ fn run_shell_blocking( g.invalidate_all(); } } - Err(e) => eprintln!("{e}"), + Err(e) => print_cli_message(&e.to_string()), } rl = build_editor( Arc::clone(&client), @@ -2020,7 +2033,7 @@ fn run_shell_blocking( push_history(&mut rl, &history_path, trimmed)?; } Err(e) => { - eprintln!("{e}"); + print_cli_message(&e.to_string()); push_history(&mut rl, &history_path, trimmed)?; } } @@ -2337,7 +2350,17 @@ mod shell_cwd_tests { "c/d", ), (Command::Stat { path: "d".into() }, "c/d"), - (Command::Wc { path: "d".into() }, "c/d"), + ( + Command::Wc { + path: "d".into(), + lines: false, + words: false, + bytes: false, + chars: false, + recursive: false, + }, + "c/d", + ), (Command::Ec { path: "d".into() }, "c/d"), (Command::Edit { path: "d".into() }, "c/d"), (Command::Wait { path: "d".into() }, "c/d"), @@ -2358,7 +2381,7 @@ mod shell_cwd_tests { | Command::Chat { path: p, .. } | Command::Slice { path: p, .. } | Command::Stat { path: p } - | Command::Wc { path: p } + | Command::Wc { path: p, .. } | Command::Ec { path: p } | Command::Edit { path: p } | Command::Wait { path: p } @@ -2703,6 +2726,11 @@ mod shell_cwd_tests { ( Command::Wc { path: "/c/d".into(), + lines: false, + words: false, + bytes: false, + chars: false, + recursive: false, }, "c/d", ), @@ -2738,7 +2766,7 @@ mod shell_cwd_tests { | Command::Chat { path: p, .. } | Command::Slice { path: p, .. } | Command::Stat { path: p } - | Command::Wc { path: p } + | Command::Wc { path: p, .. } | Command::Ec { path: p } | Command::Edit { path: p } | Command::Wait { path: p } @@ -2750,6 +2778,49 @@ mod shell_cwd_tests { } } + #[test] + fn wc_parses_metric_flags() { + use crate::ShellCommandOnly; + use clap::Parser; + let c = ShellCommandOnly::parse_from(["tb", "wc", "-l", "-w", "/docs/*"]); + let Command::Wc { + path, + lines, + words, + bytes, + chars, + recursive, + } = c.command + else { + panic!("expected wc"); + }; + assert_eq!(path, "/docs/*"); + assert!(lines); + assert!(words); + assert!(!bytes); + assert!(!chars); + assert!(!recursive); + } + + #[test] + fn wc_parses_recursive_flag() { + use crate::ShellCommandOnly; + use clap::Parser; + let c = ShellCommandOnly::parse_from(["tb", "wc", "-l", "-r", "projects/bms/*"]); + let Command::Wc { + path, + lines, + recursive, + .. + } = c.command + else { + panic!("expected wc"); + }; + assert_eq!(path, "projects/bms/*"); + assert!(lines); + assert!(recursive); + } + #[test] fn grep_and_mv_absolute_paths_strip() { let g = unwrap_ac( diff --git a/tabularium-server/res/mcp/help.txt b/tabularium-server/res/mcp/help.txt index 22a8059..556a755 100644 --- a/tabularium-server/res/mcp/help.txt +++ b/tabularium-server/res/mcp/help.txt @@ -10,6 +10,8 @@ When **`[mcp].authenticate = true`** and **`mcp.full = true`**, send **`X-Auth-K **Doctrine — three rites, do not confuse them:** **find** (tree walk) = locate files and directories by path or name in the tree, using repeated **`list_directory`** calls — there is no separate MCP “find” tool. **search** = indexed full-text across **all document bodies** (Tantivy). **grep** = regex line matches **inside one document** only. +**wc** — line/word/byte/char counts (like `tb wc`). Params: `path`; optional `recursive`, `lines`, `words`, `bytes`, `chars` (booleans). Glob allowed only in the **last path segment** (`/docs/*.md`, `/projects/bms/*`). Without `recursive`, only **files directly in that folder** match. With `recursive: true`, each matched **directory** is walked recursively; a bare directory path also needs `recursive`. **One resolved file** → flat counts; **two or more** → `{ files, total }`. Examples: `{ "path": "/docs/readme.md", "lines": true }`; `{ "path": "/docs/*.md", "lines": true }`; `{ "path": "/projects/bms/*", "recursive": true, "lines": true }` when `bms` contains only subfolders; `{ "path": "/projects/bms", "recursive": true, "bytes": true }` for the whole tree. `bytes` = UTF-8 length (POSIX `-c`); `chars` = Unicode scalars. + **append_if_not_contains** (`path`, `marker`, `content`) appends raw bytes only when `marker` is not already a **substring** of the UTF-8 body (same semantics as Rust `str::contains`). The file **must exist** (otherwise the call errors). Returns JSON `{ "appended": bool, "revision": string }` — use `appended` like the older boolean wire shape; `revision` is the post-call file token. **Revision (UUID v4)** tags each **file** row for optimistic concurrency. It advances only on **writes** (content/metadata mutations such as put, append, touch_document on a file, describe on a file, rename/move file). Passive reads update **`accessed_at`** only via `get_document` / `stat` / listing — they **do not** change `revision`. Directories keep `revision` null. **`only_if_revision`** on **`put_document`** or **`create_document`** (with `force=true` when replacing an existing file) performs compare-and-swap: mismatch → **`RevisionMismatch`** (-32003); missing file when `only_if_revision` is set → **`NotFound`** (-32603). When present, **`only_if_revision` overrides `force`** for stale-write detection (force cannot bypass a revision mismatch). diff --git a/tabularium-server/src/mcp.rs b/tabularium-server/src/mcp.rs index 35cf115..5a94614 100644 --- a/tabularium-server/src/mcp.rs +++ b/tabularium-server/src/mcp.rs @@ -98,7 +98,13 @@ create_directory — path, description optional, parents optional (`true` = POSI describe — path; optional description string (omit to read; empty string clears). document_exists — path (wire RPC name `exists`; tests file only). stat — path (includes `revision` for files). -wc — path. +wc — path; optional `recursive`, `lines`, `words`, `bytes`, `chars` (booleans). Count lines/words/bytes/chars like CLI `tb wc`. `path` may use a **final-segment glob** only (`/docs/*.md`, `/projects/bms/*`). Without `recursive`, glob matches **files directly in that folder** (not subfolders). With `recursive: true`, each **matched directory** is walked for all files beneath it; a **bare directory path** also requires `recursive`. Omit metric flags → all four fields; set flags to select columns (`bytes` = UTF-8 length, POSIX `wc -c`; `chars` = Unicode scalars). Response: **one resolved file** → flat `{ bytes, lines, words, chars }` (subset when flags set); **two or more files** → `{ "files": [{ "path", …counts }], "total": { …counts } }`. Errors: bare directory without `recursive` → `wc: PATH: is a directory`; glob matches only subdirectories without `recursive` → hint to use `recursive`. + Examples (request → shape): + • One file, all metrics: `{ "path": "/docs/readme.md" }` → `{ "bytes", "lines", "words", "chars" }` + • Lines only: `{ "path": "/docs/readme.md", "lines": true }` → `{ "lines": 42 }` + • Markdown in one folder (non-recursive): `{ "path": "/docs/*.md", "lines": true }` → `{ "files": [{ "path", "lines" }, …], "total": { "lines" } }` + • Project tree via glob on subdirs: `{ "path": "/projects/bms/*", "recursive": true, "lines": true }` → all files under each immediate child of `bms` + • Whole subtree: `{ "path": "/projects/bms", "recursive": true, "bytes": true }` → every file under `bms` head — path, lines optional (default 10 like GNU head); number or string integer (`0` = zero lines, not unlimited). tail — path, lines optional (default 10 last lines like GNU tail); number, string integer (`0` = zero lines), or "+N" from-line form. slice — path, start_line/end_line or from_line/to_line aliases (1-based inclusive); numbers or string integers. @@ -358,6 +364,21 @@ struct GrepArg { invert_match: Option, } +#[derive(Deserialize, JsonSchema)] +struct WcArg { + path: String, + #[serde(default)] + recursive: Option, + #[serde(default)] + lines: Option, + #[serde(default)] + words: Option, + #[serde(default)] + bytes: Option, + #[serde(default)] + chars: Option, +} + #[derive(Deserialize, JsonSchema)] struct DeleteDirectoryArg { path: String, @@ -656,14 +677,43 @@ impl TabulariumMcp { .await } - #[tool(description = "Line/word/byte counts (JSON-RPC wc).")] + #[tool( + description = r#"Line/word/byte/char counts (JSON-RPC wc). Like `tb wc -l/-w/-c/-m` and `-r`. + +Params: `path` (required); optional booleans `recursive`, `lines`, `words`, `bytes`, `chars`. Final-segment glob only (`/docs/*.md`, `/projects/bms/*`). Without `recursive`, glob counts files in one directory level. With `recursive`, matched directories and bare directory paths walk full subtrees. Omit metric flags for all four counts. + +Response: single file → flat object; two or more files → `{ "files": [{ "path", … }], "total": { … } }`. + +Examples: +• `{ "path": "/docs/readme.md" }` — all metrics on one file +• `{ "path": "/docs/readme.md", "lines": true }` — lines only +• `{ "path": "/docs/*.md", "lines": true }` — each `.md` in `/docs` + total +• `{ "path": "/projects/bms/*", "recursive": true, "lines": true }` — lines in every file under each child of `bms` (use when the folder contains only subdirectories) +• `{ "path": "/projects/bms", "recursive": true, "bytes": true }` — UTF-8 bytes for the whole `bms` tree"# + )] async fn wc( &self, Extension(parts): Extension, - Parameters(p): Parameters, + Parameters(p): Parameters, ) -> Result { - self.call_rpc_json(&parts, "wc", json!({ "path": p.path })) - .await + let mut m = Map::new(); + m.insert("path".into(), json!(p.path)); + if let Some(v) = p.recursive { + m.insert("recursive".into(), json!(v)); + } + if let Some(v) = p.lines { + m.insert("lines".into(), json!(v)); + } + if let Some(v) = p.words { + m.insert("words".into(), json!(v)); + } + if let Some(v) = p.bytes { + m.insert("bytes".into(), json!(v)); + } + if let Some(v) = p.chars { + m.insert("chars".into(), json!(v)); + } + self.call_rpc_json(&parts, "wc", Value::Object(m)).await } #[tool( diff --git a/tabularium-server/src/web.rs b/tabularium-server/src/web.rs index 0d19ae9..c3e94e7 100644 --- a/tabularium-server/src/web.rs +++ b/tabularium-server/src/web.rs @@ -1309,15 +1309,37 @@ pub(crate) async fn dispatch_app_rpc( } "wc" => { let path = rpc_path(&m, "path")?; - check_read(auth, &path).map_err(RpcAppError::Other)?; - let fid = st.db.resolve_existing_file_path(&path).await?; - let w = st.db.document_wc(fid).await?; - Ok(json!({ - "bytes": w.bytes(), - "lines": w.lines(), - "words": w.words(), - "chars": w.chars(), - })) + let filter = tabularium::wc::WcMetricsFilter::from_rpc_params(&m); + let glob_parent = tabularium::glob_path::parent_and_glob_pattern(&path) + .map_err(RpcAppError::Other)?; + if let Some((parent, _)) = glob_parent { + check_read(auth, &parent).map_err(RpcAppError::Other)?; + } else { + check_read(auth, &path).map_err(RpcAppError::Other)?; + } + let recursive = m.get("recursive").and_then(Value::as_bool).unwrap_or(false); + let paths = tabularium::glob_path::resolve_wc_file_paths(&st.db, &path, recursive) + .await + .map_err(RpcAppError::Other)?; + let mut stats: Vec = Vec::with_capacity(paths.len()); + for p in &paths { + check_read(auth, p).map_err(RpcAppError::Other)?; + let fid = st.db.resolve_existing_file_path(p).await?; + stats.push(st.db.document_wc(fid).await?); + } + if paths.len() > 1 { + let files: Vec = paths + .iter() + .zip(&stats) + .map(|(p, w)| tabularium::wc::wc_file_to_value(p, w, &filter)) + .collect(); + let total = tabularium::wc::sum_wc_stats(&stats); + return Ok(json!({ + "files": files, + "total": tabularium::wc::wc_stats_to_value(&total, &filter), + })); + } + Ok(tabularium::wc::wc_stats_to_value(&stats[0], &filter)) } "stat" => { let path = rpc_path(&m, "path")?; diff --git a/tabularium-server/tests/text_ops.rs b/tabularium-server/tests/text_ops.rs index 308953e..5e954e4 100644 --- a/tabularium-server/tests/text_ops.rs +++ b/tabularium-server/tests/text_ops.rs @@ -199,3 +199,176 @@ async fn rpc_tail_plus_n_form() { assert_eq!(v["error"]["code"], -32602, "bad lines: {bad}"); } } + +#[tokio::test] +async fn rpc_wc_glob_batch_and_directory_guard() { + let s = spawn_test_server().await; + let base = &s.base_url; + let client = reqwest::Client::new(); + + let cat = "text_wc_glob_cat"; + client + .post(format!("{base}/api/doc")) + .json(&json!({ "path": format!("/{cat}"), "description": null })) + .send() + .await + .unwrap(); + for (name, body) in [("a.md", "one two\n"), ("b.md", "three\nfour")] { + client + .put(format!("{base}/api/doc/{cat}/{name}")) + .json(&json!({ "content": body })) + .send() + .await + .unwrap(); + } + + let v = rpc( + &client, + base, + "wc", + json!({ "path": format!("/{cat}/*"), "lines": true }), + ) + .await; + let files = v["result"]["files"].as_array().unwrap(); + assert_eq!(files.len(), 2); + assert_eq!(v["result"]["total"]["lines"], 3); + + let v = rpc( + &client, + base, + "wc", + json!({ "path": format!("/{cat}/a.md"), "lines": true }), + ) + .await; + assert_eq!(v["result"]["lines"], 1); + assert!(v["result"]["files"].is_null()); + + let v = rpc(&client, base, "wc", json!({ "path": format!("/{cat}") })).await; + let msg = v["error"]["message"].as_str().unwrap_or(""); + assert!(msg.contains("is a directory"), "{msg}"); + assert!(msg.contains("recursive"), "{msg}"); +} + +#[tokio::test] +async fn rpc_wc_glob_single_match_returns_flat() { + let s = spawn_test_server().await; + let base = &s.base_url; + let client = reqwest::Client::new(); + + let cat = "text_wc_glob_one"; + client + .post(format!("{base}/api/doc")) + .json(&json!({ "path": format!("/{cat}"), "description": null })) + .send() + .await + .unwrap(); + client + .put(format!("{base}/api/doc/{cat}/solo.md")) + .json(&json!({ "content": "only file\n" })) + .send() + .await + .unwrap(); + + let v = rpc( + &client, + base, + "wc", + json!({ "path": format!("/{cat}/*.md"), "lines": true }), + ) + .await; + assert_eq!(v["result"]["lines"], 1); + assert!(v["result"]["files"].is_null()); +} + +#[tokio::test] +async fn rpc_wc_glob_dirs_only_suggests_recursive() { + let s = spawn_test_server().await; + let base = &s.base_url; + let client = reqwest::Client::new(); + + let cat = "text_wc_dirs_only"; + client + .post(format!("{base}/api/doc")) + .json(&json!({ "path": format!("/{cat}"), "description": null })) + .send() + .await + .unwrap(); + client + .post(format!("{base}/api/doc")) + .json(&json!({ "path": format!("/{cat}/inner"), "description": null })) + .send() + .await + .unwrap(); + + let v = rpc( + &client, + base, + "wc", + json!({ "path": format!("/{cat}/*"), "lines": true }), + ) + .await; + let msg = v["error"]["message"].as_str().unwrap_or(""); + assert!(msg.contains("recursive"), "{msg}"); + assert!(msg.contains("director"), "{msg}"); +} + +#[tokio::test] +async fn rpc_wc_recursive_glob_and_directory() { + let s = spawn_test_server().await; + let base = &s.base_url; + let client = reqwest::Client::new(); + + let cat = "text_wc_rec_cat"; + client + .post(format!("{base}/api/doc")) + .json(&json!({ "path": format!("/{cat}"), "description": null })) + .send() + .await + .unwrap(); + client + .post(format!("{base}/api/doc")) + .json(&json!({ "path": format!("/{cat}/inner"), "description": null })) + .send() + .await + .unwrap(); + client + .put(format!("{base}/api/doc/{cat}/inner/a.md")) + .json(&json!({ "content": "one\n" })) + .send() + .await + .unwrap(); + client + .put(format!("{base}/api/doc/{cat}/inner/b.md")) + .json(&json!({ "content": "two\nthree\n" })) + .send() + .await + .unwrap(); + + let v = rpc( + &client, + base, + "wc", + json!({ + "path": format!("/{cat}/*"), + "recursive": true, + "lines": true, + }), + ) + .await; + let files = v["result"]["files"].as_array().unwrap(); + assert_eq!(files.len(), 2); + assert_eq!(v["result"]["total"]["lines"], 3); + + let v = rpc( + &client, + base, + "wc", + json!({ + "path": format!("/{cat}"), + "recursive": true, + "lines": true, + }), + ) + .await; + assert_eq!(v["result"]["total"]["lines"], 3); +} diff --git a/tabularium/Cargo.toml b/tabularium/Cargo.toml index 73183c8..534245a 100644 --- a/tabularium/Cargo.toml +++ b/tabularium/Cargo.toml @@ -21,6 +21,7 @@ db = [ "dep:dashmap", "dep:dateparser", "dep:fs-err", + "dep:globset", "dep:moka", "dep:regex", "dep:serde_json", @@ -47,6 +48,7 @@ dashmap = { version = "6", optional = true } dateparser = { version = "0.2", optional = true } futures-util = { version = "0.3", default-features = false, features = ["sink"], optional = true } fs-err = { version = "3", optional = true } +globset = { version = "0.4", optional = true } moka = { version = "0.12", features = ["future"], optional = true } regex = { version = "1", optional = true } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } diff --git a/tabularium/src/db/meta.rs b/tabularium/src/db/meta.rs index 050ba29..a8f1134 100644 --- a/tabularium/src/db/meta.rs +++ b/tabularium/src/db/meta.rs @@ -45,6 +45,15 @@ impl WcStats { } } + pub(crate) fn from_totals(bytes: u64, lines: usize, words: usize, chars: usize) -> Self { + Self { + bytes, + lines, + words, + chars, + } + } + pub fn bytes(&self) -> u64 { self.bytes } diff --git a/tabularium/src/glob_path.rs b/tabularium/src/glob_path.rs new file mode 100644 index 0000000..2e48724 --- /dev/null +++ b/tabularium/src/glob_path.rs @@ -0,0 +1,165 @@ +//! Final-segment glob expansion and directory detection for path-oriented RPC/CLI. + +use globset::{GlobBuilder, GlobMatcher}; + +use crate::EntryKind; +use crate::db::{Database, Storage}; +use crate::resource_path::{join_under_directory, parent_and_final_name}; +use crate::{Error, Result}; + +/// Whether `segment` contains glob metacharacters. +pub fn path_has_glob_metachar(segment: &str) -> bool { + segment.chars().any(|c| matches!(c, '*' | '?' | '[')) +} + +/// Compile a glob that matches a single path segment name (no `/` in pattern). +pub fn compile_name_glob(pat: &str) -> Result { + let matcher = GlobBuilder::new(pat) + .literal_separator(true) + .build() + .map_err(|e| Error::InvalidInput(format!("invalid glob pattern: {e}")))? + .compile_matcher(); + Ok(matcher) +} + +/// Split a normalized non-root path into parent directory and final segment when the final segment has glob metacharacters. +pub fn parent_and_glob_pattern(normalized_path: &str) -> Result> { + if normalized_path == "/" { + return Ok(None); + } + let (parent, last) = parent_and_final_name(normalized_path)?; + if !path_has_glob_metachar(&last) { + return Ok(None); + } + if path_has_glob_metachar(&parent) { + return Err(Error::InvalidInput( + "wildcards are only allowed in the final path segment".into(), + )); + } + Ok(Some((parent, last))) +} + +/// Collect every file path under `dir_path` (depth-first, sorted siblings). +pub async fn collect_files_recursive( + db: &Database, + dir_path: &str, + out: &mut Vec, +) -> Result<()> { + let entries = db.list_directory(dir_path).await?; + for e in entries { + let full = join_under_directory(dir_path, e.name()); + if e.kind() == EntryKind::File { + out.push(full); + } else { + Box::pin(collect_files_recursive(db, &full, out)).await?; + } + } + Ok(()) +} + +/// Expand final-segment glob; with `recursive`, matching directories contribute all files in their subtrees. +pub async fn expand_final_segment_glob_files_opts( + db: &Database, + normalized_path: &str, + recursive: bool, +) -> Result>> { + let Some((parent, pattern)) = parent_and_glob_pattern(normalized_path)? else { + return Ok(None); + }; + let entries = db.list_directory(&parent).await?; + let matcher = compile_name_glob(&pattern)?; + let mut paths: Vec = Vec::new(); + let mut matched_dirs = 0usize; + for e in entries { + if !matcher.is_match(e.name()) { + continue; + } + let full = join_under_directory(&parent, e.name()); + match e.kind() { + EntryKind::File => paths.push(full), + EntryKind::Dir if recursive => { + collect_files_recursive(db, &full, &mut paths).await?; + } + EntryKind::Dir => { + matched_dirs += 1; + } + } + } + paths.sort(); + if paths.is_empty() { + if matched_dirs > 0 && !recursive { + return Err(Error::InvalidInput(format!( + "wc: no files match {normalized_path} ({matched_dirs} director{} matched; use -r/--recursive to count files under them)", + if matched_dirs == 1 { "y" } else { "ies" } + ))); + } + return Err(Error::InvalidInput(format!( + "wc: no files match {normalized_path}" + ))); + } + Ok(Some(paths)) +} + +/// Whether `path` resolves to an existing directory. +pub async fn path_is_directory(db: &Database, path: &str) -> Result { + Ok(db.resolve_directory_path(path).await.is_ok()) +} + +/// Resolve `wc` targets: glob expansion, optional recursive directory walk, or a single file path. +pub async fn resolve_wc_file_paths( + db: &Database, + normalized_path: &str, + recursive: bool, +) -> Result> { + if let Some(paths) = + expand_final_segment_glob_files_opts(db, normalized_path, recursive).await? + { + return Ok(paths); + } + if path_is_directory(db, normalized_path).await? { + if recursive { + let mut paths = Vec::new(); + collect_files_recursive(db, normalized_path, &mut paths).await?; + paths.sort(); + if paths.is_empty() { + return Err(Error::InvalidInput(format!( + "no files under {normalized_path}" + ))); + } + return Ok(paths); + } + return Err(Error::InvalidInput(format!( + "wc: {normalized_path}: is a directory (use -r/--recursive to count files under it)" + ))); + } + Ok(vec![normalized_path.to_string()]) +} + +#[cfg(test)] +mod tests { + use super::{compile_name_glob, parent_and_glob_pattern, path_has_glob_metachar}; + + #[test] + fn detects_glob_metachar() { + assert!(path_has_glob_metachar("*.md")); + assert!(!path_has_glob_metachar("readme.md")); + } + + #[test] + fn parent_and_glob_pattern_splits() { + let g = parent_and_glob_pattern("/docs/*.md").unwrap(); + assert_eq!(g, Some(("/docs".into(), "*.md".into()))); + assert!( + parent_and_glob_pattern("/docs/readme.md") + .unwrap() + .is_none() + ); + } + + #[test] + fn compile_name_glob_matches() { + let m = compile_name_glob("*.md").unwrap(); + assert!(m.is_match("a.md")); + assert!(!m.is_match("a.txt")); + } +} diff --git a/tabularium/src/lib.rs b/tabularium/src/lib.rs index 0acd852..ddcf3f8 100644 --- a/tabularium/src/lib.rs +++ b/tabularium/src/lib.rs @@ -19,11 +19,15 @@ pub mod acl; pub mod client_headers; #[cfg(feature = "db")] pub mod db; +#[cfg(feature = "db")] +pub mod glob_path; pub mod resource_path; #[cfg(feature = "client")] pub mod rpc; #[cfg(feature = "db")] pub mod text_lines; +#[cfg(feature = "db")] +pub mod wc; #[cfg(feature = "client")] pub mod ws; @@ -49,3 +53,5 @@ pub use reqwest::header::HeaderMap; #[cfg(feature = "db")] pub use text_lines::TailMode; pub use validation::{validate_chat_speaker_id, validate_entity_name}; +#[cfg(feature = "db")] +pub use wc::WcMetricsFilter; diff --git a/tabularium/src/rpc/client.rs b/tabularium/src/rpc/client.rs index 53ae7ac..09aab48 100644 --- a/tabularium/src/rpc/client.rs +++ b/tabularium/src/rpc/client.rs @@ -535,7 +535,7 @@ impl Client { serde_json::from_value(r).map_err(|e| Error::InvalidInput(e.to_string())) } - /// `wc` RPC. + /// `wc` RPC (single-file legacy shape only). pub async fn document_wc(&self, path: impl AsRef) -> Result { let path = normalize_path_for_rpc(path)?; let params = json!({ "path": path }); @@ -543,6 +543,36 @@ impl Client { serde_json::from_value(r).map_err(|e| Error::InvalidInput(e.to_string())) } + /// `wc` RPC with optional metric flags, glob paths, and recursive directory walk. + pub async fn wc( + &self, + path: impl AsRef, + filter: crate::wc::WcMetricsFilter, + recursive: bool, + ) -> Result { + let path = normalize_path_for_rpc(path)?; + let mut params = json!({ "path": path }); + if let Some(obj) = params.as_object_mut() { + if recursive { + obj.insert("recursive".into(), json!(true)); + } + if filter.lines { + obj.insert("lines".into(), json!(true)); + } + if filter.words { + obj.insert("words".into(), json!(true)); + } + if filter.bytes { + obj.insert("bytes".into(), json!(true)); + } + if filter.chars { + obj.insert("chars".into(), json!(true)); + } + } + let r = self.call("wc", params).await?; + serde_json::from_value(r).map_err(|e| Error::InvalidInput(e.to_string())) + } + /// `rename_directory` RPC; both paths are absolute; same parent (rename last segment). pub async fn rename_directory( &self, @@ -787,7 +817,7 @@ impl StatRow { } } -/// `wc` RPC payload. +/// `wc` RPC payload (single file, all metrics). #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct WcRow { bytes: u64, @@ -814,6 +844,95 @@ impl WcRow { } } +/// Partial metric row (`wc` with selective flags or batch `total`). +#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] +pub struct WcMetrics { + #[serde(default)] + bytes: Option, + #[serde(default)] + lines: Option, + #[serde(default)] + words: Option, + #[serde(default)] + chars: Option, +} + +impl WcMetrics { + pub fn bytes(&self) -> Option { + self.bytes + } + + pub fn lines(&self) -> Option { + self.lines + } + + pub fn words(&self) -> Option { + self.words + } + + pub fn chars(&self) -> Option { + self.chars + } +} + +/// One file row in a batch `wc` response. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct WcFileEntry { + path: String, + #[serde(flatten)] + metrics: WcMetrics, +} + +impl WcFileEntry { + pub fn path(&self) -> &str { + &self.path + } + + pub fn metrics(&self) -> &WcMetrics { + &self.metrics + } +} + +/// `wc` RPC response (single-file object or batch `{ files, total }`). +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct WcResponse { + #[serde(default)] + files: Option>, + #[serde(default)] + total: Option, + #[serde(default)] + bytes: Option, + #[serde(default)] + lines: Option, + #[serde(default)] + words: Option, + #[serde(default)] + chars: Option, +} + +impl WcResponse { + pub fn is_batch(&self) -> bool { + self.files.is_some() + } + + pub fn files(&self) -> Option<&[WcFileEntry]> { + self.files.as_deref() + } + + pub fn total(&self) -> Option<&WcMetrics> { + self.total.as_ref() + } + + pub fn single_metrics(&self) -> WcMetrics { + WcMetrics { + bytes: self.bytes, + lines: self.lines, + words: self.words, + chars: self.chars, + } + } +} + /// One row from `list_directory`. #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct ListedEntryRow { diff --git a/tabularium/src/rpc/mod.rs b/tabularium/src/rpc/mod.rs index 74a0c61..283931c 100644 --- a/tabularium/src/rpc/mod.rs +++ b/tabularium/src/rpc/mod.rs @@ -10,5 +10,5 @@ mod client; pub use client::{ Client, CreateDocumentReply, DocumentBody, DocumentMetaRow, GrepLineRow, ListedEntryRow, - SearchHitRow, ServerTest, StatRow, WcRow, + SearchHitRow, ServerTest, StatRow, WcFileEntry, WcMetrics, WcResponse, WcRow, }; diff --git a/tabularium/src/wc.rs b/tabularium/src/wc.rs new file mode 100644 index 0000000..415caae --- /dev/null +++ b/tabularium/src/wc.rs @@ -0,0 +1,197 @@ +//! Word-count metrics filter and JSON shapes for `wc` RPC/CLI. + +use serde_json::{Map, Value, json}; + +use crate::db::WcStats; + +/// Which `wc` metrics to include in RPC/CLI output. +#[derive(Debug, Clone, Copy, Default)] +pub struct WcMetricsFilter { + pub lines: bool, + pub words: bool, + pub bytes: bool, + pub chars: bool, +} + +impl WcMetricsFilter { + pub const fn all() -> Self { + Self { + lines: true, + words: true, + bytes: true, + chars: true, + } + } + + pub const fn posix_default() -> Self { + Self { + lines: true, + words: true, + bytes: true, + chars: false, + } + } + + pub fn from_rpc_params(m: &Map) -> Self { + let lines = m.get("lines").and_then(Value::as_bool); + let words = m.get("words").and_then(Value::as_bool); + let bytes = m.get("bytes").and_then(Value::as_bool); + let chars = m.get("chars").and_then(Value::as_bool); + let any = [lines, words, bytes, chars] + .into_iter() + .any(|o| o == Some(true)); + if !any { + return Self::all(); + } + Self { + lines: lines == Some(true), + words: words == Some(true), + bytes: bytes == Some(true), + chars: chars == Some(true), + } + } + + pub fn from_cli_flags(lines: bool, words: bool, bytes: bool, chars: bool) -> Self { + if !lines && !words && !bytes && !chars { + return Self::posix_default(); + } + Self { + lines, + words, + bytes, + chars, + } + } + + pub fn active_columns(&self) -> usize { + usize::from(self.lines) + + usize::from(self.words) + + usize::from(self.bytes) + + usize::from(self.chars) + } + + pub fn column_labels(&self) -> Vec<&'static str> { + let mut cols = Vec::with_capacity(self.active_columns()); + if self.lines { + cols.push("lines"); + } + if self.words { + cols.push("words"); + } + if self.bytes { + cols.push("bytes"); + } + if self.chars { + cols.push("chars"); + } + cols + } + + pub fn format_counts(&self, stats: &WcStats) -> Vec { + self.format_optional_counts( + Some(stats.lines()), + Some(stats.words()), + Some(stats.bytes()), + Some(stats.chars()), + ) + } + + pub fn format_optional_counts( + &self, + lines: Option, + words: Option, + bytes: Option, + chars: Option, + ) -> Vec { + let mut cols = Vec::with_capacity(self.active_columns()); + if self.lines { + cols.push(lines.unwrap_or(0).to_string()); + } + if self.words { + cols.push(words.unwrap_or(0).to_string()); + } + if self.bytes { + cols.push(bytes.unwrap_or(0).to_string()); + } + if self.chars { + cols.push(chars.unwrap_or(0).to_string()); + } + cols + } +} + +fn wc_metrics_object(stats: &WcStats, filter: &WcMetricsFilter) -> Map { + let mut m = Map::new(); + if filter.lines { + m.insert("lines".into(), json!(stats.lines())); + } + if filter.words { + m.insert("words".into(), json!(stats.words())); + } + if filter.bytes { + m.insert("bytes".into(), json!(stats.bytes())); + } + if filter.chars { + m.insert("chars".into(), json!(stats.chars())); + } + m +} + +pub fn wc_stats_to_value(stats: &WcStats, filter: &WcMetricsFilter) -> Value { + Value::Object(wc_metrics_object(stats, filter)) +} + +pub fn wc_file_to_value(path: &str, stats: &WcStats, filter: &WcMetricsFilter) -> Value { + let mut m = wc_metrics_object(stats, filter); + m.insert("path".into(), json!(path)); + Value::Object(m) +} + +pub fn sum_wc_stats(rows: &[WcStats]) -> WcStats { + let mut bytes = 0u64; + let mut lines = 0usize; + let mut words = 0usize; + let mut chars = 0usize; + for w in rows { + bytes += w.bytes(); + lines += w.lines(); + words += w.words(); + chars += w.chars(); + } + WcStats::from_totals(bytes, lines, words, chars) +} + +#[cfg(test)] +mod tests { + use super::{WcMetricsFilter, wc_stats_to_value}; + use crate::db::WcStats; + use serde_json::json; + + #[test] + fn from_rpc_params_defaults_to_all_when_no_flags() { + let f = WcMetricsFilter::from_rpc_params(&serde_json::Map::new()); + assert!(f.lines && f.words && f.bytes && f.chars); + } + + #[test] + fn from_rpc_params_honours_true_flags_only() { + let mut m = serde_json::Map::new(); + m.insert("lines".into(), json!(true)); + m.insert("words".into(), json!(false)); + let f = WcMetricsFilter::from_rpc_params(&m); + assert!(f.lines); + assert!(!f.words); + } + + #[test] + fn wc_stats_to_value_respects_filter() { + let stats = WcStats::from_totals(10, 2, 3, 8); + let f = WcMetricsFilter { + lines: true, + words: false, + bytes: false, + chars: false, + }; + assert_eq!(wc_stats_to_value(&stats, &f), json!({ "lines": 2 })); + } +} diff --git a/tests/test_cli_wc.py b/tests/test_cli_wc.py new file mode 100644 index 0000000..cbdb802 --- /dev/null +++ b/tests/test_cli_wc.py @@ -0,0 +1,85 @@ +import subprocess + +import requests + +from tests.helpers import _base, _rpc, _slug, _tb_bin, mkdir, put_doc + + +def test_cli_wc_glob_lines_words_bytes(): + tb = _tb_bin() + base = _base() + cat = f"py_wc_glob_{_slug()}" + mkdir(base, cat) + put_doc(base, cat, "a.md", "one two\nthree") + put_doc(base, cat, "b.md", "four\nfive six") + for flag, expect_total in (("-l", "4"), ("-w", "6"), ("-c", "26")): + r = subprocess.run( + [tb, "-u", base, "wc", flag, f"/{cat}/*"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert r.returncode == 0, r.stderr + assert "total" in r.stdout + assert expect_total in r.stdout.split("total")[-1] + _rpc("delete_directory", {"path": cat, "recursive": True}) + + +def test_cli_wc_directory_errors(): + tb = _tb_bin() + base = _base() + cat = f"py_wc_dir_{_slug()}" + mkdir(base, cat) + r = subprocess.run( + [tb, "-u", base, "wc", "-l", f"/{cat}"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert r.returncode != 0 + assert "is a directory" in (r.stderr + r.stdout) + assert "recursive" in (r.stderr + r.stdout) + _rpc("delete_directory", {"path": cat, "recursive": True}) + + +def test_cli_wc_recursive_glob(): + tb = _tb_bin() + base = _base() + cat = f"py_wc_rec_{_slug()}" + mkdir(base, cat) + requests.post( + f"{base}/api/doc", + json={"path": f"/{cat}/inner", "description": None}, + timeout=10, + ).raise_for_status() + put_doc(base, f"{cat}/inner", "a.md", "x\ny") + r = subprocess.run( + [tb, "-u", base, "wc", "-l", "-r", f"/{cat}/*"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert r.returncode == 0, r.stderr + assert "total" in r.stdout + _rpc("delete_directory", {"path": cat, "recursive": True}) + + +def test_cli_wc_single_file_lines_only(): + tb = _tb_bin() + base = _base() + cat = f"py_wc_one_{_slug()}" + mkdir(base, cat) + put_doc(base, cat, "solo.md", "a\nb\nc") + r = subprocess.run( + [tb, "-u", base, "wc", "-l", f"/{cat}/solo.md"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert r.returncode == 0, r.stderr + assert r.stdout.strip() == "3" + _rpc("delete_directory", {"path": cat, "recursive": True})