From 59d920bb5f8f0e5b973477f8c8d434e4025ca42b Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:22:53 -0700 Subject: [PATCH 1/6] chore(gitignore): keep the local docenta inclusion policy out of the repo docenta honors a per-repo .docentaignore (gitignore syntax, outranks .gitignore, !pattern whitelists). This checkout carries one that keeps _trash/ (the design-document quarantine) in the corpus even though /_trash/* is gitignored. The policy is per-machine, so the file itself is never committed. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 498fe7c46..2513b2ca8 100644 --- a/.gitignore +++ b/.gitignore @@ -192,3 +192,6 @@ uffs-mcp-claude-video.mp4 # rustc ICE dumps (transient nightly "delayed bug" reports; never commit) rustc-ice-*.txt + +# docenta: local inclusion policy (the quarantine whitelist), never committed +.docentaignore From 64dfe5f4a3515f9ca4d5f67829f7b501e9c7858c Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:30:09 -0700 Subject: [PATCH 2/6] refactor(cli): move the four pre-#436 command entry points out of main.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `main.rs` carried the bodies of `run_search`, `run_stats`, `run_aggregate` and `run_daemon` (plus the search payload writer) as a leftover of the pre-#436 grammar, while every newer command already lived in `commands/*` behind a `run_` entry point. Move the four stragglers to the same shape: - `commands/search/run.rs` — `run_search` + `write_search_payload_to_stdout`, re-exported as `commands::search::run_search`; the `--stats` / `--agg` synthesised searches re-enter through it. - `commands/stats.rs` — `run_stats` (and the module docs no longer claim the daemon-mode route lives in `main.rs`). - `commands/aggregate.rs` — `run_aggregate`. - `commands/daemon_mgmt.rs` — `run_daemon`. `dispatch.rs` now names every command through `commands::…` instead of reaching back into the crate root. `client_profile` is declared once with the other modules (the `#[path]` re-declaration mid-file pointed at the file's default location anyway). The crate-level `#![expect(clippy::single_call_fn)]` is gone: the workspace already allows that lint by policy, and the expectation would have gone unfulfilled once the single-call bodies left the file. Tests moved beside what they exercise: the `parse_drive_letter` cases to `args.rs`, the `SearchParams::from_cli_args` contract checks to the search entry point. The elevation tests stay with `main`. `main.rs` now holds only `main`, `run` and the two elevation helpers (651 → 227 lines). Behaviour unchanged; clippy (all targets, all features) and the 162 uffs-cli unit + integration tests are green. --- crates/uffs-cli/src/args.rs | 19 +- crates/uffs-cli/src/commands/aggregate.rs | 47 +++ crates/uffs-cli/src/commands/daemon_mgmt.rs | 17 +- crates/uffs-cli/src/commands/search/mod.rs | 9 +- crates/uffs-cli/src/commands/search/run.rs | 290 +++++++++++++ crates/uffs-cli/src/commands/stats.rs | 85 +++- crates/uffs-cli/src/dispatch.rs | 8 +- crates/uffs-cli/src/main.rs | 432 +------------------- 8 files changed, 465 insertions(+), 442 deletions(-) create mode 100644 crates/uffs-cli/src/commands/search/run.rs diff --git a/crates/uffs-cli/src/args.rs b/crates/uffs-cli/src/args.rs index 2c05f54d9..ab6e09f49 100644 --- a/crates/uffs-cli/src/args.rs +++ b/crates/uffs-cli/src/args.rs @@ -646,7 +646,9 @@ pub(crate) use help::{ mod tests { use core::error::Error as _; - use super::{DaemonAction, ParseDriveLetterError, parse_daemon_action, parse_drive_letter}; + use super::{ + DaemonAction, DriveLetter, ParseDriveLetterError, parse_daemon_action, parse_drive_letter, + }; /// `BadShape` carries the original input and its Display matches the /// byte-for-byte format the previous `Result<_, String>` produced. @@ -769,4 +771,19 @@ mod tests { "two drives must be rejected", ); } + + #[test] + fn parse_drive_letter_accepts_letter_colon_and_whitespace_variants() { + assert_eq!(parse_drive_letter("c"), Ok(DriveLetter::C)); + assert_eq!(parse_drive_letter("C:"), Ok(DriveLetter::C)); + assert_eq!(parse_drive_letter(" d: "), Ok(DriveLetter::D)); + } + + #[test] + fn parse_drive_letter_rejects_invalid_values() { + parse_drive_letter("").unwrap_err(); + parse_drive_letter("12").unwrap_err(); + parse_drive_letter("1:").unwrap_err(); + parse_drive_letter("CD").unwrap_err(); + } } diff --git a/crates/uffs-cli/src/commands/aggregate.rs b/crates/uffs-cli/src/commands/aggregate.rs index f7ae26859..2e96d390d 100644 --- a/crates/uffs-cli/src/commands/aggregate.rs +++ b/crates/uffs-cli/src/commands/aggregate.rs @@ -21,7 +21,54 @@ use std::io::Write; use anyhow::Result; use uffs_client::protocol::AggregateResultWire; +use super::search::run_search; use super::{format_number, format_size}; +use crate::args; + +/// Handle `uffs --agg|agg [--format ...] [--data-dir ...]`. +/// +/// # Errors +/// +/// Returns an error when no preset is given or when the synthesised +/// aggregate search fails. +pub(crate) fn run_aggregate(args: &[String]) -> Result<()> { + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + args::print_aggregate_help(); + return Ok(()); + } + // Extract the preset (first positional arg). + let preset = args + .iter() + .find(|arg| !arg.starts_with('-')) + .ok_or_else(|| { + anyhow::anyhow!( + "Usage: uffs --agg \n\ + Available presets: overview, by_type, by_extension, by_drive, by_size, by_age, count" + ) + })?; + + // Synthesise search args: `* --agg --limit 0 [remaining flags]`. + let mut synth_args = vec![ + "*".to_owned(), + "--agg".to_owned(), + preset.clone(), + "--limit".to_owned(), + "0".to_owned(), + ]; + // Default to table format for `uffs --agg` unless user specifies --format. + let has_format = args.iter().any(|arg| arg == "--format" || arg == "-f"); + if !has_format { + synth_args.extend(["--format".to_owned(), "table".to_owned()]); + } + // Forward all flags (skip the preset positional). + for arg in args { + if arg == preset { + continue; + } + synth_args.push(arg.clone()); + } + run_search(&synth_args) +} /// Print aggregate results in a human-readable table format. /// diff --git a/crates/uffs-cli/src/commands/daemon_mgmt.rs b/crates/uffs-cli/src/commands/daemon_mgmt.rs index 42e9c3c92..4b4ddf06c 100644 --- a/crates/uffs-cli/src/commands/daemon_mgmt.rs +++ b/crates/uffs-cli/src/commands/daemon_mgmt.rs @@ -11,9 +11,24 @@ use uffs_client::connect_sync::UffsClientSync; use uffs_client::daemon_ctl::{pid_file_path, socket_path}; use uffs_client::protocol::response::DaemonStatus; -use crate::args::DaemonAction; +use crate::args::{self, DaemonAction}; use crate::commands::{daemon_load, daemon_status, daemon_tiering}; +/// Handle `uffs --daemon [flags...]`. +/// +/// # Errors +/// +/// Returns an error for an unparseable action or when the action's +/// handler fails. +pub(crate) fn run_daemon(args: &[String]) -> Result<()> { + if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { + args::print_daemon_help(); + return Ok(()); + } + let action = args::parse_daemon_action(args)?; + daemon(&action) +} + /// Suppress the user-facing progress prints of the daemon handlers while an /// internal flow (the uninstall's background drive-coverage reload) runs them /// behind a spinner. Read by the print sites in `daemon_start` / `daemon_kill`; diff --git a/crates/uffs-cli/src/commands/search/mod.rs b/crates/uffs-cli/src/commands/search/mod.rs index 2b15eee3b..d9478d0fc 100644 --- a/crates/uffs-cli/src/commands/search/mod.rs +++ b/crates/uffs-cli/src/commands/search/mod.rs @@ -1,13 +1,18 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2025-2026 SKY, LLC. -//! Search command — thin-client output helpers. +//! Search command — the thin-client entry point and its output helpers. //! //! All searches route through the UFFS daemon via `search_cli` RPC. -//! This module provides output formatting for the responses. +//! `run_search` owns the round trip; the submodules own the argument +//! transforms on the way in and the output formatting on the way out. /// Argument transforms (spawn-arg extraction, `--out` resolution, /// NUL-stdout `--no-output` injection). pub(crate) mod args; /// Output dispatch and formatting. pub mod dispatch; +/// The `search_cli` round trip and payload-to-stdout writer. +mod run; + +pub(crate) use run::run_search; diff --git a/crates/uffs-cli/src/commands/search/run.rs b/crates/uffs-cli/src/commands/search/run.rs new file mode 100644 index 000000000..960fd4ce4 --- /dev/null +++ b/crates/uffs-cli/src/commands/search/run.rs @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! `uffs [--search] [flags...]` — the search entry point. +//! +//! Forwards the raw argument vector to the daemon over the `search_cli` +//! RPC, then writes the typed response back to stdout through whichever +//! transport the daemon picked (shmem blob, inline blob, shmem rows, +//! inline rows). The `--stats` and `--agg` entry points synthesise an +//! argument vector and re-enter through [`run_search`], so this module is +//! the single place where a search leaves the CLI. + +use anyhow::{Context as _, Result}; +use uffs_client::protocol::response::SearchPayload; + +use super::args::{extract_spawn_args, inject_no_output_for_null_stdout, resolve_out_path}; +use super::dispatch::{write_aggregations, write_rows}; +use crate::client_profile::{ClientProfile, print_client_profile}; +use crate::{args, dispatch, search_retry}; + +/// Forward raw search args to the daemon via `search_cli` RPC. +/// +/// # Errors +/// +/// Propagates a daemon connect / readiness / RPC failure, a response the +/// CLI cannot deserialise, or a stdout write failure. A `--flag` that is +/// a near-miss of a management command is rejected up front with a +/// "did you mean" hint instead of a round trip to the daemon. +pub(crate) fn run_search(args: &[String]) -> Result<()> { + // No pattern, or an explicit help request as the first token + // (`uffs --search --help`) → the search-first top-level help. + if matches!( + args.first().map(String::as_str), + None | Some("--help" | "-h") + ) { + args::print_help(); + return Ok(()); + } + + // Command-typo hint. If the first token is a `--`-flag that the shared + // parser rejects AND it is a near-miss of a management command, surface a + // "did you mean" hint up front instead of spinning up the daemon only for + // it to return a bare unknown-flag error. The CLI suggests over ITS own + // command set; flag validation stays in `uffs_client::from_cli_args`, so + // the daemon never learns CLI commands (design: cli-grammar.md §6). + if let Some(first) = args.first() + && first.starts_with("--") + && dispatch::Command::from_token(first).is_none() + && let Err(uffs_client::protocol::cli_args::Error::UnknownFlag { flag }) = + uffs_client::protocol::SearchParams::from_cli_args(args) + && let Some(command) = dispatch::suggest_command(&flag) + { + anyhow::bail!( + "`{flag}` is not a known search flag.\n\ + Did you mean the command `uffs {command}`? (run `uffs {command} --help`)" + ); + } + + // Extract daemon-spawn args (--data-dir, --mft-file, --no-cache) + // from the raw args so we can auto-start the daemon if needed. + let spawn_args = extract_spawn_args(args); + + let t_connect = std::time::Instant::now(); + let mut client = uffs_client::connect_sync::UffsClientSync::connect_with_args(&spawn_args) + .with_context(|| "Failed to connect to UFFS daemon")?; + let connect_ms = t_connect.elapsed().as_millis(); + + let t_ready = std::time::Instant::now(); + // 2 minutes — `from_mins` is nightly-only as of 2026-04. + let ready_timeout = core::time::Duration::from_secs(120); + client + .await_ready(ready_timeout) + .with_context(|| "Daemon did not become ready in time")?; + let ready_ms = t_ready.elapsed().as_millis(); + + let t_search = std::time::Instant::now(); + // Resolve relative --out paths to absolute using the CLI's cwd, since the + // daemon process runs in a different working directory. + // Phase 3.1 NUL fast path: when stdout is redirected to the null + // device (e.g. `uffs *.dll > NUL`), inject `--no-output` so the + // daemon skips row materialisation + `paths_blob` construction + // + IPC row transfer entirely. Saves ~20-30 ms on medium result + // sets that would otherwise push 3.5 MB through the pipe just to + // discard the bytes client-side. + let args_owned: Vec = inject_no_output_for_null_stdout(resolve_out_path(args)); + let raw_response = search_retry::search_cli_with_warm_retry(&mut client, &args_owned) + .with_context(|| "Daemon search_cli failed")?; + let ipc_ms = t_search.elapsed().as_millis(); + + // v0.5.62: deserialise the daemon response into the typed + // `SearchResponse` struct. The `SearchPayload` enum is + // self-describing (serde tag = "kind", content = "data") so the + // CLI no longer needs to probe individual fields like + // `paths_blob`, `paths_blob_shmem`, `shmem_path`, etc. — the + // enum's variant is the single source of truth for which + // transport the daemon picked. + // + // Unknown fields on the wire are silently ignored (serde default), + // so newer daemons that add optional response fields are still + // forward-compatible with this CLI. + let response: uffs_client::protocol::response::SearchResponse = + serde_json::from_value(raw_response) + .with_context(|| "Failed to deserialize search response from daemon")?; + + if args + .iter() + .any(|arg| arg == "--profile" || arg == "--benchmark") + { + print_client_profile(&ClientProfile { + connect_ms, + ready_ms, + ipc_ms, + duration_ms: response.duration_ms, + promotion_ms: response.promotion_ms.unwrap_or(0), + payload: &response.payload, + total_count: response.total_count, + daemon_profile: response.profile.as_ref(), + }); + } + + // OPT-4: When --out is specified, the daemon writes the file directly + // and returns `SearchPayload::Empty`. Don't overwrite the file. + // Handles both `--out foo.csv` (separate arg) and `--out=foo.csv` (= form). + let has_out = args + .iter() + .any(|arg| arg == "--out" || arg.starts_with("--out=")); + let daemon_wrote_file = has_out && response.payload.is_empty(); + + // Phase 3.1 NUL fast path: `--no-output` (explicit or auto-injected + // for NUL stdout) skips every client-side stdout write. + let suppress_stdout = args_owned.iter().any(|arg| arg == "--no-output"); + + if !daemon_wrote_file && !suppress_stdout { + write_search_payload_to_stdout(response.payload, args)?; + } + + if !suppress_stdout && !response.aggregations.is_empty() { + // `write_aggregations` still consumes `&[serde_json::Value]` + // for format flexibility — re-serialise the typed + // `AggregateResultWire` list via `to_value` once up front + // and pass the slice to the helper. Allocation is one per + // aggregation bucket, which is trivial compared to the + // aggregation itself. + let agg_values: Vec = response + .aggregations + .iter() + .filter_map(|agg| serde_json::to_value(agg).ok()) + .collect(); + write_aggregations(&agg_values, args)?; + } + + Ok(()) +} + +/// Write the daemon's search payload to stdout, picking the fastest +/// transport the daemon selected for this response. +/// +/// Priority order matches the [`SearchPayload`] variant dispatch: +/// +/// 1. [`SearchPayload::ShmemBlob`] → mmap the raw-bytes file and stream +/// directly to stdout via [`uffs_client::shmem::stream_paths_blob_into`]. +/// Zero-copy, zero JSON decode, zero UTF-8 re-validation. Used for blobs +/// above [`uffs_client::shmem::PATHS_BLOB_SHMEM_THRESHOLD`]. +/// 2. [`SearchPayload::InlineBlob`] → single `write_all` of the inline UTF-8 +/// buffer. Skips per-row formatting but still paid ~40 ms of JSON decode on +/// the way in. +/// 3. [`SearchPayload::ShmemRows`] → read the shmem file into a +/// `Vec` (client's `connect_sync` shim doesn't do transparent +/// resolution for `search_cli`), then fall through to per-row format +/// dispatch. +/// 4. [`SearchPayload::InlineRows`] → traditional per-row format + write +/// dispatch in [`write_rows`]. +/// 5. [`SearchPayload::Empty`] → nothing to write. +/// +/// Extracted from [`run_search`] to keep that function under the +/// `clippy::too_many_lines` cap. +fn write_search_payload_to_stdout(payload: SearchPayload, args: &[String]) -> Result<()> { + match payload { + SearchPayload::Empty => { + // Nothing to write — no-match query, `--no-output` + // injection, or `--out=file` (daemon already wrote to + // disk). The earlier `daemon_wrote_file` guard also + // handles the latter case at the call site. + } + SearchPayload::ShmemBlob(shmem_path_str) => { + // Binary shmem transport: mmap the file and write bytes + // directly to stdout with one syscall, then delete the + // file. No JSON decode, no intermediate allocation, no + // UTF-8 re-validation — stdout takes bytes. + let shmem_path = std::path::Path::new(&shmem_path_str); + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + uffs_client::shmem::stream_paths_blob_into(shmem_path, &mut handle) + .with_context(|| format!("Failed to stream shmem_blob from {shmem_path_str}"))?; + } + SearchPayload::InlineBlob(blob) => { + // Single write_all to stdout — the buffer is one + // contiguous slice; the whole point of the blob + // inline transport. + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + std::io::Write::write_all(&mut handle, blob.as_bytes()) + .with_context(|| "Failed to write inline_blob to stdout")?; + } + SearchPayload::ShmemRows { path, .. } => { + // Shmem rows variant: read the file (returns a + // `SearchResponse` with `InlineRows`) and dispatch to + // the per-row writer. Re-encode rows to `Value` so the + // existing `write_rows` path (which handles `--format`, + // `--sep`, `--header`, column resolution, etc.) stays + // untouched — one Vec allocation scales O(N) but beats + // duplicating the column-resolution logic. + let shmem_resp = uffs_client::shmem::read_search_results(std::path::Path::new(&path)) + .with_context(|| format!("Failed to read shmem_rows from {path}"))?; + let row_values: Vec = shmem_resp + .payload + .into_inline_rows() + .unwrap_or_default() + .iter() + .filter_map(|row| serde_json::to_value(row).ok()) + .collect(); + write_rows(&row_values, args)?; + } + SearchPayload::InlineRows(rows) => { + // Traditional per-row format dispatch. `write_rows` + // accepts `&[serde_json::Value]` for format flexibility + // (extract_field, parity-compat, drilldown), so re- + // serialise the typed rows once up front. + let row_values: Vec = rows + .iter() + .filter_map(|row| serde_json::to_value(row).ok()) + .collect(); + write_rows(&row_values, args)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use uffs_client::protocol::SearchParams; + + #[test] + fn from_cli_args_basic_search() { + let args: Vec = [ + "*.rs", + "--drive", + "C", + "--format", + "json", + "--tz-offset", + "-8", + ] + .iter() + .map(ToString::to_string) + .collect(); + let params = SearchParams::from_cli_args(&args).expect("should parse"); + // `*.rs` is promoted to pattern="*" + ext=Some("rs") so the + // daemon can route through the ExtensionIndex fast path in + // `numeric_top_n::ext_fast_path` instead of the trigram + glob + // path. See `is_pure_ext_glob` in cli_args.rs for the shape + // acceptance matrix and `test_from_cli_args_ext_glob_promoted` + // in uffs-client for the full rewrite semantics. + assert_eq!(params.pattern, "*"); + assert_eq!(params.ext.as_deref(), Some("rs")); + assert_eq!(params.drives, vec![uffs_mft::platform::DriveLetter::C]); + assert_eq!(params.output_tz_offset_hours, Some(-8_i32)); + } + + #[test] + fn from_cli_args_sugar_begins_with() { + let args: Vec = ["--begins-with", "report"] + .iter() + .map(ToString::to_string) + .collect(); + let params = SearchParams::from_cli_args(&args).expect("should parse"); + assert_eq!(params.pattern, "report*"); + } + + #[test] + fn from_cli_args_sugar_between() { + let args: Vec = ["*", "--between", "2026-01-01,2026-03-31"] + .iter() + .map(ToString::to_string) + .collect(); + let params = SearchParams::from_cli_args(&args).expect("should parse"); + assert_eq!(params.newer.as_deref(), Some("2026-01-01")); + assert_eq!(params.older.as_deref(), Some("2026-03-31")); + } +} diff --git a/crates/uffs-cli/src/commands/stats.rs b/crates/uffs-cli/src/commands/stats.rs index ac59eca7f..f7cbe6826 100644 --- a/crates/uffs-cli/src/commands/stats.rs +++ b/crates/uffs-cli/src/commands/stats.rs @@ -3,9 +3,10 @@ //! Stats command implementation. //! -//! **Daemon mode** (no path): routed through `SearchConfig::aggregate_only` -//! + `run_with_config` in `main.rs` — reuses the full search daemon -//! lifecycle (auto-start, await_ready, data-dir forwarding). +//! **Daemon mode** (no path): `run_stats` synthesises an aggregate-only +//! `overview` search and re-enters through +//! [`crate::commands::search::run_search`] — reusing the full search +//! daemon lifecycle (auto-start, await_ready, data-dir forwarding). //! //! Legacy parquet-mode stats have been removed from the thin CLI. //! Use `uffsd` directly or `uffs --stats` (daemon mode) instead. @@ -14,10 +15,82 @@ use std::path::Path; use anyhow::Result; +use crate::args; +use crate::commands::search::run_search; + +/// Handle `uffs --stats [path] [--top N] [--data-dir ...] [--mft-file ...]`. +/// +/// # Errors +/// +/// Returns an error for a malformed `--top`, for a path argument (legacy +/// parquet stats are no longer supported), or when the synthesised +/// overview search fails. +pub(crate) fn run_stats(args: &[String]) -> Result<()> { + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + args::print_stats_help(); + return Ok(()); + } + // Simple arg extraction for stats subcommand. + let mut path: Option = None; + let mut top: u32 = 10; + let mut data_dir: Option = None; + let mut mft_file: Vec = Vec::new(); + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--top" => { + if let Some(val) = iter.next() { + top = val + .parse() + .map_err(|err| anyhow::anyhow!("Bad --top: {err}"))?; + } + } + "--data-dir" => { + if let Some(val) = iter.next() { + data_dir = Some(val.into()); + } + } + "--mft-file" => { + if let Some(val) = iter.next() { + mft_file = val.split(',').map(|part| part.trim().into()).collect(); + } + } + other if !other.starts_with('-') && path.is_none() => { + path = Some(other.into()); + } + _ => {} + } + } + + if let Some(stats_path) = path { + stats(Some(&stats_path), top)?; + } else { + // Synthesise search args for an aggregate-only overview query. + let mut synth_args = vec![ + "*".to_owned(), + "--agg".to_owned(), + "overview".to_owned(), + "--format".to_owned(), + "table".to_owned(), + "--limit".to_owned(), + "0".to_owned(), + ]; + if let Some(dir) = data_dir { + synth_args.extend(["--data-dir".to_owned(), dir.to_string_lossy().into_owned()]); + } + for mf in &mft_file { + synth_args.extend(["--mft-file".to_owned(), mf.to_string_lossy().into_owned()]); + } + run_search(&synth_args)?; + } + Ok(()) +} + /// Show statistics. /// -/// Daemon-mode stats (no path) are handled in `main.rs` via the search path. -/// Parquet-mode stats (path given) are no longer supported by the thin CLI. +/// Daemon-mode stats (no path) are handled by [`run_stats`] via the search +/// path. Parquet-mode stats (path given) are no longer supported by the +/// thin CLI. /// /// # Errors /// @@ -26,7 +99,7 @@ use anyhow::Result; pub fn stats(path: Option<&Path>, _top: u32) -> Result<()> { match path { None => { - anyhow::bail!("stats without a path should be routed through search path in main.rs") + anyhow::bail!("stats without a path should be routed through `run_stats`'s search path") } Some(dir) => { anyhow::bail!( diff --git a/crates/uffs-cli/src/dispatch.rs b/crates/uffs-cli/src/dispatch.rs index a03a48a33..674051aa2 100644 --- a/crates/uffs-cli/src/dispatch.rs +++ b/crates/uffs-cli/src/dispatch.rs @@ -113,12 +113,12 @@ pub(crate) fn suggest_command(flag: &str) -> Option<&'static str> { /// Propagates the underlying command's failure. pub(crate) fn dispatch_command(command: Command, args: &[String]) -> Result<()> { match command { - Command::Search => crate::run_search(args), - Command::Stats => crate::run_stats(args), - Command::Agg => crate::run_aggregate(args), + Command::Search => commands::search::run_search(args), + Command::Stats => commands::stats::run_stats(args), + Command::Agg => commands::aggregate::run_aggregate(args), Command::Deleted => commands::deleted::run_deleted(args), Command::Snapshot => commands::snapshot::run_snapshot(args), - Command::Daemon => crate::run_daemon(args), + Command::Daemon => commands::daemon_mgmt::run_daemon(args), Command::Mcp => commands::mcp_mgmt::mcp_from_args(args), Command::Update => commands::update::run_update(args), Command::Uninstall => commands::uninstall::run_uninstall(args), diff --git a/crates/uffs-cli/src/main.rs b/crates/uffs-cli/src/main.rs index 81d327ece..ef4da3ecb 100644 --- a/crates/uffs-cli/src/main.rs +++ b/crates/uffs-cli/src/main.rs @@ -42,21 +42,18 @@ //! | `UFFS_LOG_DIR` | `path` | platform default (`%LOCALAPPDATA%\UFFS\logs` / `$XDG_CACHE_HOME/uffs/logs`) | Log directory override for `uffs --daemon start` and `uffs --search`. Mirrors the `--log-dir` CLI flag. INTERNAL semver class. | //! | `UFFS_LOG_FILE` | `path` | (none — auto-generated under `UFFS_LOG_DIR`) | Log-file path override. Mirrors the `--log-file` CLI flag. INTERNAL semver class. | -// CLI main module uses single-call functions by design -#![expect( - clippy::single_call_fn, - reason = "CLI entry point functions are called once from main" -)] - -use anyhow::{Context as _, Result}; +use anyhow::Result; #[cfg(test)] use assert_cmd as _; pub mod args; +mod client_profile; pub mod commands; mod dispatch; mod search_retry; +use commands::search::run_search; + /// Run the CLI and return a result. fn run() -> Result<()> { let raw_args: Vec = std::env::args().collect(); @@ -107,347 +104,6 @@ fn run() -> Result<()> { Ok(()) } -/// Timing + payload summary forwarded to [`print_client_profile`]. -#[path = "client_profile.rs"] -mod client_profile; -use client_profile::{ClientProfile, print_client_profile}; - -/// Forward raw search args to the daemon via `search_cli` RPC. -pub(crate) fn run_search(args: &[String]) -> Result<()> { - // No pattern, or an explicit help request as the first token - // (`uffs --search --help`) → the search-first top-level help. - if matches!( - args.first().map(String::as_str), - None | Some("--help" | "-h") - ) { - args::print_help(); - return Ok(()); - } - - // Command-typo hint. If the first token is a `--`-flag that the shared - // parser rejects AND it is a near-miss of a management command, surface a - // "did you mean" hint up front instead of spinning up the daemon only for - // it to return a bare unknown-flag error. The CLI suggests over ITS own - // command set; flag validation stays in `uffs_client::from_cli_args`, so - // the daemon never learns CLI commands (design: cli-grammar.md §6). - if let Some(first) = args.first() - && first.starts_with("--") - && dispatch::Command::from_token(first).is_none() - && let Err(uffs_client::protocol::cli_args::Error::UnknownFlag { flag }) = - uffs_client::protocol::SearchParams::from_cli_args(args) - && let Some(command) = dispatch::suggest_command(&flag) - { - anyhow::bail!( - "`{flag}` is not a known search flag.\n\ - Did you mean the command `uffs {command}`? (run `uffs {command} --help`)" - ); - } - - // Extract daemon-spawn args (--data-dir, --mft-file, --no-cache) - // from the raw args so we can auto-start the daemon if needed. - let spawn_args = commands::search::args::extract_spawn_args(args); - - let t_connect = std::time::Instant::now(); - let mut client = uffs_client::connect_sync::UffsClientSync::connect_with_args(&spawn_args) - .with_context(|| "Failed to connect to UFFS daemon")?; - let connect_ms = t_connect.elapsed().as_millis(); - - let t_ready = std::time::Instant::now(); - // 2 minutes — `from_mins` is nightly-only as of 2026-04. - let ready_timeout = core::time::Duration::from_secs(120); - client - .await_ready(ready_timeout) - .with_context(|| "Daemon did not become ready in time")?; - let ready_ms = t_ready.elapsed().as_millis(); - - let t_search = std::time::Instant::now(); - // Resolve relative --out paths to absolute using the CLI's cwd, since the - // daemon process runs in a different working directory. - // Phase 3.1 NUL fast path: when stdout is redirected to the null - // device (e.g. `uffs *.dll > NUL`), inject `--no-output` so the - // daemon skips row materialisation + `paths_blob` construction - // + IPC row transfer entirely. Saves ~20-30 ms on medium result - // sets that would otherwise push 3.5 MB through the pipe just to - // discard the bytes client-side. - let args_owned: Vec = commands::search::args::inject_no_output_for_null_stdout( - commands::search::args::resolve_out_path(args), - ); - let raw_response = search_retry::search_cli_with_warm_retry(&mut client, &args_owned) - .with_context(|| "Daemon search_cli failed")?; - let ipc_ms = t_search.elapsed().as_millis(); - - // v0.5.62: deserialise the daemon response into the typed - // `SearchResponse` struct. The `SearchPayload` enum is - // self-describing (serde tag = "kind", content = "data") so the - // CLI no longer needs to probe individual fields like - // `paths_blob`, `paths_blob_shmem`, `shmem_path`, etc. — the - // enum's variant is the single source of truth for which - // transport the daemon picked. - // - // Unknown fields on the wire are silently ignored (serde default), - // so newer daemons that add optional response fields are still - // forward-compatible with this CLI. - let response: uffs_client::protocol::response::SearchResponse = - serde_json::from_value(raw_response) - .with_context(|| "Failed to deserialize search response from daemon")?; - - if args - .iter() - .any(|arg| arg == "--profile" || arg == "--benchmark") - { - print_client_profile(&ClientProfile { - connect_ms, - ready_ms, - ipc_ms, - duration_ms: response.duration_ms, - promotion_ms: response.promotion_ms.unwrap_or(0), - payload: &response.payload, - total_count: response.total_count, - daemon_profile: response.profile.as_ref(), - }); - } - - // OPT-4: When --out is specified, the daemon writes the file directly - // and returns `SearchPayload::Empty`. Don't overwrite the file. - // Handles both `--out foo.csv` (separate arg) and `--out=foo.csv` (= form). - let has_out = args - .iter() - .any(|arg| arg == "--out" || arg.starts_with("--out=")); - let daemon_wrote_file = has_out && response.payload.is_empty(); - - // Phase 3.1 NUL fast path: `--no-output` (explicit or auto-injected - // for NUL stdout) skips every client-side stdout write. - let suppress_stdout = args_owned.iter().any(|arg| arg == "--no-output"); - - if !daemon_wrote_file && !suppress_stdout { - write_search_payload_to_stdout(response.payload, args)?; - } - - if !suppress_stdout && !response.aggregations.is_empty() { - // `write_aggregations` still consumes `&[serde_json::Value]` - // for format flexibility — re-serialise the typed - // `AggregateResultWire` list via `to_value` once up front - // and pass the slice to the helper. Allocation is one per - // aggregation bucket, which is trivial compared to the - // aggregation itself. - let agg_values: Vec = response - .aggregations - .iter() - .filter_map(|agg| serde_json::to_value(agg).ok()) - .collect(); - commands::search::dispatch::write_aggregations(&agg_values, args)?; - } - - Ok(()) -} - -/// Write the daemon's search payload to stdout, picking the fastest -/// transport the daemon selected for this response. -/// -/// Priority order matches the [`SearchPayload`] variant dispatch: -/// -/// 1. [`SearchPayload::ShmemBlob`] → mmap the raw-bytes file and stream -/// directly to stdout via [`uffs_client::shmem::stream_paths_blob_into`]. -/// Zero-copy, zero JSON decode, zero UTF-8 re-validation. Used for blobs -/// above [`uffs_client::shmem::PATHS_BLOB_SHMEM_THRESHOLD`]. -/// 2. [`SearchPayload::InlineBlob`] → single `write_all` of the inline UTF-8 -/// buffer. Skips per-row formatting but still paid ~40 ms of JSON decode on -/// the way in. -/// 3. [`SearchPayload::ShmemRows`] → read the shmem file into a -/// `Vec` (client's `connect_sync` shim doesn't do transparent -/// resolution for `search_cli`), then fall through to per-row format -/// dispatch. -/// 4. [`SearchPayload::InlineRows`] → traditional per-row format + write -/// dispatch in [`commands::search::dispatch::write_rows`]. -/// 5. [`SearchPayload::Empty`] → nothing to write. -/// -/// Extracted from `run_search` to keep that function under the -/// `clippy::too_many_lines` cap. -/// -/// [`SearchPayload`]: uffs_client::protocol::response::SearchPayload -/// [`SearchPayload::ShmemBlob`]: uffs_client::protocol::response::SearchPayload::ShmemBlob -/// [`SearchPayload::InlineBlob`]: uffs_client::protocol::response::SearchPayload::InlineBlob -/// [`SearchPayload::ShmemRows`]: uffs_client::protocol::response::SearchPayload::ShmemRows -/// [`SearchPayload::InlineRows`]: uffs_client::protocol::response::SearchPayload::InlineRows -/// [`SearchPayload::Empty`]: uffs_client::protocol::response::SearchPayload::Empty -fn write_search_payload_to_stdout( - payload: uffs_client::protocol::response::SearchPayload, - args: &[String], -) -> Result<()> { - use uffs_client::protocol::response::SearchPayload; - match payload { - SearchPayload::Empty => { - // Nothing to write — no-match query, `--no-output` - // injection, or `--out=file` (daemon already wrote to - // disk). The earlier `daemon_wrote_file` guard also - // handles the latter case at the call site. - } - SearchPayload::ShmemBlob(shmem_path_str) => { - // Binary shmem transport: mmap the file and write bytes - // directly to stdout with one syscall, then delete the - // file. No JSON decode, no intermediate allocation, no - // UTF-8 re-validation — stdout takes bytes. - let shmem_path = std::path::Path::new(&shmem_path_str); - let stdout = std::io::stdout(); - let mut handle = stdout.lock(); - uffs_client::shmem::stream_paths_blob_into(shmem_path, &mut handle) - .with_context(|| format!("Failed to stream shmem_blob from {shmem_path_str}"))?; - } - SearchPayload::InlineBlob(blob) => { - // Single write_all to stdout — the buffer is one - // contiguous slice; the whole point of the blob - // inline transport. - let stdout = std::io::stdout(); - let mut handle = stdout.lock(); - std::io::Write::write_all(&mut handle, blob.as_bytes()) - .with_context(|| "Failed to write inline_blob to stdout")?; - } - SearchPayload::ShmemRows { path, .. } => { - // Shmem rows variant: read the file (returns a - // `SearchResponse` with `InlineRows`) and dispatch to - // the per-row writer. Re-encode rows to `Value` so the - // existing `write_rows` path (which handles `--format`, - // `--sep`, `--header`, column resolution, etc.) stays - // untouched — one Vec allocation scales O(N) but beats - // duplicating the column-resolution logic. - let shmem_resp = uffs_client::shmem::read_search_results(std::path::Path::new(&path)) - .with_context(|| format!("Failed to read shmem_rows from {path}"))?; - let row_values: Vec = shmem_resp - .payload - .into_inline_rows() - .unwrap_or_default() - .iter() - .filter_map(|row| serde_json::to_value(row).ok()) - .collect(); - commands::search::dispatch::write_rows(&row_values, args)?; - } - SearchPayload::InlineRows(rows) => { - // Traditional per-row format dispatch. `write_rows` - // accepts `&[serde_json::Value]` for format flexibility - // (extract_field, parity-compat, drilldown), so re- - // serialise the typed rows once up front. - let row_values: Vec = rows - .iter() - .filter_map(|row| serde_json::to_value(row).ok()) - .collect(); - commands::search::dispatch::write_rows(&row_values, args)?; - } - } - Ok(()) -} - -/// Handle `uffs --stats [path] [--top N] [--data-dir ...] [--mft-file ...]`. -pub(crate) fn run_stats(args: &[String]) -> Result<()> { - if args.iter().any(|arg| arg == "--help" || arg == "-h") { - args::print_stats_help(); - return Ok(()); - } - // Simple arg extraction for stats subcommand. - let mut path: Option = None; - let mut top: u32 = 10; - let mut data_dir: Option = None; - let mut mft_file: Vec = Vec::new(); - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - match arg.as_str() { - "--top" => { - if let Some(val) = iter.next() { - top = val - .parse() - .map_err(|err| anyhow::anyhow!("Bad --top: {err}"))?; - } - } - "--data-dir" => { - if let Some(val) = iter.next() { - data_dir = Some(val.into()); - } - } - "--mft-file" => { - if let Some(val) = iter.next() { - mft_file = val.split(',').map(|part| part.trim().into()).collect(); - } - } - other if !other.starts_with('-') && path.is_none() => { - path = Some(other.into()); - } - _ => {} - } - } - - if let Some(stats_path) = path { - commands::stats::stats(Some(&stats_path), top)?; - } else { - // Synthesise search args for an aggregate-only overview query. - let mut synth_args = vec![ - "*".to_owned(), - "--agg".to_owned(), - "overview".to_owned(), - "--format".to_owned(), - "table".to_owned(), - "--limit".to_owned(), - "0".to_owned(), - ]; - if let Some(dir) = data_dir { - synth_args.extend(["--data-dir".to_owned(), dir.to_string_lossy().into_owned()]); - } - for mf in &mft_file { - synth_args.extend(["--mft-file".to_owned(), mf.to_string_lossy().into_owned()]); - } - run_search(&synth_args)?; - } - Ok(()) -} - -/// Handle `uffs --agg|agg [--format ...] [--data-dir ...]`. -pub(crate) fn run_aggregate(args: &[String]) -> Result<()> { - if args.iter().any(|arg| arg == "--help" || arg == "-h") { - args::print_aggregate_help(); - return Ok(()); - } - // Extract the preset (first positional arg). - let preset = args - .iter() - .find(|arg| !arg.starts_with('-')) - .ok_or_else(|| { - anyhow::anyhow!( - "Usage: uffs --agg \n\ - Available presets: overview, by_type, by_extension, by_drive, by_size, by_age, count" - ) - })?; - - // Synthesise search args: `* --agg --limit 0 [remaining flags]`. - let mut synth_args = vec![ - "*".to_owned(), - "--agg".to_owned(), - preset.clone(), - "--limit".to_owned(), - "0".to_owned(), - ]; - // Default to table format for `uffs --agg` unless user specifies --format. - let has_format = args.iter().any(|arg| arg == "--format" || arg == "-f"); - if !has_format { - synth_args.extend(["--format".to_owned(), "table".to_owned()]); - } - // Forward all flags (skip the preset positional). - for arg in args { - if arg == preset { - continue; - } - synth_args.push(arg.clone()); - } - run_search(&synth_args) -} - -/// Handle `uffs --daemon [flags...]`. -pub(crate) fn run_daemon(args: &[String]) -> Result<()> { - if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { - args::print_daemon_help(); - return Ok(()); - } - let action = args::parse_daemon_action(args)?; - commands::daemon_mgmt::daemon(&action) -} - /// Entry point — synchronous, no runtime. #[expect( clippy::print_stderr, @@ -520,14 +176,6 @@ fn format_elevation_help(daemon_path: &str) -> String { #[cfg(test)] mod tests { - #![expect( - clippy::default_numeric_fallback, - reason = "test module — relaxed linting" - )] - - use uffs_client::protocol::SearchParams; - - use super::args::parse_drive_letter; use super::{find_needs_elevation, format_elevation_help}; /// The elevation help must name every recovery path the user has, @@ -576,76 +224,4 @@ mod tests { )); assert!(find_needs_elevation(&other).is_none()); } - - #[test] - fn parse_drive_letter_accepts_letter_colon_and_whitespace_variants() { - assert_eq!( - parse_drive_letter("c"), - Ok(uffs_mft::platform::DriveLetter::C) - ); - assert_eq!( - parse_drive_letter("C:"), - Ok(uffs_mft::platform::DriveLetter::C) - ); - assert_eq!( - parse_drive_letter(" d: "), - Ok(uffs_mft::platform::DriveLetter::D) - ); - } - - #[test] - fn parse_drive_letter_rejects_invalid_values() { - parse_drive_letter("").unwrap_err(); - parse_drive_letter("12").unwrap_err(); - parse_drive_letter("1:").unwrap_err(); - parse_drive_letter("CD").unwrap_err(); - } - - #[test] - fn from_cli_args_basic_search() { - let args: Vec = [ - "*.rs", - "--drive", - "C", - "--format", - "json", - "--tz-offset", - "-8", - ] - .iter() - .map(ToString::to_string) - .collect(); - let params = SearchParams::from_cli_args(&args).expect("should parse"); - // `*.rs` is promoted to pattern="*" + ext=Some("rs") so the - // daemon can route through the ExtensionIndex fast path in - // `numeric_top_n::ext_fast_path` instead of the trigram + glob - // path. See `is_pure_ext_glob` in cli_args.rs for the shape - // acceptance matrix and `test_from_cli_args_ext_glob_promoted` - // in uffs-client for the full rewrite semantics. - assert_eq!(params.pattern, "*"); - assert_eq!(params.ext.as_deref(), Some("rs")); - assert_eq!(params.drives, vec![uffs_mft::platform::DriveLetter::C]); - assert_eq!(params.output_tz_offset_hours, Some(-8)); - } - - #[test] - fn from_cli_args_sugar_begins_with() { - let args: Vec = ["--begins-with", "report"] - .iter() - .map(ToString::to_string) - .collect(); - let params = SearchParams::from_cli_args(&args).expect("should parse"); - assert_eq!(params.pattern, "report*"); - } - - #[test] - fn from_cli_args_sugar_between() { - let args: Vec = ["*", "--between", "2026-01-01,2026-03-31"] - .iter() - .map(ToString::to_string) - .collect(); - let params = SearchParams::from_cli_args(&args).expect("should parse"); - assert_eq!(params.newer.as_deref(), Some("2026-01-01")); - assert_eq!(params.older.as_deref(), Some("2026-03-31")); - } } From d1743f13ff20c13c262241317e7544104f22e113 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:31:51 -0700 Subject: [PATCH 3/6] docs(statusfmt): describe the crate's real consumer surface The crate header and module docs promised one visual language "for the daemon, broker, combined-system, and MCP status output", which reads as if those binaries render through this crate. They do not: the daemon reports status over the wire (`StatusResponse`) and `uffs-broker --status` is a four-line service probe. The only consumer is `uffs-cli`, whose `--status` / `--daemon status` views cover all four sections. Say so, and record why it stays a separate layer-0 crate instead of folding into `uffs-format` (which pulls `uffs-mft`): the thin CLI must style output without the MFT reader in its dependency graph. --- crates/uffs-statusfmt/Cargo.toml | 16 ++++++++++++---- crates/uffs-statusfmt/src/lib.rs | 14 ++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/uffs-statusfmt/Cargo.toml b/crates/uffs-statusfmt/Cargo.toml index aeb402704..07084eb02 100644 --- a/crates/uffs-statusfmt/Cargo.toml +++ b/crates/uffs-statusfmt/Cargo.toml @@ -3,10 +3,18 @@ # ============================================================================ # Layer 0 leaf crate. Zero external dependencies. # -# One visual language for every `--status` / status surface (daemon, broker, -# combined, MCP): TTY- and NO_COLOR-aware color, health glyphs, aligned -# `key: value` fields, and section headers. Keeps the human output consistent -# and scannable; the machine-readable `--json` form is modelled by each caller. +# One visual language for the CLI's status views — `uffs --status` (daemon, +# broker, combined-system and MCP sections) and `uffs --daemon status`: +# TTY- and NO_COLOR-aware color, health glyphs, aligned `key: value` fields, +# and section headers. Keeps the human output consistent and scannable; the +# machine-readable `--json` form is modelled by each caller. +# +# Consumers: `uffs-cli` only. The daemon exposes status over the wire +# (`StatusResponse`) and the broker's `--status` is a four-line service +# probe; neither renders an operator table of its own, so neither links +# this crate. It stays a separate layer-0 crate (rather than living in +# `uffs-format`, which pulls `uffs-mft`) so the thin CLI can style output +# without dragging the MFT reader into its dependency graph. # ============================================================================ [package] diff --git a/crates/uffs-statusfmt/src/lib.rs b/crates/uffs-statusfmt/src/lib.rs index 8923f92b7..d0737a9bf 100644 --- a/crates/uffs-statusfmt/src/lib.rs +++ b/crates/uffs-statusfmt/src/lib.rs @@ -3,10 +3,16 @@ //! Shared operator-status styling for UFFS `--status` surfaces. //! -//! One visual language for the daemon, broker, combined-system, and MCP status -//! output: a health [`Glyph`], aligned `key: value` [`field`]s, and [`section`] -//! headers, all through a single [`Palette`] that turns color **off** -//! automatically when stdout is not a terminal or `NO_COLOR` is set. +//! One visual language for the CLI's status views — the daemon, broker, +//! combined-system and MCP sections of `uffs --status`, and +//! `uffs --daemon status`: a health [`Glyph`], aligned `key: value` +//! [`field`]s, and [`section`] headers, all through a single [`Palette`] that +//! turns color **off** automatically when stdout is not a terminal or +//! `NO_COLOR` is set. +//! +//! `uffs-cli` is the only consumer. The daemon reports status over the wire +//! and the broker's `--status` is a bare service probe; neither renders an +//! operator table, so neither depends on this crate. //! //! Callers build lines with these helpers and print them; the machine-readable //! `--json` form is modelled separately by each caller (this crate is purely From 86075178910cb0e87b1a4072c07f48489858495c Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:32:10 -0700 Subject: [PATCH 4/6] chore(cli): stop restating the crate version in dependency comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two comments in crates/uffs-cli/Cargo.toml still said `version = "0.5.90"` while the pins beside them read 0.6.39 — the release bump rewrites the value but not the prose. Describe the field without quoting a number so the comment cannot rot again (the same lesson rust-toolchain.toml's header records for the channel date). --- crates/uffs-cli/Cargo.toml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/uffs-cli/Cargo.toml b/crates/uffs-cli/Cargo.toml index 80449b702..b4507f638 100644 --- a/crates/uffs-cli/Cargo.toml +++ b/crates/uffs-cli/Cargo.toml @@ -66,16 +66,18 @@ path = "src/main.rs" # Not using `.workspace = true` here: workspace inheritance cannot # override `default-features`, so we point at the path directly. The # version is pinned by the workspace via path dependency resolution. -# `version = "0.5.90"` is required for `cargo package` validation — -# see root `Cargo.toml`'s [workspace.dependencies] note for the full +# The explicit `version = "…"` (kept in step with the workspace version +# by the release bump) is required for `cargo package` validation — see +# root `Cargo.toml`'s [workspace.dependencies] note for the full # rationale (R6 of `release-automation-plan.md`). uffs-client = { path = "../uffs-client", version = "0.6.39", default-features = false } # Canonical CSV / parity / legacy-footer writer. Direct dep (not a # re-export chain through `uffs-client`) so the CLI and the daemon # hit the same `uffs_format::*` symbols without an indirection layer. -# `version = "0.5.90"` is required for `cargo package` validation — -# see root `Cargo.toml`'s [workspace.dependencies] note for the full +# The explicit `version = "…"` (kept in step with the workspace version +# by the release bump) is required for `cargo package` validation — see +# root `Cargo.toml`'s [workspace.dependencies] note for the full # rationale (R6 of `release-automation-plan.md`). uffs-format = { path = "../uffs-format", version = "0.6.39" } From d29c24101c72db83bfdfc5ae44b9af987b7cc76f Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:32:20 -0700 Subject: [PATCH 5/6] docs(cli): say why the thin client parses its arguments by hand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README explained the thin client's dropped tokio/ws2_32 dependency but never why there is no clap — the question every reader of src/args.rs asks. Add the answer with its evidence from docs/research/cross-tool-benchmark-analysis.md §4.1.1: Windows process creation is ~12 ms + ~2.7 ms per MB, the 52.7 MB fat client spent 136 ms before main(), a clap parse is ~1 ms — the cost was weight, not parsing — and the thin client landed at ~774 KB. Name where clap still lives (uffs-mft, uffs-daemon, uffs-mcp, uffs-bench) and give the two-line PowerShell recipe to re-measure size and cold start on a release box. --- crates/uffs-cli/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/uffs-cli/README.md b/crates/uffs-cli/README.md index f04bbc688..46d027a0a 100644 --- a/crates/uffs-cli/README.md +++ b/crates/uffs-cli/README.md @@ -33,6 +33,31 @@ The "thin client" design has three concrete consequences: CLI auto-spawns the daemon if it isn't already running; subsequent invocations reuse the daemon over the warm socket. +### Why the fast path parses its arguments by hand + +`uffs-cli` has no `clap` dependency, on purpose. The cross-tool +benchmark (`docs/research/cross-tool-benchmark-analysis.md`, §4.1.1) +measured Windows process creation at **~12 ms + ~2.7 ms per MB of +binary**: the original 52.7 MB fat client took 152 ms to start, 136 ms +of it before `main()` ran. Parsing was never the cost — a clap +`Cli::parse()` is ~1 ms — the *weight* was. So the thin client dropped +Polars, tokio, tracing and clap together and went from 52.7 MB to +~774 KB (Phase 1, v0.5.x). What is left is a handful of `--flag` +matches in `src/args.rs` and `src/commands/search/args.rs`; the real +search-grammar validation is `SearchParams::from_cli_args` in +`uffs-client`, shared with the daemon, so the CLI never re-implements +it. + +clap stays where startup does not matter: `uffs-mft`, `uffs-daemon`, +`uffs-mcp` and `uffs-bench`. + +To re-measure on a release box (PowerShell, elevated shell not needed): + +```powershell +(Get-Item (Get-Command uffs).Source).Length / 1KB # binary size, KB +1..10 | ForEach-Object { (Measure-Command { uffs --version }).TotalMilliseconds } +``` + ## Install ```bash From 035ab344be36c55cf2d0758da2acdd4fe503999c Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:40:16 -0700 Subject: [PATCH 6/6] chore: development v0.6.40 - comprehensive testing complete [auto-commit] --- CHANGELOG.md | 9 ++++++- Cargo.lock | 52 +++++++++++++++++++------------------- Cargo.toml | 28 ++++++++++---------- crates/uffs-cli/Cargo.toml | 4 +-- 4 files changed, 50 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a65754b62..dbe5f095c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.40] - 2026-08-31 + +### Changed + +- cli: move the four pre-#436 command entry points out of main.rs + ## [0.6.38] - 2026-08-24 ### Added @@ -2839,7 +2845,8 @@ thin clients over a unified `uffsd` process. ### Fixed - Various MFT parsing edge cases -[Unreleased]: https://github.com/skyllc-ai/UltraFastFileSearch/compare/v0.6.38...HEAD +[Unreleased]: https://github.com/skyllc-ai/UltraFastFileSearch/compare/v0.6.40...HEAD +[0.6.40]: https://github.com/skyllc-ai/UltraFastFileSearch/compare/v0.6.38...v0.6.40 [0.6.38]: https://github.com/skyllc-ai/UltraFastFileSearch/compare/v0.6.37...v0.6.38 [0.6.37]: https://github.com/skyllc-ai/UltraFastFileSearch/compare/v0.6.36...v0.6.37 [0.6.36]: https://github.com/skyllc-ai/UltraFastFileSearch/compare/v0.6.35...v0.6.36 diff --git a/Cargo.lock b/Cargo.lock index d9281016e..d17e4b282 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4329,7 +4329,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uffs-bench" -version = "0.6.39" +version = "0.6.40" dependencies = [ "chrono", "clap", @@ -4346,7 +4346,7 @@ dependencies = [ [[package]] name = "uffs-broker" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "serde", @@ -4364,14 +4364,14 @@ dependencies = [ [[package]] name = "uffs-broker-protocol" -version = "0.6.39" +version = "0.6.40" dependencies = [ "thiserror 2.0.20", ] [[package]] name = "uffs-ci-pipeline" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "chrono", @@ -4390,7 +4390,7 @@ dependencies = [ [[package]] name = "uffs-cli" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "assert_cmd", @@ -4413,7 +4413,7 @@ dependencies = [ [[package]] name = "uffs-client" -version = "0.6.39" +version = "0.6.40" dependencies = [ "dirs-next", "libc", @@ -4433,7 +4433,7 @@ dependencies = [ [[package]] name = "uffs-core" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "bytemuck", @@ -4464,7 +4464,7 @@ dependencies = [ [[package]] name = "uffs-daemon" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "clap", @@ -4497,7 +4497,7 @@ dependencies = [ [[package]] name = "uffs-diag" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "chrono", @@ -4512,7 +4512,7 @@ dependencies = [ [[package]] name = "uffs-fetch" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "hex", @@ -4523,7 +4523,7 @@ dependencies = [ [[package]] name = "uffs-format" -version = "0.6.39" +version = "0.6.40" dependencies = [ "chrono", "itoa", @@ -4534,7 +4534,7 @@ dependencies = [ [[package]] name = "uffs-gen-hooks" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "clap", @@ -4546,7 +4546,7 @@ dependencies = [ [[package]] name = "uffs-gen-workflow" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "clap", @@ -4559,7 +4559,7 @@ dependencies = [ [[package]] name = "uffs-manifest-audit" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "clap", @@ -4571,7 +4571,7 @@ dependencies = [ [[package]] name = "uffs-mcp" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "axum", @@ -4595,7 +4595,7 @@ dependencies = [ [[package]] name = "uffs-mft" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "bitflags", @@ -4636,14 +4636,14 @@ dependencies = [ [[package]] name = "uffs-polars" -version = "0.6.39" +version = "0.6.40" dependencies = [ "polars", ] [[package]] name = "uffs-security" -version = "0.6.39" +version = "0.6.40" dependencies = [ "aes-gcm", "dirs-next", @@ -4658,22 +4658,22 @@ dependencies = [ [[package]] name = "uffs-statusfmt" -version = "0.6.39" +version = "0.6.40" [[package]] name = "uffs-text" -version = "0.6.39" +version = "0.6.40" dependencies = [ "bytemuck", ] [[package]] name = "uffs-time" -version = "0.6.39" +version = "0.6.40" [[package]] name = "uffs-update" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "dirs-next", @@ -4690,11 +4690,11 @@ dependencies = [ [[package]] name = "uffs-version" -version = "0.6.39" +version = "0.6.40" [[package]] name = "uffs-vss-requestor" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "cc", @@ -4706,7 +4706,7 @@ dependencies = [ [[package]] name = "uffs-watchdog" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "dirs-next", @@ -4715,7 +4715,7 @@ dependencies = [ [[package]] name = "uffs-winsvc" -version = "0.6.39" +version = "0.6.40" dependencies = [ "anyhow", "windows", diff --git a/Cargo.toml b/Cargo.toml index ff5c68adb..51e74dcb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,7 +68,7 @@ members = [ # Workspace Package Metadata (inherited by all crates) # ───────────────────────────────────────────────────────────────────────────── [workspace.package] -version = "0.6.39" +version = "0.6.40" edition = "2024" # No `rust-version` claim: the workspace is structurally nightly-only. # `crates/uffs-polars` enables `polars/nightly` unconditionally, which @@ -135,36 +135,36 @@ publish = false # proposed-plan output for 12 days because `release-plz update` # failed at `cargo package` with this very error. See # `release-automation-baseline.md` §10 for the diagnostic trail. -uffs-polars = { path = "crates/uffs-polars", version = "0.6.39" } -uffs-security = { path = "crates/uffs-security", version = "0.6.39" } -uffs-text = { path = "crates/uffs-text", version = "0.6.39" } -uffs-time = { path = "crates/uffs-time", version = "0.6.39" } -uffs-version = { path = "crates/uffs-version", version = "0.6.39" } -uffs-statusfmt = { path = "crates/uffs-statusfmt", version = "0.6.39" } -uffs-mft = { path = "crates/uffs-mft", version = "0.6.39" } -uffs-format = { path = "crates/uffs-format", version = "0.6.39" } -uffs-core = { path = "crates/uffs-core", version = "0.6.39" } -uffs-client = { path = "crates/uffs-client", version = "0.6.39" } +uffs-polars = { path = "crates/uffs-polars", version = "0.6.40" } +uffs-security = { path = "crates/uffs-security", version = "0.6.40" } +uffs-text = { path = "crates/uffs-text", version = "0.6.40" } +uffs-time = { path = "crates/uffs-time", version = "0.6.40" } +uffs-version = { path = "crates/uffs-version", version = "0.6.40" } +uffs-statusfmt = { path = "crates/uffs-statusfmt", version = "0.6.40" } +uffs-mft = { path = "crates/uffs-mft", version = "0.6.40" } +uffs-format = { path = "crates/uffs-format", version = "0.6.40" } +uffs-core = { path = "crates/uffs-core", version = "0.6.40" } +uffs-client = { path = "crates/uffs-client", version = "0.6.40" } # `uffs-broker-protocol` carries the wire-protocol types shared between # `uffs-broker` (the elevated handle vendor, Windows-only binary) and # `uffs-daemon::broker_client` (the handle consumer). Pure-logic # Layer-0 lib — cross-platform tests run on every CI lane. Added in # F5 (issue #205) so neither side duplicates `BROKER_PIPE_NAME` / # wire-format byte literals. -uffs-broker-protocol = { path = "crates/uffs-broker-protocol", version = "0.6.39" } +uffs-broker-protocol = { path = "crates/uffs-broker-protocol", version = "0.6.40" } # `uffs-winsvc` — native Windows service control (SCM query/start/stop) + # the non-connecting broker-pipe readiness probe. Layer-0 leaf: its only # dependency is the `windows` crate (windows-target), with non-Windows # stubs so cross-platform consumers (uffs-update, uffs-cli) compile. # Single source of truth for the `sc`/SCM mechanics previously duplicated # across uffs-broker, uffs-update, and uffs-cli. -uffs-winsvc = { path = "crates/uffs-winsvc", version = "0.6.39" } +uffs-winsvc = { path = "crates/uffs-winsvc", version = "0.6.40" } # `uffs-fetch` — hardened release-asset transport (blocking reqwest + # rustls with retry/timeout/byte-cap, plus `SHA256SUMS` verification), # extracted from `uffs-update` as a small public lib so external products # can reuse it. Cross-platform pure-logic leaf; keeps the HTTP/TLS stack # out of the lean `uffs` CLI exactly as before. -uffs-fetch = { path = "crates/uffs-fetch", version = "0.6.39" } +uffs-fetch = { path = "crates/uffs-fetch", version = "0.6.40" } # NOTE: no `uffs-broker` workspace dependency alias on purpose — # `uffs-broker` is a binary-only crate (the only `[lib]` it carries is # this protocol module's now-extracted sibling); no other workspace diff --git a/crates/uffs-cli/Cargo.toml b/crates/uffs-cli/Cargo.toml index b4507f638..f4589be90 100644 --- a/crates/uffs-cli/Cargo.toml +++ b/crates/uffs-cli/Cargo.toml @@ -70,7 +70,7 @@ path = "src/main.rs" # by the release bump) is required for `cargo package` validation — see # root `Cargo.toml`'s [workspace.dependencies] note for the full # rationale (R6 of `release-automation-plan.md`). -uffs-client = { path = "../uffs-client", version = "0.6.39", default-features = false } +uffs-client = { path = "../uffs-client", version = "0.6.40", default-features = false } # Canonical CSV / parity / legacy-footer writer. Direct dep (not a # re-export chain through `uffs-client`) so the CLI and the daemon @@ -79,7 +79,7 @@ uffs-client = { path = "../uffs-client", version = "0.6.39", default-features = # by the release bump) is required for `cargo package` validation — see # root `Cargo.toml`'s [workspace.dependencies] note for the full # rationale (R6 of `release-automation-plan.md`). -uffs-format = { path = "../uffs-format", version = "0.6.39" } +uffs-format = { path = "../uffs-format", version = "0.6.40" } # Typed drive-letter newtype. Direct dep so the CLI command signatures # (`daemon_load`, `daemon_tiering`, etc.) name `DriveLetter` natively