Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/json-rpc-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
1 change: 0 additions & 1 deletion tabularium-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
197 changes: 98 additions & 99 deletions tabularium-cli/src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -26,6 +27,14 @@ use crate::render::mad_skin;

pub(crate) type BoxErr = Box<dyn std::error::Error + Send + Sync>;

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,
Expand Down Expand Up @@ -134,27 +143,12 @@ async fn expand_final_segment_glob(
client: &Client,
normalized_path: &str,
) -> Result<Option<Vec<(String, ListedEntryRow)>>, 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()
Expand All @@ -168,6 +162,77 @@ async fn expand_final_segment_glob(
Ok(Some(out))
}

fn wc_metric_values(m: &WcMetrics, filter: WcMetricsFilter) -> Vec<String> {
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<String>> = 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<Vec<String>> = 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<Vec<String>, BoxErr> {
let norm =
normalize_user_path(user_path.trim()).map_err(|e| -> BoxErr { e.to_string().into() })?;
Expand Down Expand Up @@ -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<String>> = 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 =
Expand Down Expand Up @@ -1514,18 +1525,6 @@ struct FindRow {
description: Option<String>,
}

fn path_has_glob_metachar(path: &str) -> bool {
path.chars().any(|c| matches!(c, '*' | '?' | '['))
}

fn compile_name_glob(pat: &str) -> Result<globset::GlobMatcher, BoxErr> {
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;
Expand Down
22 changes: 19 additions & 3 deletions tabularium-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,21 @@ pub(crate) enum Command {
Touch { path: String, time: Option<String> },
/// 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,
}
Expand Down Expand Up @@ -337,7 +350,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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);
}
}
}
}
Expand Down
Loading
Loading