use std::ffi::{OsStr, OsString};
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime};
use std::borrow::Cow;
use clap::builder::styling::{AnsiColor, Style as AnsiStyle, Styles};
use clap::{ArgAction, ColorChoice, CommandFactory, FromArgMatches, Parser, ValueEnum};
use fdu_core::content::AnalysisSet;
use fdu_core::control::ControlCoverage;
#[cfg(test)]
use fdu_core::query::IgnoredEntries;
#[cfg(feature = "watch")]
use fdu_core::query::parse_when;
use fdu_core::query::{
AxisNames, Delivery, ReadSpec, Report, ReportSource, Request, RequestError, RequestSpec,
SizeMetric, WatchDelivery, parse_cache_policy,
};
use fdu_core::report_format;
use fdu_core::report_format::human_count;
use fdu_core::{CachePolicy, CacheScope, CacheState, Progress, default_cache_path_in};
use fdu_core::{
PerformanceSummary, prepare_report, prepare_report_with_progress,
prepare_report_with_scan_diagnostics,
};
use crate::progress_line::{
ProgressMode, ProgressPlan, TerminalFacts, display_root, home_directory, should_draw,
};
use crate::progress_ticker::{ProgressIo, Ticker};
use crate::skill_install;
const SKILL_TEMPLATE: &str = include_str!("skills/SKILL.md");
const SCAN_DIAGNOSTICS_ENV: &str = "FDU_SCAN_DIAGNOSTICS";
const SCAN_DIAGNOSTICS_PREFIX: &str = "__FDU_SCAN_DIAGNOSTICS__=";
#[cfg(feature = "watch")]
#[derive(Clone, Copy)]
struct WatchPresentation {
render: report_format::RenderOptions,
diagnostic_color: bool,
quiet: bool,
}
const STYLE_HEADING: AnsiStyle = report_format::STYLE_HEADING;
const STYLE_WARNING: AnsiStyle = AnsiColor::Yellow.on_default();
pub(crate) const STYLE_ERROR: AnsiStyle = AnsiColor::Red.on_default().bold();
const STYLE_CAUSE: AnsiStyle = AnsiStyle::new().dimmed();
const STYLE_PERFORMANCE: AnsiStyle = report_format::STYLE_DETAIL;
#[cfg(feature = "watch")]
const STYLE_WATCH_RULE: AnsiStyle = report_format::STYLE_DETAIL;
const CLI_STYLES: Styles = Styles::styled()
.header(STYLE_HEADING)
.usage(STYLE_HEADING)
.literal(AnsiColor::Green.on_default())
.placeholder(AnsiColor::Cyan.on_default())
.error(STYLE_ERROR)
.valid(AnsiColor::Green.on_default())
.invalid(AnsiColor::Yellow.on_default());
const DOCS_POINTER: &str = r"Agent setup:
uvx --no-build fdu@latest --install-skill
Examples:
fdu . directory sizes (metadata only)
fdu . --ignored=exclude omit entries covered by .gitignore
fdu . --view=summary one total for the tree
fdu . --analyze=code standard lines of code by language
fdu . --kind dir --include .venv --modified-before 7d --long
fdu . --kind dir --include node_modules --modified-before 30d --long
fdu . --kind dir --include target --modified-before 30d --format paths
Run `fdu --docs` for setup, libraries, more commands, cache behavior, and the full usage guide.";
macro_rules! docs_guide {
($watch_composition:literal, $mode_flags:literal) => {
concat!(
r"fdu — a fast, incremental file roll-up engine.
SET UP WITH ANY CODING AGENT
Install fdu's self-contained skill for current and future agent sessions:
uvx --no-build fdu@latest --install-skill
Run it from the project root. The generated SKILL.md needs no prior session
context. If fdu is on PATH, `fdu --install-skill` is equivalent.
INSTALL THE COMMAND LINE
Run the latest release once, or keep it on PATH:
uvx --no-build fdu@latest PATH
uv tool install --no-build fdu
fdu PATH
The wheel requires GIL-enabled Python 3.12 or newer. uv normally selects a
matching interpreter; if it selects a free-threaded build such as 3.14t,
add `--python 3.14` to the uvx or uv tool command.
USE AS A LIBRARY
Python: `uv add fdu` (or `pip install fdu`)
Rust: `cargo add fdu` (or `cargo add fdu-core` for the engine alone)
START HERE
A report requires a PATH. Use `.` for the current directory.
fdu . directory sizes (the default)
fdu . --ignored=exclude omit entries covered by .gitignore
fdu . --view=summary one total for the tree
fdu . --view=languages languages by byte size
fdu . --view=families,types,extensions three file-kind breakdowns
fdu . --view=recent --limit=10 ten most recently modified files
fdu . --analyze=lines physical lines and raw words
fdu . --analyze=lines --view=languages those metrics by language
fdu . --analyze=code standard lines of code by language
fdu . --analyze=words prose volume by document type
`fdu .` is metadata-only. It prints a tree in allocated bytes, largest first,
to depth 5, showing contents with at least 1% of the selected root size. Hidden
and gitignored entries are included; .gitignore is read to label gitignored shares, not to exclude them.
VIEWS AND ANALYSIS
--view chooses the question the report answers. Several views share one scan
and one requested analysis; adding a view does not run a second scan.
--analyze opts into reading eligible file bodies. Without it, regular file
contents are not opened. Compatible cached results avoid rereading unchanged
bodies, so a repeated content analysis can be much cheaper.
Naming analyzers selects a view that displays them: code selects code, words
selects documents, code,words selects both, and lines selects families.
Name --view for a different projection; it always wins. Headers name views;
columns name measurements. words is an analyzer; documents selects prose/markup.
Use --analyze words for that report, or add --view types for all text types.
A view never turns on an analyzer, because choosing how to look at a result
should not quietly authorize reading every file in the tree. If a selected
view cannot display requested analysis, fdu still performs the analysis and
prints a note. --view=full names any view it had to skip.
MORE COMPOSITIONS
fdu ~/Downloads --view=extensions
fdu . --view=types,families --format=json
fdu . --analyze=words --view=documents
fdu PATH --view tree --full --format json complete recursive tree
fdu PATH --kind dir --full --format json recursive directory totals
fdu PATH --kind file --full --format paths find/fd-style file inventory
fdu PATH --view=largest --limit=100 the 100 largest files
fdu PATH --view=files --kind=file --modified-since=1h files changed lately
fdu PATH --view=files --ignored=only --format=jsonl what .gitignore covers
",
$watch_composition,
r"
largest and recent are presets over files, not more views to learn:
largest = files --sort size --limit 20, regular files only
recent = files --sort mtime --limit 20, regular files only
--full expands to --depth=all --breadth=all --limit=all --min-share=0%.
Explicit bounds override it regardless of order. It leaves view, scan scope,
population, and analysis unchanged. --view=full chooses views instead.
--sort and --limit still override them. files alone is complete: every
matching entry, in name order. full combines applicable views, without list/files.
LIST FORMATS AND OLD BUILD DIRECTORIES
The metadata default view is list; its default format is tree. These agree:
fdu PATH
fdu PATH --view list --format tree
Tree shows directories and significant files to depth 5. --min-share 1% compares
every row to the selected root total. --min-share 0% shows even zero-size rows.
--breadth caps children per directory; --limit caps data rows per section.
Both default to all. --depth all expands levels. Omission notes name each bound.
Display bounds never change totals or limit the filesystem scan.
--format paths gives matching paths only; --long adds size, age, and path.
Flat lists are complete and size-ranked by default; --sort name lists by name.
JSON, JSONL, and YAML give exact metrics. text keeps automatic human tables.
Tree/paths/long require a single list view; use text or machine formats for
grouped/mixed views and full. largest/recent accept paths/long, keeping file ranks.
files keeps name order; the tree view keeps structured tree output.
Explicit paths/long overrides the tree presentation. Format flags conflict.
fdu PATH --kind dir --include .venv --modified-before 7d --long
fdu PATH --kind dir --include node_modules --modified-before 30d --format long
fdu PATH --kind dir --include target --modified-before 30d --format paths
fdu PATH --kind dir --include .venv --include venv --include node_modules --include target --modified-before 30d --long --sort mtime --reverse
fdu PATH --kind dir --include .venv --modified-before 30d --format json
Kind, name/path, size, and age are filters. Repeated includes form a union.
Directory sizes sum eligible regular-file contents, excluding inode/symlink bytes.
Age uses the newest modification of the root or an eligible descendant, including
directories and symlinks. Empty directories use their own time; future age is negative.
This is modification activity, not access or last use. target is a naming convention.
Exclusions win throughout the subtree before size/age filtering. Nested matching
roots can overlap; aggregate views count the covered contents once. --size apparent
selects logical bytes. Paths/long omit the footer and send bound notices to stderr.
SIX AXES, AND EVERY OPTION BELONGS TO EXACTLY ONE
Scope PATH, --scan-depth, --one-filesystem what is scanned and cached
--gitignore-budget, --gitignore-line-limit, --no-gitignore, --ignored
Content --analyze none|lines|code|words|all which file bodies are read
Selection --include, --exclude, --depth, --limit which entries are considered
--breadth, --min-share
View list,summary,tree,families,types,extensions,languages,code,documents,
largest,recent,files,full
Format --format text|tree|paths|long|json|jsonl|yaml, --tree, --long
--color, --progress
Mode ",
$mode_flags,
r"
CONTENT ANALYSIS
none metadata only; source files are never opened (default)
lines physical, blank, and nonblank lines plus raw word counts
code standard SLOC from the versioned common-language analyzer
words normalized and reader-visible word volume
all every shipped analyzer
A comma-separated set: code,words runs both. none and all name the whole
axis and cannot be combined. code and words already include lines; adding lines
explicitly changes neither measurements nor work. lines alone measures physical
text volume without language-specific code counting or word normalization.
languages is metadata-only by default; --view code requires --analyze code.
Code reports show source lines, language shares, population columns, and coverage.
documents requires any enabled analyzer.
Analysis streams every eligible file through EOF; files are never size-truncated.
--workers bounds content-analysis concurrency; directory scanning uses its own pool.
--words-per-page changes only report-time page derivation.
Unchanged results are restored from a separate sidecar written by the same
analyzer set; any other set, wider or narrower, reads the files again.
--stale-ok never opens source files and fails if requested content is absent.
CACHE BEHAVIOR
--cache=auto, the default, uses the cache only where the kind of run gains from
it. A metadata report neither reads nor writes one: checking a snapshot costs as
much as the scan it would save, and no later report reads it. Content analysis
and long-lived sessions read, revalidate, and write it. --cache=on also writes
after a one-shot report, leaving a snapshot for a later --stale-ok answer.
Content analysis is where repeated-run caching pays most. The first run reads
eligible file bodies. A compatible later run reuses results for unchanged files
and reads only changed or newly eligible bodies; the performance footer reports
fresh and cached analysis separately. Repeat the same --analyze command to see it.
--stale-ok answers from the snapshot alone: it does no filesystem verification,
requires a compatible snapshot and content sidecar for the requested analysis,
and labels its answer stale with a warn: line on stderr that --quiet keeps.
--cache=off neither reads nor writes fdu's cache.
macOS and Linux default to ~/.cache/fdu; Windows uses %LOCALAPPDATA%/fdu.
--cache-dir overrides FDU_CACHE_DIR, then XDG_CACHE_HOME/fdu and native defaults.
Each root has <key>.metadata.bin and optional <key>.analysis.bin. The latter
stores derived metrics, not source bodies. Status and clear use the same directory.
IGNORE RULES
Fresh scans read applicable .gitignore files by default; --stale-ok uses retained
rules. Summary, tree, and extension rows show gitignored size as `(128 B gitignored)`.
Ignoring a directory covers its descendants. Non-gitignored does not mean Git-tracked:
.git is non-gitignored unless a rule names it. --ignored=include is default.
--ignored=exclude prunes safely gitignored subtrees and skips gitignored body reads.
--ignored=only discovers gitignored matches through ordinary ancestors, reading
only gitignored bodies for analysis. Sort and --min-size follow the size shown.
--no-gitignore reads no rules and shows no share. Only per-directory .gitignore
files apply, not core.excludesFile, .git/info/exclude, or a global ignore file.
Each is found as git opens it, so a .GITIGNORE counts on a case-insensitive
volume; matching itself is case-sensitive on every platform. An unreadable
.gitignore makes the result partial, like any unreadable path. A .gitignore past
--gitignore-budget or --gitignore-line-limit is refused whole and named in a note:
sizes stay exact, gitignored shares under that directory do not.
OUTPUT AND AUTOMATION
Every machine report uses fdu.report/10; watch changes use fdu.stream/2.
Cache status is its own document in every machine format: fdu.cache/3.
Summary, tree, extension, and file rows carry `ignored`: null under --no-gitignore.
Text language rows use canonical names; machine formats retain lowercase IDs.
Metric rows include detection source, confidence, origin flags, and coverage.
Tree remainder totals are shared by every format: recursive files, apparent and
allocated bytes, and applicable reasons. Null means nothing hidden; unknown counts
or sizes stay null. Text shows one root-level line: ... and SIZE (N files) more.
Human diagnostics use note:, warn:, tip:, and perf: on stderr, in that order.
One-shot text reports end with gray perf: on stderr; machine formats omit it.
It counts ignore files and accepted rules, including repeated governing sources.
Total files/s and binary GiB/s use the displayed elapsed duration. GiB/s represents
walked file size; actual body-read throughput is reported separately.
JSON numbers above 2^53 (fingerprints, option hashes, nanosecond timestamps)
lose precision in IEEE 754 binary64 parsers such as JavaScript JSON.parse.
Results go to stdout; all diagnostics, including warnings and errors, go to stderr.
The command never prompts or pages. A progress line is drawn on stderr only for a
person at an interactive terminal; --progress never draws into a pipe, a file, or CI.
Reports require an explicit PATH; bare `fdu` prints help and scans nothing.
`fdu --install-skill` writes a portable agent skill describing this same surface
under the project root, and `fdu --skill` prints it.
EXIT STATUS
0 Complete result, or a partial result accepted with --allow-partial
1 Fatal filesystem or cache error
2 Partial result, or command-line usage error
"
)
};
}
#[cfg(feature = "watch")]
const DOCS: &str = docs_guide!(
" fdu --watch --view files --format jsonl PATH a tail -f for a tree
--interval throttles rendering only; change detection is event-driven and
unaffected by it, so an idle tree costs nothing between changes.
The duration uses the age grammar: `2s`, `200ms`, `1h30m`.
",
"--cache, --watch, --workers"
);
#[cfg(not(feature = "watch"))]
const DOCS: &str = docs_guide!("", "--cache, --workers");
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum ColorWhen {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug)]
struct UsageError(String);
impl std::fmt::Display for UsageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for UsageError {}
const SIZE_DEFAULT: &str = Request::DEFAULTS.size.label();
const ANALYZE_DEFAULT: &str = AnalysisSet::NONE_LABEL;
const _: () = assert!(
!Request::DEFAULTS.content.is_enabled(),
"--help prints the default analyzer set; a table that enables one needs a spelling here"
);
fn refused(error: &RequestError) -> anyhow::Error {
anyhow::anyhow!(error.message(&AxisNames::FLAGS))
}
fn usage(error: &anyhow::Error) -> anyhow::Error {
anyhow::Error::new(UsageError(error.to_string()))
}
fn is_usage_error(error: &anyhow::Error) -> bool {
error.chain().any(|cause| cause.downcast_ref::<UsageError>().is_some())
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RunOutcome {
Complete,
Partial,
}
#[derive(Parser, Debug)]
#[command(
name = "fdu",
// Set by build.rs: the package semver plus the git revision on dev builds, so a
// binary built from a checkout never impersonates the published release.
version = env!("FDU_BUILD_VERSION"),
about,
long_about = None,
styles = CLI_STYLES,
after_help = DOCS_POINTER,
// Wrap at the terminal's width, never wider than this. clap takes the smaller of the
// two, so a narrow terminal still narrows and a wide one stops at a readable measure
// rather than running a description across the whole screen.
max_term_width = 120,
// `--help` renders the same compact block `-h` does. clap's long help puts every
// description on its own line with a blank line between flags, which roughly doubles
// the height and breaks a section into a list of islands; the prose that justified
// that layout now lives in `--docs`.
disable_help_flag = true,
disable_version_flag = true,
arg_required_else_help = true,
override_usage = "fdu [OPTIONS] <PATH>\n fdu [PATH] --cache-status[=<SCOPE>] [--cache-clear[=<SCOPE>]]\n fdu [PATH] --cache-clear[=<SCOPE>]\n fdu --docs\n fdu --skill\n fdu --install-skill [--agent-base <DIR>]"
)]
#[allow(clippy::struct_excessive_bools)]
pub struct Cli {
#[arg(
required_unless_present_any = ["docs", "skill", "install_skill", "cache_status", "cache_clear"],
help_heading = "ARGUMENTS"
)]
pub path: Option<PathBuf>,
#[arg(long, value_name = "N", help_heading = "SCOPE")]
pub scan_depth: Option<usize>,
#[arg(long, action = ArgAction::SetTrue, help_heading = "SCOPE")]
pub one_filesystem: bool,
#[arg(long, value_name = "SIZE", help_heading = "SCOPE")]
pub gitignore_budget: Option<String>,
#[arg(long, value_name = "SIZE", help_heading = "SCOPE")]
pub gitignore_line_limit: Option<String>,
#[arg(long, action = ArgAction::SetTrue, help_heading = "SCOPE")]
pub no_gitignore: bool,
#[arg(long, value_name = "GLOB", help_heading = "SELECTION")]
pub include: Vec<String>,
#[arg(long, value_name = "GLOB", help_heading = "SELECTION")]
pub exclude: Vec<String>,
#[arg(long, value_name = "SIZE", help_heading = "SELECTION")]
pub min_size: Option<String>,
#[arg(long, value_name = "WHEN", help_heading = "SELECTION")]
pub modified_since: Option<String>,
#[arg(long, value_name = "WHEN", help_heading = "SELECTION")]
pub modified_before: Option<String>,
#[arg(long, value_name = "LIST", help_heading = "SELECTION")]
pub kind: Option<String>,
#[arg(long, value_name = "MODE", help_heading = "SCOPE")]
pub ignored: Option<String>,
#[arg(short, long, value_name = "N", help_heading = "SELECTION")]
pub depth: Option<String>,
#[arg(short = 'n', long, value_name = "N", help_heading = "SELECTION")]
pub limit: Option<String>,
#[arg(long, value_name = "N", help_heading = "SELECTION")]
pub breadth: Option<String>,
#[arg(long, value_name = "PERCENT", help_heading = "SELECTION")]
pub min_share: Option<String>,
#[arg(long, action = ArgAction::SetTrue, help_heading = "SELECTION")]
pub full: bool,
#[arg(long, value_name = "KEY", help_heading = "SELECTION")]
pub sort: Option<String>,
#[arg(long, action = ArgAction::SetTrue, help_heading = "SELECTION")]
pub reverse: bool,
#[arg(long, value_name = "METRIC", default_value = SIZE_DEFAULT, help_heading = "SELECTION")]
pub size: String,
#[arg(long, value_name = "LIST", help_heading = "VIEWS")]
pub view: Option<String>,
#[arg(
long,
value_name = "LIST",
default_value = ANALYZE_DEFAULT,
help_heading = "CONTENT ANALYSIS"
)]
pub analyze: String,
#[arg(
long = "workers",
value_name = "N",
default_value_t = 0,
help_heading = "CONTENT ANALYSIS"
)]
pub analysis_workers: usize,
#[arg(
long,
value_name = "N",
default_value_t = fdu_core::query::Request::DEFAULTS.words_per_page,
help_heading = "VIEWS"
)]
pub words_per_page: u64,
#[arg(long, value_name = "FORMAT", default_value = "text", help_heading = "OUTPUT")]
pub format: String,
#[arg(long, conflicts_with_all = ["format", "long"], help_heading = "OUTPUT")]
pub tree: bool,
#[arg(long, conflicts_with_all = ["format", "tree"], help_heading = "OUTPUT")]
pub long: bool,
#[arg(
long,
value_name = "WHEN",
default_value = "auto",
hide_possible_values = true,
help_heading = "OUTPUT"
)]
pub color: ColorWhen,
#[arg(
long,
value_name = "WHEN",
default_value = "auto",
hide_possible_values = true,
help_heading = "OUTPUT"
)]
pub progress: ProgressMode,
#[arg(
long,
value_name = "N",
default_value_t = 10,
allow_hyphen_values = true,
help_heading = "OUTPUT"
)]
pub bar_size: i64,
#[arg(short = 'q', long, action = ArgAction::SetTrue, help_heading = "OUTPUT")]
pub quiet: bool,
#[arg(long, value_name = "POLICY", default_value = "auto", help_heading = "EXECUTION")]
pub cache: String,
#[arg(long, action = ArgAction::SetTrue, help_heading = "EXECUTION")]
pub stale_ok: bool,
#[arg(long, value_name = "DIR", help_heading = "DELIVERY")]
pub cache_dir: Option<PathBuf>,
#[arg(long, action = ArgAction::SetTrue, help_heading = "EXECUTION")]
pub allow_partial: bool,
#[arg(long, value_name = "SCOPE", num_args = 0..=1, require_equals = true, default_missing_value = "root", help_heading = "CACHE MANAGEMENT")]
pub cache_status: Option<String>,
#[arg(long, value_name = "SCOPE", num_args = 0..=1, require_equals = true, default_missing_value = "root", help_heading = "CACHE MANAGEMENT")]
pub cache_clear: Option<String>,
#[cfg(feature = "watch")]
#[arg(long, action = ArgAction::SetTrue, help_heading = "EXECUTION")]
pub watch: bool,
#[cfg(feature = "watch")]
#[arg(long, value_name = "DUR", default_value_t = format!("{}s", WatchDelivery::DEFAULT_INTERVAL.as_secs()), help_heading = "EXECUTION")]
pub interval: String,
#[arg(short = 'h', long = "help", action = ArgAction::HelpShort, help_heading = "OTHER")]
pub help: Option<bool>,
#[arg(short = 'V', long = "version", action = ArgAction::Version, help_heading = "OTHER")]
pub version: Option<bool>,
#[arg(long, action = ArgAction::SetTrue, help_heading = "OTHER")]
pub docs: bool,
#[arg(long, action = ArgAction::SetTrue, help_heading = "OTHER")]
pub skill: bool,
#[arg(long, action = ArgAction::SetTrue, help_heading = "OTHER")]
pub install_skill: bool,
#[arg(long, value_name = "DIR", requires = "install_skill", help_heading = "OTHER")]
pub agent_base: Option<PathBuf>,
}
fn parse_cache_scope(value: &str, flag: &str) -> anyhow::Result<CacheScope> {
CacheScope::parse(value).ok_or_else(|| {
anyhow::anyhow!(
"invalid {flag} {:?}: expected root or all",
value.trim().to_ascii_lowercase()
)
})
}
impl Cli {
pub fn run(
&self,
out: &mut dyn Write,
diagnostic: &mut dyn Write,
stdout_is_terminal: bool,
terminal: &TerminalFacts,
progress_io: ProgressIo,
) -> anyhow::Result<RunOutcome> {
let stderr_is_terminal = terminal.stderr_is_terminal;
if self.docs {
let color =
ColorContext::from_environment(self.color, false, false, stdout_is_terminal)
.enabled();
write!(out, "{}", style_guide(DOCS, color))?;
return Ok(RunOutcome::Complete);
}
if self.skill {
write!(out, "{}", compose_skill())?;
return Ok(RunOutcome::Complete);
}
if self.install_skill {
return self.run_install_skill(out);
}
if self.cache_clear.is_some() || self.cache_status.is_some() {
return self.run_cache_lifecycle(out, stdout_is_terminal);
}
let format = self.parse_format().map_err(|error| usage(&error))?;
let path = self.path.as_deref().ok_or_else(|| {
usage(&anyhow::anyhow!(
"missing PATH: specify the directory to summarize, for example `fdu .`"
))
})?;
let request = self.request(path, SystemTime::now())?;
let has_tree = request.query.views.iter().any(|view| {
matches!(view, fdu_core::query::ViewSpec::List | fdu_core::query::ViewSpec::Tree)
});
if has_tree
&& matches!(format, report_format::Format::Text | report_format::Format::Tree)
&& self.bar_size() > report_format::MAX_BAR_SIZE
{
return Err(usage(&anyhow::anyhow!(
"invalid --bar-size \"{}\": expected at most {} cells",
self.bar_size,
report_format::MAX_BAR_SIZE
)));
}
let delivery = Delivery {
cache: self.parse_cache_policy().map_err(|error| usage(&error))?,
stale_ok: self.stale_ok,
cache_path: default_cache_path_in(path, self.cache_dir.as_deref())?,
workers: fdu_core::query::Workers {
analysis: self.analysis_workers,
..Default::default()
},
batch_size: fdu_core::ScanConfig::default().batch_size,
order: fdu_core::ScanOrder::default(),
watch: self.watch_delivery().map_err(|error| usage(&error))?,
accept_partial: self.allow_partial,
};
request.validate_delivery(&delivery).map_err(|error| usage(&refused(&error)))?;
let progress_plan = self.progress_plan(terminal, &request);
#[cfg(feature = "watch")]
if self.watch {
let color = ColorContext::from_environment(
self.color,
self.machine_format(),
self.skill,
stdout_is_terminal,
)
.enabled();
let diagnostic_color = ColorContext::from_environment(
self.color,
false,
false,
terminal.stderr_is_terminal,
)
.enabled();
let indicator = progress_plan.draw.then_some((progress_plan, progress_io));
return Self::run_watch(
out,
diagnostic,
format,
&request,
&delivery,
indicator,
WatchPresentation {
render: report_format::RenderOptions { color, bar_size: self.bar_size() },
diagnostic_color,
quiet: self.quiet,
},
);
}
let report_started = Instant::now();
let collect_scan_diagnostics =
std::env::var_os(SCAN_DIAGNOSTICS_ENV).is_some_and(|value| value == OsStr::new("1"));
let (report, pending_save, performance, scan_diagnostics) = if collect_scan_diagnostics {
prepare_report_with_scan_diagnostics(&request, &delivery)?
} else if progress_plan.draw {
let progress = Progress::new();
let mut ticker =
Ticker::start(progress_plan, progress.clone(), report_started, progress_io);
let prepared = prepare_report_with_progress(&request, &delivery, &progress);
ticker.stop();
let (report, pending_save, performance) = prepared?;
(report, pending_save, performance, None)
} else {
let (report, pending_save, performance) = prepare_report(&request, &delivery)?;
(report, pending_save, performance, None)
};
if let Some(scan_diagnostics) = scan_diagnostics {
writeln!(diagnostic, "{SCAN_DIAGNOSTICS_PREFIX}{}", scan_diagnostics.to_json())?;
}
let color = ColorContext::from_environment(
self.color,
self.machine_format(),
self.skill,
stdout_is_terminal,
)
.enabled();
let render_options = report_format::RenderOptions { color, bar_size: self.bar_size() };
let rendered_text =
matches!(format, report_format::Format::Text | report_format::Format::Tree)
.then(|| report_format::render_with_options(&report, format, render_options));
let (rendered_text, render_result): (Option<String>, anyhow::Result<()>) =
match rendered_text {
Some(Ok(rendered)) => {
let result =
write!(out, "{rendered}").and_then(|()| out.flush()).map_err(Into::into);
(Some(rendered), result)
}
Some(Err(error)) => (None, Err(error.into())),
None => (
None,
report_format::write_with_options(&report, format, render_options, out)
.and_then(|()| out.flush())
.map_err(Into::into),
),
};
let diagnostic_color =
ColorContext::from_environment(self.color, false, false, stderr_is_terminal).enabled();
let save_warning = pending_save.join().err().map(|error| format!("warn: {error}"));
if render_result.is_err() {
if let Some(warning) = &save_warning {
let _ = writeln!(diagnostic, "{}", paint(warning, STYLE_WARNING, diagnostic_color));
}
}
render_result?;
if matches!(format, report_format::Format::Text | report_format::Format::Tree) {
let rendered = rendered_text.as_deref().unwrap_or_default();
if !rendered.is_empty() && !rendered.ends_with('\n') {
writeln!(out)?;
}
}
out.flush()?;
write_report_diagnostics(
diagnostic,
&report,
format,
diagnostic_color,
save_warning.as_deref(),
self.quiet,
)?;
if !self.quiet
&& matches!(format, report_format::Format::Text | report_format::Format::Tree)
{
writeln!(
diagnostic,
"{}",
paint(
&performance_footer(
performance,
&report.ignore_rules,
report_started.elapsed(),
request.query.selection.size,
diagnostic_color,
),
STYLE_PERFORMANCE,
diagnostic_color,
)
)?;
}
diagnostic.flush()?;
let plan = fdu_core::plan(&request, &delivery, fdu_core::Route::OneShot)?;
Ok(match plan.outcome(&report.status) {
fdu_core::OutcomeClass::Success => RunOutcome::Complete,
fdu_core::OutcomeClass::Partial => RunOutcome::Partial,
})
}
fn machine_format(&self) -> bool {
self.parse_format().is_ok_and(report_format::Format::is_machine)
}
fn bar_size(&self) -> usize {
usize::try_from(self.bar_size.max(0)).unwrap_or(usize::MAX)
}
fn progress_plan(&self, terminal: &TerminalFacts, request: &Request) -> ProgressPlan {
let walks = !(self.docs
|| self.skill
|| self.install_skill
|| self.cache_status.is_some()
|| self.cache_clear.is_some());
let root = self.path.as_deref().unwrap_or(Path::new("."));
ProgressPlan {
draw: !self.quiet && should_draw(self.progress, terminal, self.machine_format(), walks),
root: display_root(root, home_directory().as_deref()),
color: ColorContext::from_environment(
self.color,
false,
false,
terminal.stderr_is_terminal,
)
.enabled(),
size: request.query.selection.size,
}
}
#[cfg(feature = "watch")]
fn run_watch(
out: &mut dyn Write,
diagnostic: &mut dyn Write,
format: report_format::Format,
request: &Request,
delivery: &Delivery,
indicator: Option<(ProgressPlan, ProgressIo)>,
presentation: WatchPresentation,
) -> anyhow::Result<RunOutcome> {
use fdu_core::query::ViewSpec;
use fdu_core::watch_session::Session;
let WatchPresentation { render, diagnostic_color, quiet } = presentation;
let interval = delivery
.watch
.expect("run() builds a watch delivery before it takes the watch path")
.interval;
let mut session = match indicator {
Some((plan, progress_io)) => {
let progress = Progress::new();
let mut ticker = Ticker::start(plan, progress.clone(), Instant::now(), progress_io);
let session =
Session::start_with_progress(request.clone(), delivery.clone(), &progress);
ticker.stop();
session?
}
None => Session::start(request.clone(), delivery.clone())?,
};
Self::persist_live(&mut session, diagnostic, diagnostic_color);
let streams_changes = request.query.views.contains(&ViewSpec::Files)
&& !matches!(
format,
report_format::Format::Tree
| report_format::Format::Paths
| report_format::Format::Long
);
let has_aggregates =
!streams_changes || request.query.views.iter().any(|view| *view != ViewSpec::Files);
if format == report_format::Format::Yaml {
write!(out, "{}", report_format::document_start(format))?;
}
let initial = session.report(SystemTime::now())?;
report_format::write_with_options(&initial, format, render, out)?;
out.flush()?;
write_report_diagnostics(diagnostic, &initial, format, diagnostic_color, None, quiet)?;
let mut dirty_since_render = false;
let mut last_render = SystemTime::now();
loop {
let Some(batch) = session.next_batch(interval)? else {
if has_aggregates && dirty_since_render {
Self::render_live(
out,
diagnostic,
&session,
format,
render,
diagnostic_color,
quiet,
)?;
dirty_since_render = false;
last_render = SystemTime::now();
}
Self::persist_live(&mut session, diagnostic, diagnostic_color);
continue;
};
Self::render_watch_changes(out, diagnostic, &batch.changes, format, streams_changes)?;
out.flush()?;
dirty_since_render |= batch.dirty;
let elapsed = last_render.elapsed().unwrap_or_default();
if has_aggregates && dirty_since_render && elapsed >= interval {
Self::render_live(
out,
diagnostic,
&session,
format,
render,
diagnostic_color,
quiet,
)?;
dirty_since_render = false;
last_render = SystemTime::now();
}
Self::persist_live(&mut session, diagnostic, diagnostic_color);
}
}
#[cfg(feature = "watch")]
fn persist_live(
session: &mut fdu_core::watch_session::Session,
diagnostic: &mut dyn Write,
color: bool,
) {
if let fdu_core::watch_session::SaveOutcome::Failed(error) =
session.persist_due(Instant::now())
{
let _ =
writeln!(diagnostic, "{}", paint(&format!("warn: {error}"), STYLE_WARNING, color));
}
}
#[cfg(feature = "watch")]
fn render_watch_changes(
out: &mut dyn Write,
diagnostic: &mut dyn Write,
changes: &[fdu_core::watch_session::Change],
format: report_format::Format,
streams_changes: bool,
) -> std::io::Result<()> {
for change in changes {
if change.kind == fdu_core::watch_session::ChangeKind::Invalidate
&& matches!(format, report_format::Format::Paths | report_format::Format::Long)
{
writeln!(diagnostic, "{}", report_format::render_change(change, format))?;
} else if streams_changes
|| change.kind == fdu_core::watch_session::ChangeKind::Invalidate
{
writeln!(out, "{}", report_format::render_change(change, format))?;
}
}
Ok(())
}
#[cfg(feature = "watch")]
fn render_live(
out: &mut dyn Write,
diagnostic: &mut dyn Write,
session: &fdu_core::watch_session::Session,
format: report_format::Format,
render: report_format::RenderOptions,
diagnostic_color: bool,
quiet: bool,
) -> anyhow::Result<()> {
let generated_at = SystemTime::now();
let report = session.report(generated_at)?;
if matches!(format, report_format::Format::Text | report_format::Format::Tree) {
writeln!(
out,
"\n{}",
paint(&report_format::watch_rule(generated_at), STYLE_WATCH_RULE, render.color)
)?;
} else if format == report_format::Format::Yaml {
write!(out, "{}", report_format::document_start(format))?;
}
report_format::write_with_options(&report, format, render, out)?;
out.flush()?;
write_report_diagnostics(diagnostic, &report, format, diagnostic_color, None, quiet)?;
Ok(())
}
fn run_install_skill(&self, out: &mut dyn Write) -> anyhow::Result<RunOutcome> {
let cwd = std::env::current_dir()
.map_err(|error| anyhow::anyhow!("cannot read the current directory: {error}"))?;
self.install_skill_from(out, &cwd)
}
fn install_skill_from(&self, out: &mut dyn Write, cwd: &Path) -> anyhow::Result<RunOutcome> {
let targets = skill_install::targets(cwd, self.agent_base.as_deref());
let report = |out: &mut dyn Write, outcomes: &[skill_install::Outcome]| {
for outcome in outcomes {
writeln!(
out,
"{} {}",
outcome.action,
skill_install::display_path(&outcome.path, cwd)
)?;
}
io::Result::Ok(())
};
match skill_install::install(&compose_skill(), &targets) {
Ok(outcomes) => {
report(out, &outcomes)?;
Ok(RunOutcome::Complete)
}
Err(skill_install::InstallError::Foreign(path)) => Err(usage(&anyhow::anyhow!(
"refusing to overwrite {}: fdu did not generate it (no `{}` marker); move it aside, then re-run fdu --install-skill",
skill_install::display_path(&path, cwd),
skill_install::GENERATED_MARKER_PREFIX,
))),
Err(skill_install::InstallError::Io { path, source, completed }) => {
let _ = report(out, &completed).and_then(|()| out.flush());
Err(anyhow::Error::new(source).context(format!(
"cannot install the skill at {}",
skill_install::display_path(&path, cwd)
)))
}
}
}
fn run_cache_lifecycle(
&self,
out: &mut dyn Write,
stdout_is_terminal: bool,
) -> anyhow::Result<RunOutcome> {
let root = self.path.as_deref().unwrap_or_else(|| Path::new("."));
let cache_dir = fdu_core::default_cache_dir(self.cache_dir.as_deref())?;
if let Some(scope) = &self.cache_clear {
let scope = parse_cache_scope(scope, "--cache-clear").map_err(|e| usage(&e))?;
match (scope, &cache_dir) {
(CacheScope::All, Some(dir)) => {
writeln!(out, "Cache directory: {}", dir.display())?;
let removed = fdu_core::clear_all_caches(dir)?;
if removed.is_empty() {
writeln!(out, "Cache already empty.")?;
}
if removed.snapshots > 0 {
writeln!(
out,
"Cache cleared: {} {}.",
report_format::human_count_u128(removed.snapshots as u128),
plural(removed.snapshots, "snapshot", "snapshots")
)?;
}
if removed.leftovers > 0 {
writeln!(
out,
"Also reclaimed: {} {} fdu left behind.",
report_format::human_count_u128(removed.leftovers as u128),
plural(removed.leftovers, "file", "files")
)?;
}
let remaining = fdu_core::list_caches(dir)?;
let left = remaining
.iter()
.filter(|status| status.state == CacheState::Unrecognized)
.count();
if left > 0 {
writeln!(
out,
"Left in place: {} {}; fdu --cache-status=all lists {}.",
report_format::human_count_u128(left as u128),
plural(
left,
"file that is not an fdu snapshot",
"files that are not fdu snapshots"
),
plural(left, "it", "them")
)?;
}
let staging = remaining
.iter()
.filter(|status| matches!(status.state, CacheState::Leftover(_)))
.count();
if staging > 0 {
writeln!(
out,
"Left in place: {} staging {} another fdu may still be \
writing.",
report_format::human_count_u128(staging as u128),
plural(staging, "file", "files")
)?;
}
}
(CacheScope::Root, _) => {
let path = default_cache_path_in(root, self.cache_dir.as_deref())?;
let removed = match &path {
Some(path) => fdu_core::clear_cache(path)?,
None => false,
};
if let Some(path) = &path {
writeln!(out, "Cache file: {}", path.display())?;
}
writeln!(
out,
"{}",
if removed { "Cache cleared." } else { "Cache already empty." }
)?;
let left = match &path {
Some(path) => fdu_core::cache_status(path)?.state,
None => CacheState::Absent,
};
if left == CacheState::Unrecognized {
writeln!(out, "Left in place: the file is not an fdu snapshot.")?;
}
}
(CacheScope::All, None) => writeln!(out, "Cache already empty.")?,
}
}
if let Some(scope) = &self.cache_status {
let scope = parse_cache_scope(scope, "--cache-status").map_err(|e| usage(&e))?;
let statuses = match (scope, &cache_dir) {
(CacheScope::All, Some(dir)) => fdu_core::list_caches(dir)?,
(CacheScope::All, None) => Vec::new(),
(CacheScope::Root, _) => {
match default_cache_path_in(root, self.cache_dir.as_deref())? {
Some(path) => vec![fdu_core::cache_status(&path)?],
None => Vec::new(),
}
}
};
self.write_cache_status(out, &statuses, scope, stdout_is_terminal)?;
}
Ok(RunOutcome::Complete)
}
fn write_cache_status(
&self,
out: &mut dyn Write,
statuses: &[fdu_core::CacheStatus],
scope: CacheScope,
stdout_is_terminal: bool,
) -> anyhow::Result<()> {
let format = self.parse_format().map_err(|e| usage(&e))?;
let color = ColorContext::from_environment(
self.color,
format.is_machine(),
false,
stdout_is_terminal,
)
.enabled();
writeln!(
out,
"{}",
report_format::render_cache_status_with_options(
statuses,
scope,
format,
report_format::RenderOptions { color, ..Default::default() },
)
)?;
Ok(())
}
fn request(&self, root: &Path, now: SystemTime) -> anyhow::Result<Request> {
let typed = self.typed_values();
let request = Request::build(&self.spec(root, &typed)?, now, &AxisNames::FLAGS)
.map_err(|error| usage(&refused(&error)))?;
request.validate().map_err(|error| usage(&refused(&error)))?;
Ok(request)
}
fn typed_values(&self) -> TypedValues {
TypedValues {
scan_depth: self.scan_depth.map(|depth| depth.to_string()),
words_per_page: self.words_per_page.to_string(),
}
}
fn spec<'a>(
&'a self,
root: &'a Path,
typed: &'a TypedValues,
) -> anyhow::Result<RequestSpec<'a>> {
Ok(RequestSpec {
root,
scan_depth: typed.scan_depth.as_deref(),
one_filesystem: self.one_filesystem,
read_controls: self.no_gitignore.then_some(false),
control_budget: self.gitignore_budget.as_deref(),
control_line_limit: self.gitignore_line_limit.as_deref(),
analyze: Some(&self.analyze),
read: ReadSpec {
views: self.view.as_deref(),
format: Some(self.parse_format()?.label()),
words_per_page: Some(&typed.words_per_page),
include: &self.include,
exclude: &self.exclude,
min_size: self.min_size.as_deref(),
modified_since: self.modified_since.as_deref(),
modified_before: self.modified_before.as_deref(),
kinds: self.kind.as_deref(),
ignored: self.ignored.as_deref(),
depth: self.depth.as_deref().or(self.full.then_some("all")),
limit: self.limit.as_deref().or(self.full.then_some("all")),
breadth: self.breadth.as_deref().or(self.full.then_some("all")),
min_share: self.min_share.as_deref().or(self.full.then_some("0%")),
sort: self.sort.as_deref(),
reverse: self.reverse,
size: Some(&self.size),
},
})
}
#[cfg(feature = "watch")]
fn watch_delivery(&self) -> anyhow::Result<Option<WatchDelivery>> {
if !self.watch {
return Ok(None);
}
Ok(Some(WatchDelivery { interval: parse_duration(&self.interval)? }))
}
#[cfg(not(feature = "watch"))]
#[allow(clippy::unnecessary_wraps, clippy::unused_self)]
fn watch_delivery(&self) -> anyhow::Result<Option<WatchDelivery>> {
Ok(None)
}
fn parse_cache_policy(&self) -> anyhow::Result<CachePolicy> {
parse_cache_policy(&self.cache, AxisNames::FLAGS.cache).map_err(|error| refused(&error))
}
fn parse_format(&self) -> anyhow::Result<report_format::Format> {
if self.tree {
return Ok(report_format::Format::Tree);
}
if self.long {
return Ok(report_format::Format::Long);
}
report_format::Format::parse(&self.format).ok_or_else(|| {
anyhow::anyhow!(
"invalid --format {:?}: expected one of {}",
self.format,
report_format::Format::ALL.join(", ")
)
})
}
#[cfg(test)]
fn resolved_query(&self) -> anyhow::Result<fdu_core::query::Query> {
Ok(self.resolved_request()?.query)
}
#[cfg(test)]
fn resolved_request(&self) -> anyhow::Result<Request> {
self.request(self.path.as_deref().unwrap_or(Path::new(".")), SystemTime::now())
}
}
struct TypedValues {
scan_depth: Option<String>,
words_per_page: String,
}
fn performance_footer(
performance: PerformanceSummary,
ignore_rules: &ControlCoverage,
total: Duration,
size: SizeMetric,
color: bool,
) -> String {
let walked_bytes = match size {
SizeMetric::Apparent => performance.walked_bytes,
SizeMetric::Allocated => performance.walked_allocated,
};
let fresh = match (performance.fresh_files, performance.analysis_ns) {
(0, _) | (_, 0) => format!("{} fresh", human_count(performance.fresh_files)),
(files, elapsed_ns) => {
format!("{} fresh at {} files/s", human_count(files), human_rate(files, elapsed_ns))
}
};
let cached = if performance.cached_files == 0 {
"0 cached".to_string()
} else {
format!(
"{} cached ({})",
human_count(performance.cached_files),
performance_bytes(performance.cached_bytes, color)
)
};
let read_rate = if performance.bytes_read == 0 || performance.analysis_ns == 0 {
String::new()
} else {
format!(
" at {}/s",
performance_bytes(
rate_per_second(performance.bytes_read, performance.analysis_ns,),
color
)
)
};
let rules = match ignore_rules {
ControlCoverage::NotObserved => "gitignore not read".to_string(),
ControlCoverage::Observed(observed) => {
let refused = if observed.refused > 0 {
format!(", {} refused", human_count(observed.refused))
} else {
String::new()
};
format!(
"{} gitignore {} ({} {}){refused}",
human_count(observed.rules),
plural_u64(observed.rules, "rule", "rules"),
human_count(observed.applied.saturating_add(observed.refused)),
plural_u64(observed.applied.saturating_add(observed.refused), "file", "files")
)
}
};
let rates = performance.total_throughput(total, size);
format!(
"perf: took {} to walk {} {} ({}) at {rates}; {rules}; content read {}{}; analysis {fresh}, {cached}; {}",
human_duration(total),
human_count(performance.walked_files),
plural_u64(performance.walked_files, "file", "files"),
performance_bytes(walked_bytes, color),
performance_bytes(performance.bytes_read, color),
read_rate,
performance_source(performance.source),
)
}
fn performance_bytes(bytes: u64, color: bool) -> String {
let styled = report_format::styled_bytes(bytes, 0, color, true);
if color { format!("{styled}{STYLE_PERFORMANCE}") } else { styled }
}
fn performance_source(source: ReportSource) -> &'static str {
match source {
ReportSource::ColdScan => "cold scan",
ReportSource::WarmRevalidate => "warm revalidation",
ReportSource::CacheOnly => "cache only",
}
}
fn rate_per_second(units: u64, elapsed_ns: u64) -> u64 {
if elapsed_ns == 0 {
return 0;
}
let scaled = u128::from(units).saturating_mul(1_000_000_000);
u64::try_from(scaled / u128::from(elapsed_ns)).unwrap_or(u64::MAX)
}
fn human_rate(units: u64, elapsed_ns: u64) -> String {
if elapsed_ns == 0 {
return "0".to_string();
}
report_format::human_count_u128(u128::from(units) * 1_000_000_000 / u128::from(elapsed_ns))
}
fn human_duration(duration: Duration) -> String {
let nanos = duration.as_nanos();
if nanos < 1_000 {
format!("{nanos} ns")
} else if nanos < 1_000_000 {
format!("{} µs", scaled_decimal(nanos, 1_000, 1))
} else if nanos < 1_000_000_000 {
format!("{} ms", scaled_decimal(nanos, 1_000_000, 1))
} else {
format!("{} s", scaled_decimal(nanos, 1_000_000_000, 2))
}
}
pub(crate) fn progress_duration(duration: Duration) -> String {
format!("{} s", scaled_decimal(duration.as_nanos(), 1_000_000_000, 1))
}
pub(crate) fn scaled_decimal(value: u128, unit: u128, precision: u32) -> String {
let factor = 10_u128.pow(precision);
let scaled = value.saturating_mul(factor).saturating_add(unit / 2) / unit;
let whole = scaled / factor;
let fraction = scaled % factor;
let width = usize::try_from(precision).expect("decimal precision fits usize");
format!("{whole}.{fraction:0width$}")
}
fn plural_u64<'a>(count: u64, singular: &'a str, plural: &'a str) -> &'a str {
if count == 1 { singular } else { plural }
}
#[cfg(feature = "watch")]
const INTERVAL_ANCHOR_SECS: u64 = 1 << 35;
#[cfg(feature = "watch")]
fn parse_duration(value: &str) -> anyhow::Result<std::time::Duration> {
let anchor = SystemTime::UNIX_EPOCH + Duration::from_secs(INTERVAL_ANCHOR_SECS);
let at = parse_when(value, anchor)
.map_err(|error| anyhow::anyhow!("invalid --interval {value:?}: {error}"))?;
anchor
.duration_since(at)
.map_err(|_| anyhow::anyhow!("invalid --interval {value:?}: expected a duration like `2s`"))
}
fn plural<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str {
if count == 1 { singular } else { plural }
}
#[cfg(test)]
#[derive(Debug)]
struct ResolvedViews {
selected: Vec<fdu_core::query::ViewSpec>,
omitted: Vec<fdu_core::query::ViewSpec>,
}
#[cfg(test)]
fn resolve_views(spec: Option<&str>, profile: AnalysisSet) -> anyhow::Result<ResolvedViews> {
let (selected, omitted) = fdu_core::query::ViewSpec::resolve(spec, profile, "--view")
.map_err(|message| anyhow::anyhow!(message))?;
Ok(ResolvedViews { selected, omitted })
}
fn write_report_diagnostics(
diagnostic: &mut dyn Write,
report: &Report,
format: report_format::Format,
color: bool,
save_warning: Option<&str>,
quiet: bool,
) -> io::Result<()> {
let lines = if matches!(format, report_format::Format::Paths | report_format::Format::Long) {
report_format::flat_diagnostic_lines(report)
} else {
report_format::diagnostic_lines(report)
};
if !quiet {
for line in lines.notes {
writeln!(diagnostic, "{}", paint(&line, STYLE_PERFORMANCE, color))?;
}
}
for warning in report_format::report_warnings(report) {
writeln!(diagnostic, "{}", paint(&warning, STYLE_WARNING, color))?;
}
if let Some(warning) = save_warning {
writeln!(diagnostic, "{}", paint(warning, STYLE_WARNING, color))?;
}
if !report.status.complete {
for warning in status_warnings(&report.status) {
writeln!(diagnostic, "{}", paint(&warning, STYLE_WARNING, color))?;
}
}
if !quiet {
for line in lines.tips {
writeln!(diagnostic, "{}", paint(&line, STYLE_PERFORMANCE, color))?;
}
}
diagnostic.flush()
}
fn status_warnings(status: &fdu_core::query::TreeStatus) -> Vec<String> {
let mut warnings: Vec<String> = status
.errors
.iter()
.map(|issue| match &issue.path {
Some(path) if !path.as_os_str().is_empty() && !names_path(&issue.message, path) => {
format!("warn: {}: {}", path.display(), issue.message)
}
_ => format!("warn: {}", issue.message),
})
.collect();
if status.errors_omitted > 0 {
warnings.push(format!(
"warn: {} more {} omitted; details are kept for the first {}",
human_count(status.errors_omitted),
if status.errors_omitted == 1 { "error" } else { "errors" },
human_count(fdu_core::MAX_RETAINED_ISSUES as u64),
));
}
warnings
}
fn names_path(message: &str, path: &Path) -> bool {
let path = path.to_string_lossy();
message.match_indices(&*path).any(|(start, found)| {
let before = message[..start].chars().next_back();
let after = message[start + found.len()..].chars().next();
matches!(before, None | Some('/' | '\\' | ' ' | '"' | '\'' | '`'))
&& matches!(after, None | Some(':' | ' ' | '"' | '\'' | '`' | ')' | ','))
})
}
pub fn run_process<I, T>(args: I) -> u8
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
let stdout = io::stdout();
let stdout_is_terminal = stdout.is_terminal();
let terminal = TerminalFacts::detect();
let mut out = io::BufWriter::new(stdout.lock());
let mut diagnostic = io::stderr();
run_with_io(
&args,
&mut out,
&mut diagnostic,
stdout_is_terminal,
&terminal,
ProgressIo::for_process(),
)
}
fn run_with_io(
args: &[OsString],
out: &mut dyn Write,
diagnostic: &mut dyn Write,
stdout_is_terminal: bool,
terminal: &TerminalFacts,
progress_io: ProgressIo,
) -> u8 {
let stderr_is_terminal = terminal.stderr_is_terminal;
let implicit_help;
let args = if args.len() == 1 {
implicit_help = vec![args[0].clone(), OsString::from("--help")];
implicit_help.as_slice()
} else {
args
};
let requested_color = requested_color(args);
let json_requested = flag_is_present(args, "--json");
let skill_requested = flag_is_present(args, "--skill");
let command = Cli::command().color(ColorChoice::Always);
let matches = match command.try_get_matches_from(args) {
Ok(matches) => matches,
Err(error) => {
let use_stderr = error.use_stderr();
let destination_is_terminal =
if use_stderr { stderr_is_terminal } else { stdout_is_terminal };
let color = ColorContext::from_environment(
requested_color,
json_requested,
skill_requested,
destination_is_terminal,
)
.enabled();
let rendered = error.render();
let write_result = if use_stderr {
write_styled(diagnostic, &rendered, color)
} else {
write_styled(out, &rendered, color)
};
if let Err(write_error) = write_result {
return match write_error.kind() {
io::ErrorKind::BrokenPipe => 0,
_ => 1,
};
}
return u8::try_from(error.exit_code()).unwrap_or(2);
}
};
let cli = match Cli::from_arg_matches(&matches) {
Ok(cli) => cli,
Err(error) => {
let _ = write!(diagnostic, "{error}");
return 2;
}
};
let result =
cli.run(out, diagnostic, stdout_is_terminal, terminal, progress_io).and_then(|outcome| {
out.flush()?;
Ok(outcome)
});
let diagnostic_color =
ColorContext::from_environment(cli.color, false, false, stderr_is_terminal).enabled();
finish(result, diagnostic, diagnostic_color)
}
fn write_styled(
out: &mut dyn Write,
rendered: &clap::builder::StyledStr,
color: bool,
) -> io::Result<()> {
let rendered = if color { rendered.ansi().to_string() } else { rendered.to_string() };
for line in rendered.split_inclusive('\n') {
let (content, newline) =
line.strip_suffix('\n').map_or((line, ""), |content| (content, "\n"));
let content = content.trim_end_matches([' ', '\t']);
let content = strip_heading_colon(content);
out.write_all(content.as_bytes())?;
out.write_all(newline.as_bytes())?;
}
Ok(())
}
fn strip_heading_colon(line: &str) -> Cow<'_, str> {
let Some(without_colon) = line.strip_suffix(':').or_else(|| {
line.strip_suffix("\u{1b}[0m").and_then(|inner| inner.strip_suffix(':'))
}) else {
return Cow::Borrowed(line);
};
let visible = strip_ansi(without_colon);
let is_heading = !visible.is_empty()
&& visible.chars().any(char::is_alphabetic)
&& visible.chars().all(|c| c.is_uppercase() || c == ' ' || !c.is_alphabetic());
if !is_heading {
return Cow::Borrowed(line);
}
let colon = line.rfind(':').expect("the suffix match found one");
Cow::Owned(format!("{}{}", &line[..colon], &line[colon + 1..]))
}
fn strip_ansi(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut chars = line.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for escape in chars.by_ref() {
if escape.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn finish(result: anyhow::Result<RunOutcome>, diagnostic: &mut dyn Write, color: bool) -> u8 {
match result {
Ok(RunOutcome::Complete) => 0,
Ok(RunOutcome::Partial) => 2,
Err(error) if is_broken_pipe(&error) => 0,
Err(error) if is_usage_error(&error) => {
let _ = writeln!(diagnostic, "{} {error}", paint("fdu:", STYLE_ERROR, color));
2
}
Err(error) => {
let headline = error.to_string();
let _ = writeln!(diagnostic, "{} {headline}", paint("fdu:", STYLE_ERROR, color));
let mut chain = error.chain().skip(1).peekable();
let embedded = chain.peek().is_some_and(|first| headline.ends_with(&first.to_string()));
if embedded {
chain.next();
}
for cause in chain {
let cause = format!(" caused by: {cause}");
let _ = writeln!(diagnostic, "{}", paint(&cause, STYLE_CAUSE, color));
}
1
}
}
}
fn is_broken_pipe(error: &anyhow::Error) -> bool {
error.chain().any(|cause| {
cause
.downcast_ref::<io::Error>()
.is_some_and(|io_error| io_error.kind() == io::ErrorKind::BrokenPipe)
})
}
fn requested_color(args: &[OsString]) -> ColorWhen {
let mut arguments = args.iter().skip(1);
while let Some(argument) = arguments.next() {
if argument == "--" {
break;
}
if argument == "--color" {
return arguments.next().and_then(|value| color_value(value)).unwrap_or_default();
}
if let Some(value) = argument.to_str().and_then(|value| value.strip_prefix("--color=")) {
return color_value(OsStr::new(value)).unwrap_or_default();
}
}
ColorWhen::Auto
}
fn color_value(value: &OsStr) -> Option<ColorWhen> {
match value.to_str()? {
"auto" => Some(ColorWhen::Auto),
"always" => Some(ColorWhen::Always),
"never" => Some(ColorWhen::Never),
_ => None,
}
}
fn flag_is_present(args: &[OsString], flag: &str) -> bool {
args.iter().skip(1).take_while(|argument| *argument != "--").any(|argument| argument == flag)
}
#[derive(Clone, Copy)]
#[allow(clippy::struct_excessive_bools)]
struct ColorContext {
when: ColorWhen,
json: bool,
skill: bool,
no_color_env: bool,
force_color_env: bool,
destination_is_terminal: bool,
}
impl ColorContext {
fn from_environment(
when: ColorWhen,
json: bool,
skill: bool,
destination_is_terminal: bool,
) -> Self {
let no_color_env = std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty());
let force_color_env =
std::env::var_os("FORCE_COLOR").is_some_and(|value| !value.is_empty() && value != "0");
Self { when, json, skill, no_color_env, force_color_env, destination_is_terminal }
}
fn enabled(self) -> bool {
if self.json || self.skill {
return false;
}
match self.when {
ColorWhen::Always => true,
ColorWhen::Never => false,
ColorWhen::Auto if self.no_color_env => false,
ColorWhen::Auto if self.force_color_env => true,
ColorWhen::Auto => self.destination_is_terminal,
}
}
}
fn style_guide(guide: &str, color: bool) -> String {
if !color {
return guide.to_string();
}
let mut out = String::with_capacity(guide.len());
for line in guide.lines() {
let is_header = !line.is_empty()
&& !line.starts_with(char::is_whitespace)
&& line.chars().any(char::is_alphabetic)
&& line.chars().filter(|c| c.is_alphabetic()).all(char::is_uppercase);
if is_header {
out.push_str(&paint(line, STYLE_HEADING, true));
} else {
out.push_str(line);
}
out.push('\n');
}
out
}
pub(crate) fn paint(text: &str, style: AnsiStyle, color: bool) -> String {
report_format::paint(text, style, color)
}
fn compose_skill() -> String {
compose_skill_from(SKILL_TEMPLATE)
}
fn compose_skill_from(template: &str) -> String {
template.replace("\r\n", "\n").replace("__FDU_VERSION__", env!("FDU_BUILD_VERSION"))
}
#[cfg(any(unix, windows))]
#[cfg(test)]
mod tests {
use super::*;
use crate::progress_ticker::{ERASE_LINE, SharedBuffer, Timing};
use fdu_core::EntryKind;
use fdu_core::query::ViewSpec;
use fdu_core::query::{Bound, ScopeAxis, SizeMetric, SortKey};
#[cfg(feature = "watch")]
use std::time::UNIX_EPOCH;
#[test]
fn status_warnings_name_missing_paths_and_the_omitted_count() {
let issue = |path: Option<&str>, message: &str| fdu_core::Issue {
kind: fdu_core::IssueKind::ProviderFailure,
path: path.map(PathBuf::from),
message: message.to_string(),
os_error: None,
};
let status = |errors, errors_omitted| fdu_core::query::TreeStatus {
complete: false,
coverage: fdu_core::Coverage::Partial(fdu_core::CoverageReason::Failed),
errors,
errors_omitted,
};
let complete = status_warnings(&status(
vec![
issue(Some("docs/a.md"), "Permission denied (os error 13)"),
issue(Some("src"), "I/O error at /abs/src: Permission denied (os error 13)"),
issue(None, "content analysis results became stale"),
issue(Some("d"), "Permission denied (os error 13)"),
],
0,
));
assert_eq!(
complete,
[
"warn: docs/a.md: Permission denied (os error 13)",
"warn: I/O error at /abs/src: Permission denied (os error 13)",
"warn: content analysis results became stale",
"warn: d: Permission denied (os error 13)",
]
);
let one = status_warnings(&status(vec![issue(None, "x")], 1));
assert_eq!(
one.last().map(String::as_str),
Some("warn: 1 more error omitted; details are kept for the first 64")
);
let many = status_warnings(&status(Vec::new(), 1_234));
assert_eq!(many, ["warn: 1,234 more errors omitted; details are kept for the first 64"]);
}
#[test]
fn the_ignored_population_axis_accepts_modes_and_rejects_bad_values() {
assert_eq!(
cli().resolved_query().expect("parses").selection.ignored,
IgnoredEntries::Include
);
let exclude =
Cli { ignored: Some("exclude".into()), ..cli() }.resolved_query().expect("parses");
assert_eq!(exclude.selection.ignored, IgnoredEntries::Exclude);
let only = Cli { ignored: Some("only".into()), ..cli() }.resolved_query().expect("parses");
assert_eq!(only.selection.ignored, IgnoredEntries::Only);
assert_eq!(
query_error(&Cli { ignored: Some("invalid".into()), ..cli() }),
"invalid --ignored \"invalid\": expected one of include, exclude, only"
);
}
#[test]
fn no_gitignore_refuses_a_selection_by_ignored_state_before_scanning() {
let mut out = Vec::new();
let mut err = Vec::new();
let args = [
"fdu",
"--no-gitignore",
"--ignored=only",
"/nonexistent-root-that-must-not-be-scanned",
]
.map(OsString::from);
let status = run_with_io(
&args,
&mut out,
&mut err,
false,
&TerminalFacts::default(),
ProgressIo::inert(),
);
assert_eq!(status, 2);
assert!(out.is_empty());
assert_eq!(
String::from_utf8(err).expect("UTF-8 diagnostics"),
"fdu: --ignored=only needs .gitignore classification, and --no-gitignore turned it \
off; drop one of them\n"
);
}
#[test]
fn the_performance_line_counts_the_ignore_rules_it_read_or_says_it_read_none() {
use fdu_core::control::{ControlObservation, ControlRefusalReason, RefusedControl};
let performance = PerformanceSummary {
walked_files: 7,
walked_bytes: 269,
walked_allocated: 28_672,
..PerformanceSummary::default()
};
let footer = |rules: &ControlCoverage| {
performance_footer(
performance,
rules,
Duration::from_millis(3),
SizeMetric::Apparent,
false,
)
};
assert_eq!(
footer(&ControlCoverage::NotObserved),
"perf: took 3.0 ms to walk 7 files (269 B) at 2,333 files/s (0.000 GiB/s); gitignore not read; content read 0 B; analysis 0 fresh, 0 cached; cold scan"
);
assert!(
performance_footer(
performance,
&ControlCoverage::NotObserved,
Duration::from_millis(3),
SizeMetric::Allocated,
false,
)
.starts_with("perf: took 3.0 ms to walk 7 files (28 KiB) at ")
);
let observed = |applied, refusals: Vec<RefusedControl>| {
ControlCoverage::Observed(ControlObservation {
limits: fdu_core::ControlLimits::default(),
applied,
rules: 0,
refused: u64::try_from(refusals.len()).expect("a handful"),
refusals,
})
};
assert!(footer(&observed(1, Vec::new())).contains("; 0 gitignore rules (1 file); "));
let refused = RefusedControl {
path: PathBuf::from(".gitignore"),
reason: ControlRefusalReason::LineLimit,
};
assert!(
footer(&observed(0, vec![refused]))
.contains("; 0 gitignore rules (1 file), 1 refused; ")
);
}
#[test]
fn each_gitignore_limit_flag_sets_only_its_own_limit_and_joins_the_observed_scope() {
let scan_config = |flags: &[&str]| {
let args = std::iter::once("fdu").chain(flags.iter().copied()).chain(["."]);
Cli::try_parse_from(args)
.expect("parses")
.resolved_request()
.map(|request| request.basis.scope)
};
let defaults = fdu_core::ControlLimits::default();
let unobserved = |config: &fdu_core::query::Scope| {
fdu_core::query::Scope { read_controls: false, ..config.clone() }.scope()
};
let default = scan_config(&[]).expect("defaults");
assert_eq!(default.control_limits, defaults);
assert!(default.read_controls, "every run observes .gitignore unless told not to");
for (flags, limits) in [
(
&["--gitignore-budget", "16MiB"][..],
fdu_core::ControlLimits { budget: Some(16 * 1024 * 1024), ..defaults },
),
(
&["--gitignore-budget", "all"][..],
fdu_core::ControlLimits { budget: None, ..defaults },
),
(
&["--gitignore-line-limit", "64KiB"][..],
fdu_core::ControlLimits { line_limit: Some(64 * 1024), ..defaults },
),
(
&["--gitignore-line-limit", "all"][..],
fdu_core::ControlLimits { line_limit: None, ..defaults },
),
] {
let config = scan_config(flags).expect("a valid limit");
assert_eq!(config.control_limits, limits, "{flags:?}");
assert_ne!(config.scope(), default.scope(), "{flags:?} while observed");
assert_eq!(unobserved(&config), unobserved(&default), "{flags:?} once unobserved");
}
for flag in ["--gitignore-budget", "--gitignore-line-limit"] {
assert_eq!(
scan_config(&[flag, "lots"]).expect_err("not a size").to_string(),
format!(
"invalid {flag} \"lots\": expected a number before the unit, as in `10M`, \
or `all` for no bound"
)
);
}
}
#[cfg(feature = "watch")]
#[test]
fn a_watch_rule_carries_the_instant_and_cannot_be_read_as_a_data_row() {
let rule = report_format::watch_rule(UNIX_EPOCH + Duration::from_secs(1_786_386_151));
assert_eq!(rule, "──── 2026-08-10T18:22:31.000000000Z ────");
assert!(rule.starts_with('─'), "{rule}");
assert_eq!(paint(&rule, STYLE_WATCH_RULE, false), rule);
assert!(paint(&rule, STYLE_WATCH_RULE, true).contains(&rule));
}
#[cfg(feature = "watch")]
#[test]
fn flat_watch_invalidations_are_visible_without_becoming_path_rows() {
use fdu_core::watch_session::{Change, ChangeKind};
let changes = [Change {
path: "builds".into(),
kind: ChangeKind::Invalidate,
clock: 1,
entry_kind: None,
bytes: None,
allocated: None,
mtime_ns: None,
ignored: None,
}];
for format in [report_format::Format::Paths, report_format::Format::Long] {
let (mut out, mut diagnostic) = (Vec::new(), Vec::new());
Cli::render_watch_changes(&mut out, &mut diagnostic, &changes, format, false)
.expect("invalidation");
assert!(out.is_empty(), "stdout is reserved for flat data rows");
assert_eq!(
String::from_utf8(diagnostic).expect("diagnostic"),
format!("{}\n", report_format::render_change(&changes[0], format))
);
}
}
#[cfg(feature = "watch")]
#[test]
fn an_interval_parses_without_overflowing_any_platforms_clock() {
assert_eq!(parse_duration("2s").expect("seconds"), Duration::from_secs(2));
assert_eq!(parse_duration("1h30m").expect("compound"), Duration::from_secs(5_400));
assert_eq!(parse_duration("1w").expect("weeks"), Duration::from_secs(604_800));
assert_eq!(parse_duration("200ms").expect("milliseconds"), Duration::from_millis(200));
assert!(parse_duration("0.2s").is_err(), "a fractional age stays rejected");
assert!(parse_duration("banana").is_err(), "a non-duration must be rejected, not parsed");
}
struct FailingWriter;
impl Write for FailingWriter {
fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
Err(io::Error::other("output failed"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
struct ClosedStdout;
impl Write for ClosedStdout {
fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
Err(io::Error::from(io::ErrorKind::BrokenPipe))
}
fn flush(&mut self) -> io::Result<()> {
Err(io::Error::from(io::ErrorKind::BrokenPipe))
}
}
fn cli() -> Cli {
Cli {
path: Some(PathBuf::from(".")),
scan_depth: None,
one_filesystem: false,
gitignore_budget: None,
gitignore_line_limit: None,
no_gitignore: false,
include: Vec::new(),
exclude: Vec::new(),
min_size: None,
modified_since: None,
modified_before: None,
kind: None,
ignored: None,
depth: None,
limit: None,
breadth: None,
min_share: None,
full: false,
sort: None,
reverse: false,
size: SIZE_DEFAULT.to_string(),
view: Some("tree".to_string()),
analyze: "none".to_string(),
analysis_workers: 0,
words_per_page: Request::DEFAULTS.words_per_page,
format: "text".to_string(),
tree: false,
long: false,
color: ColorWhen::Auto,
progress: ProgressMode::Auto,
bar_size: 10,
quiet: false,
cache: "off".to_string(),
stale_ok: false,
cache_dir: None,
cache_status: None,
cache_clear: None,
#[cfg(feature = "watch")]
watch: false,
#[cfg(feature = "watch")]
interval: "2s".to_string(),
allow_partial: false,
help: None,
version: None,
docs: false,
skill: false,
install_skill: false,
agent_base: None,
}
}
fn query_error(cli: &Cli) -> String {
cli.resolved_query().expect_err("expected a rejection").to_string()
}
#[test]
fn a_bare_invocation_prints_help_instead_of_scanning_the_current_directory() {
let mut bare_out = Vec::new();
let mut bare_err = Vec::new();
let bare = [OsString::from("fdu")];
let terminal = TerminalFacts::default();
let status =
run_with_io(&bare, &mut bare_out, &mut bare_err, false, &terminal, ProgressIo::inert());
let mut help_out = Vec::new();
let mut help_err = Vec::new();
let help = [OsString::from("fdu"), OsString::from("--help")];
let help_status =
run_with_io(&help, &mut help_out, &mut help_err, false, &terminal, ProgressIo::inert());
assert_eq!(status, 0, "showing help is a successful discovery action");
assert_eq!(help_status, 0);
assert_eq!(bare_out, help_out, "bare fdu should be exactly the long-help surface");
assert!(bare_err.is_empty(), "help belongs on stdout");
assert!(help_err.is_empty());
let bare_help = String::from_utf8(bare_out).expect("help is UTF-8");
assert!(bare_help.contains("Usage: fdu"));
assert!(
bare_help.lines().all(|line| line.trim_end() == line),
"help should not pad blank lines with invisible whitespace"
);
}
#[test]
fn a_scope_this_build_cannot_honour_exits_as_a_usage_error() {
for (axis, printed) in [
(ScopeAxis::OneFilesystem, "--one-filesystem requires platform device identity"),
(
ScopeAxis::FollowSymlinks,
"follow_symlinks requires cycle, root-boundary, and filesystem-boundary semantics",
),
] {
let refusal = RequestError::ScopeUnsupported { axis, reason: axis.reason() };
let error = usage(&refused(&refusal));
assert!(is_usage_error(&error), "{axis:?} must exit like the bad argument it is");
let mut diagnostic = Vec::new();
assert_eq!(finish(Err(error), &mut diagnostic, false), 2);
assert_eq!(
String::from_utf8(diagnostic).expect("diagnostics are UTF-8"),
format!("fdu: unsupported scan configuration: {printed}\n")
);
}
}
#[test]
fn report_options_do_not_make_the_scan_path_optional() {
let error = Cli::command()
.color(ColorChoice::Never)
.try_get_matches_from(["fdu", "--view", "summary"])
.expect_err("a report without PATH must never fall back to the current directory");
assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument);
assert_eq!(error.exit_code(), 2);
assert!(error.use_stderr());
assert!(error.to_string().contains("<PATH>"));
}
#[test]
fn views_parse_as_an_ordered_comma_list() {
let parsed = Cli { view: Some("types,tree,summary".to_string()), ..cli() }
.resolved_query()
.expect("views parse");
assert_eq!(parsed.views, vec![ViewSpec::Types, ViewSpec::Tree, ViewSpec::Summary]);
}
#[test]
fn an_unknown_view_names_every_valid_value() {
let message = query_error(&Cli { view: Some("bogus".to_string()), ..cli() });
for view in ViewSpec::ALL {
let label = view.label();
assert!(message.contains(label), "{label} missing from: {message}");
}
assert!(message.contains("full"), "the total must be listed too: {message}");
}
#[test]
fn the_cache_policy_has_three_values_and_retired_ones_name_their_replacement() {
let parse = |value: &str| Cli { cache: value.to_string(), ..cli() }.parse_cache_policy();
assert_eq!(parse("on").expect("a policy"), CachePolicy::On);
assert_eq!(
parse("only").expect_err("retired").to_string(),
"invalid --cache \"only\": answering from the snapshot alone is now --stale-ok"
);
assert_eq!(
parse("readonly")
.expect_err("an unreleased alias must not become a contract")
.to_string(),
"invalid --cache \"readonly\": expected one of auto, on, off"
);
}
#[test]
fn a_repeated_view_is_a_typo_not_a_no_op() {
let message = query_error(&Cli { view: Some("tree,tree".to_string()), ..cli() });
assert!(message.contains("appears more than once"), "{message}");
}
#[test]
fn an_empty_list_entry_is_rejected() {
let message = query_error(&Cli { view: Some("tree,,types".to_string()), ..cli() });
assert!(message.contains("empty entry"), "{message}");
}
#[test]
fn full_is_the_explicit_bounds_request_and_specific_flags_override_it() {
let bounds = |query: fdu_core::query::Query| {
(
query.selection.depth,
query.selection.breadth,
query.selection.limit,
query.selection.min_share,
)
};
let shorthand = Cli::try_parse_from(["fdu", ".", "--full"]).expect("parse");
let explicit = Cli::try_parse_from([
"fdu",
".",
"--depth=all",
"--breadth=all",
"--limit=all",
"--min-share=0%",
])
.expect("parse");
assert_eq!(
bounds(shorthand.resolved_query().expect("query")),
bounds(explicit.resolved_query().expect("query"))
);
for args in [
vec!["fdu", ".", "--full", "--depth=3", "--breadth=2", "--limit=8", "--min-share=2%"],
vec!["fdu", ".", "--depth=3", "--breadth=2", "--limit=8", "--min-share=2%", "--full"],
] {
let actual = Cli::try_parse_from(args).expect("parse").resolved_query().expect("query");
let expected = Cli::try_parse_from([
"fdu",
".",
"--depth=3",
"--breadth=2",
"--limit=8",
"--min-share=2%",
])
.expect("parse")
.resolved_query()
.expect("query");
assert_eq!(bounds(actual), bounds(expected));
}
}
#[test]
fn bounds_accept_all_as_well_as_a_number() {
let parsed = Cli { depth: Some("all".to_string()), limit: Some("3".to_string()), ..cli() }
.resolved_query()
.expect("bounds parse");
assert_eq!(parsed.selection.depth, Some(Bound::All));
assert_eq!(parsed.selection.limit, Some(Bound::Limit(3)));
let zero = Cli { depth: Some("0".to_string()), ..cli() }.resolved_query().expect("parses");
assert_eq!(zero.selection.depth, Some(Bound::Limit(0)));
}
#[test]
fn an_unnamed_depth_takes_the_view_default_rather_than_unbounded() {
let parsed = cli().resolved_query().expect("parses");
assert_eq!(parsed.selection.depth, None, "the CLI must not invent a default");
assert_eq!(parsed.depth_for(ViewSpec::Tree), Bound::Limit(5));
assert_eq!(parsed.depth_for(ViewSpec::Files), Bound::All);
}
#[test]
fn patterns_are_repeatable_flags_so_brace_globs_survive() {
let parsed = Cli {
include: vec!["*.{rs,toml}".to_string(), "docs/**".to_string()],
exclude: vec!["**/target/**".to_string()],
..cli()
}
.resolved_query()
.expect("patterns parse");
assert_eq!(parsed.selection.include.len(), 2);
assert_eq!(parsed.selection.exclude.len(), 1);
}
#[test]
fn value_grammars_reach_the_cli_with_their_suggestions_intact() {
let fractional = query_error(&Cli { modified_since: Some("1.5h".to_string()), ..cli() });
assert!(fractional.contains("1h30m"), "{fractional}");
let calendar = query_error(&Cli { modified_before: Some("3months".to_string()), ..cli() });
assert!(calendar.contains("use days"), "{calendar}");
let size = query_error(&Cli { min_size: Some("10X".to_string()), ..cli() });
assert!(size.contains("unknown size unit"), "{size}");
}
#[test]
fn the_modified_window_reaches_the_selection_as_nanoseconds() {
let parsed = Cli {
modified_since: Some("@1000".to_string()),
modified_before: Some("@2000".to_string()),
..cli()
}
.resolved_query()
.expect("window parses");
assert_eq!(parsed.selection.modified.since, Some(1_000_000_000_000));
assert_eq!(parsed.selection.modified.before, Some(2_000_000_000_000));
}
#[test]
fn kinds_sort_and_size_translate_to_their_library_values() {
let parsed = Cli {
kind: Some("file,dir".to_string()),
sort: Some("mtime".to_string()),
size: "apparent".to_string(),
reverse: true,
..cli()
}
.resolved_query()
.expect("parses");
assert_eq!(parsed.selection.kinds, vec![EntryKind::File, EntryKind::Dir]);
assert_eq!(parsed.selection.sort, Some(SortKey::Mtime));
assert_eq!(parsed.selection.size, SizeMetric::Apparent);
assert!(parsed.selection.reverse);
}
#[test]
fn the_guide_only_names_flags_that_exist() {
let command = Cli::command();
let external_install_flags = ["--no-build", "--python"];
let known: Vec<String> = command
.get_arguments()
.filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
.collect();
let mut named = Vec::new();
for token in DOCS.split(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_')) {
if token.starts_with("--") && token.len() > 2 {
named.push(token.trim_end_matches('-').to_string());
}
}
assert!(!named.is_empty(), "the guide should name some flags");
for flag in &named {
if external_install_flags.contains(&flag.as_str()) {
continue;
}
assert!(known.contains(flag), "--docs names {flag}, which is not a flag");
}
assert!(DOCS_POINTER.contains("--docs"), "{DOCS_POINTER}");
}
#[test]
fn the_guide_only_names_views_and_analyzers_that_parse() {
let row = DOCS
.split("\n View ")
.nth(1)
.and_then(|rest| rest.split("\n Format ").next())
.expect("the guide should have a View axis row");
let listed: Vec<&str> =
row.split([',', ' ', '\n']).filter(|name| !name.is_empty()).collect();
let vocabulary = ViewSpec::vocabulary();
let accepted: Vec<&str> = vocabulary.split(", ").collect();
assert_eq!(listed, accepted, "the View row should list exactly what --view accepts");
for set in ["none", "lines", "code", "words", "all"] {
assert!(DOCS.contains(set), "the guide should list the {set} analyzer value");
AnalysisSet::parse(set).unwrap_or_else(|_| panic!("{set} must parse"));
}
}
#[test]
fn every_command_the_guide_shows_resolves() {
let mut checked = 0;
for line in DOCS.lines().filter(|line| line.starts_with(' ')) {
let Some(example) = line.trim_start().strip_prefix("fdu ") else { continue };
let example = example.split(" ").next().unwrap_or(example);
let args = std::iter::once("fdu").chain(example.split_whitespace());
let parsed = Cli::try_parse_from(args)
.unwrap_or_else(|error| panic!("the guide shows `fdu {example}`: {error}"));
if let Err(error) = parsed.resolved_query() {
panic!("the guide shows `fdu {example}`: {error}");
}
checked += 1;
}
assert!(checked > 0, "the guide should show commands");
}
#[test]
fn the_skill_only_names_views_and_analyzers_that_parse() {
let skill = compose_skill();
let spans = skill.split('`').filter(|span| span.starts_with("--"));
let commands = skill.lines().map(str::trim_start).filter(|line| line.starts_with("fdu "));
let mut checked = 0;
for text in spans.chain(commands) {
let mut words = text.split_whitespace();
while let Some(flag) = words.next() {
if flag != "--view" && flag != "--analyze" {
continue;
}
let Some(value) = words.next() else { continue };
for choice in value.split(['\\', '|']).filter(|choice| !choice.is_empty()) {
let parsed = if flag == "--view" {
ViewSpec::resolve(Some(choice), AnalysisSet::ALL, flag).map(drop)
} else {
AnalysisSet::parse(choice).map(drop)
};
assert!(parsed.is_ok(), "the skill shows `{flag} {choice}`: {parsed:?}");
checked += 1;
}
}
}
assert!(checked > 0, "the skill should show views and analyzers");
}
#[test]
fn no_surface_names_a_schema_the_binary_does_not_emit() {
const PREFIX: &str = "fdu.";
let live = [
report_format::REPORT_SCHEMA,
report_format::CONTENT_REPORT_SCHEMA,
report_format::CACHE_SCHEMA,
report_format::STREAM_SCHEMA,
];
for (surface, text) in [("--docs", DOCS.to_string()), ("--skill", compose_skill())] {
let mut rest = text.as_str();
let mut found = 0;
while let Some(at) = rest.find(PREFIX) {
rest = &rest[at..];
let tail = &rest[PREFIX.len()..];
let family = tail.chars().take_while(char::is_ascii_alphabetic).count();
if !tail[family..].starts_with('/') {
rest = &rest[PREFIX.len()..];
continue;
}
let digits = tail[family + 1..].chars().take_while(char::is_ascii_digit).count();
let named = &rest[..PREFIX.len() + family + 1 + digits];
assert!(
live.contains(&named),
"{surface} names {named}, but the binary emits {live:?}"
);
found += 1;
rest = &rest[named.len()..];
}
assert!(found > 0, "{surface} should state which schema it emits");
}
}
#[test]
fn requesting_analysis_selects_a_view_that_displays_it() {
let cases = [
(AnalysisSet::NONE, ViewSpec::List),
(AnalysisSet::NONE.with_lines(), ViewSpec::Families),
(AnalysisSet::NONE.with_code(), ViewSpec::Code),
(AnalysisSet::NONE.with_words(), ViewSpec::Documents),
(AnalysisSet::ALL, ViewSpec::Code),
];
for (profile, expected) in cases {
assert_eq!(ViewSpec::default_for(profile), expected, "default view for {profile:?}");
}
}
#[test]
fn an_explicit_view_overrides_the_derived_default() {
let resolved = resolve_views(Some("tree"), AnalysisSet::ALL).expect("resolve");
assert_eq!(resolved.selected, vec![ViewSpec::Tree]);
assert!(resolved.omitted.is_empty());
}
#[test]
fn view_full_expands_to_the_summary_views_the_analyzer_set_can_answer() {
let bare = resolve_views(Some("full"), AnalysisSet::NONE).expect("resolve");
assert_eq!(
bare.omitted,
vec![ViewSpec::Code, ViewSpec::Documents],
"content views need their analyzers"
);
assert!(!bare.selected.contains(&ViewSpec::Documents));
let analyzed = resolve_views(Some("full"), AnalysisSet::ALL).expect("resolve");
assert!(analyzed.omitted.is_empty());
assert!(analyzed.selected.contains(&ViewSpec::Largest));
assert!(analyzed.selected.contains(&ViewSpec::Recent));
assert!(!analyzed.selected.contains(&ViewSpec::Files), "{:?}", analyzed.selected);
assert_eq!(
analyzed.selected,
ViewSpec::ALL
.into_iter()
.filter(|view| view.is_summary_view() && *view != ViewSpec::Languages)
.collect::<Vec<_>>(),
"every summary view, in table order"
);
let combined = resolve_views(Some("full,tree"), AnalysisSet::NONE)
.expect_err("full cannot be combined")
.to_string();
assert!(combined.contains("cannot be combined"), "{combined}");
}
#[test]
fn no_view_enables_an_analyzer() {
for view in ViewSpec::ALL {
let spec = view.label();
let cli = Cli { view: Some(spec.to_string()), ..cli() };
let typed = cli.typed_values();
let built = Request::build(
&cli.spec(Path::new("."), &typed).expect("the spec composes"),
SystemTime::now(),
&AxisNames::FLAGS,
)
.expect("every view parses");
assert_eq!(
built.basis.content,
AnalysisSet::NONE,
"--view {spec} must leave the content axis empty"
);
}
}
#[test]
fn analysis_profile_workers_and_page_denominator_parse_before_io() {
let parsed_cli = Cli::try_parse_from(["fdu", ".", "--workers=3"]).expect("worker flag");
assert_eq!(parsed_cli.analysis_workers, 3);
let parsed = Cli {
analyze: "lines".to_string(),
analysis_workers: 3,
words_per_page: Request::DEFAULTS.words_per_page,
..cli()
};
let request = parsed.resolved_request().expect("request");
assert_eq!(request.basis.content, AnalysisSet::NONE.with_lines());
assert_eq!(parsed.analysis_workers, 3);
assert_eq!(request.query.words_per_page, Request::DEFAULTS.words_per_page);
let invalid = query_error(&Cli { analyze: "deep".to_string(), ..cli() });
assert!(invalid.contains("none, lines, code, words, all"), "{invalid}");
assert!(invalid.contains("--analyze"), "the message names the flag: {invalid}");
assert!(query_error(&Cli { words_per_page: 0, ..cli() }).contains("positive"));
}
#[test]
fn real_documents_report_exposes_requested_lines_words_pages_and_current_schema() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(root.path().join("notes.md"), b"one two\n\nthree\n").expect("write");
let command = Cli {
path: Some(root.path().to_path_buf()),
analyze: "lines,words".to_string(),
view: Some("documents".to_string()),
format: "json".to_string(),
size: "apparent".to_string(),
..cli()
};
let mut output = Vec::new();
let outcome = command
.run(
&mut output,
&mut Vec::new(),
false,
&TerminalFacts::default(),
ProgressIo::inert(),
)
.expect("run content report");
assert_eq!(outcome, RunOutcome::Complete);
let output = String::from_utf8(output).expect("UTF-8 JSON");
assert!(output.contains("\"schema\": \"fdu.report/10\""), "{output}");
assert!(output.contains("\"physical_lines\": 3"), "{output}");
assert!(output.contains("\"raw_words\": 3"), "{output}");
assert!(output.contains("\"words_per_page\": 250"), "{output}");
assert!(output.contains("\"content-basic-v1\""), "{output}");
}
#[test]
fn one_shot_text_ends_with_a_plain_or_dimmed_performance_footer() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(root.path().join("one.txt"), b"one\n").expect("write");
std::fs::write(root.path().join("two.txt"), b"two\n").expect("write");
let command = Cli {
path: Some(root.path().to_path_buf()),
analyze: "lines".to_string(),
view: Some("summary".to_string()),
size: "apparent".to_string(),
..cli()
};
let (mut plain, mut diagnostics) = (Vec::new(), Vec::new());
command
.run(
&mut plain,
&mut diagnostics,
false,
&TerminalFacts::default(),
ProgressIo::inert(),
)
.expect("plain report");
let plain = String::from_utf8(plain).expect("plain UTF-8");
let diagnostics = String::from_utf8(diagnostics).expect("plain diagnostics");
assert!(!plain.contains("perf:"), "the answer stays on stdout: {plain}");
assert!(!plain.contains("note:"), "facts stay on stderr: {plain}");
assert!(!plain.contains("tip:"), "suggestions stay on stderr: {plain}");
let lines: Vec<&str> = diagnostics.lines().collect();
let note = lines.iter().position(|line| line.starts_with("note:")).expect("display fact");
let tip =
lines.iter().position(|line| line.starts_with("tip:")).expect("display suggestion");
assert!(note < tip && tip < lines.len() - 1, "diagnostic category order: {diagnostics}");
let footer = diagnostics.lines().last().expect("performance footer");
assert!(footer.starts_with("perf: took "), "{plain}");
assert!(footer.contains("2 fresh at "), "{footer}");
assert!(footer.contains("0 cached"), "{footer}");
assert!(footer.ends_with("cold scan"), "{footer}");
assert!(!footer.contains('\u{1b}'), "color-disabled output must not contain ANSI");
let (mut colored, mut colored_diagnostics) = (Vec::new(), Vec::new());
Cli { color: ColorWhen::Always, ..command }
.run(
&mut colored,
&mut colored_diagnostics,
false,
&TerminalFacts::default(),
ProgressIo::inert(),
)
.expect("colored report");
let colored = String::from_utf8(colored).expect("colored UTF-8");
let colored_diagnostics =
String::from_utf8(colored_diagnostics).expect("colored diagnostics");
assert!(!colored.contains("perf:"), "the answer stays on stdout: {colored}");
for prefix in ["note:", "tip:"] {
let line =
colored_diagnostics.lines().find(|line| line.contains(prefix)).expect(prefix);
assert!(line.starts_with("\u{1b}[90m"), "gray {prefix}: {colored_diagnostics:?}");
}
let footer = colored_diagnostics.lines().last().expect("colored performance footer");
assert!(
footer.starts_with("\u{1b}[90mperf:"),
"the footer must use terminal gray when color is active: {colored:?}"
);
}
#[test]
fn quiet_preserves_the_answer_and_suppresses_notes_tips_and_perf() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(root.path().join("one.txt"), b"one\n").expect("write");
let command = |quiet| Cli {
path: Some(root.path().to_path_buf()),
analyze: "lines".to_string(),
view: Some("summary".to_string()),
size: "apparent".to_string(),
quiet,
..cli()
};
let run = |quiet| {
let (mut out, mut diagnostic) = (Vec::new(), Vec::new());
command(quiet)
.run(
&mut out,
&mut diagnostic,
false,
&TerminalFacts::default(),
ProgressIo::inert(),
)
.expect("report");
(out, diagnostic)
};
let (normal_out, normal_diagnostic) = run(false);
let (quiet_out, quiet_diagnostic) = run(true);
assert_eq!(normal_out, quiet_out, "quiet leaves result stdout intact");
assert!(String::from_utf8_lossy(&normal_diagnostic).contains("note:"));
assert!(String::from_utf8_lossy(&normal_diagnostic).contains("tip:"));
assert!(String::from_utf8_lossy(&normal_diagnostic).contains("perf:"));
assert!(quiet_diagnostic.is_empty(), "quiet diagnostic: {quiet_diagnostic:?}");
let (mut json_out, mut json_diagnostic) = (Vec::new(), Vec::new());
Cli {
format: "json".to_string(),
bar_size: 5_000, ..command(true)
}
.run(
&mut json_out,
&mut json_diagnostic,
false,
&TerminalFacts::default(),
ProgressIo::inert(),
)
.expect("quiet JSON report");
assert!(json_diagnostic.is_empty());
let json = String::from_utf8(json_out).expect("UTF-8 JSON");
assert!(json.trim().starts_with('{') && json.trim().ends_with('}'));
assert!(json.contains("\"schema\": \"fdu.report/10\""));
assert!(json.contains("\"view\": \"summary\""));
}
#[test]
fn quiet_keeps_warnings_on_the_diagnostic_stream() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(root.path().join("one.txt"), b"one\n").expect("write");
let command = Cli { path: Some(root.path().to_path_buf()), quiet: true, ..cli() };
let request = command.request(root.path(), SystemTime::now()).expect("request");
let (report, pending_save, _) =
prepare_report(&request, &Delivery::new(CachePolicy::Off, None)).expect("report");
pending_save.join().expect("no cache save");
let mut diagnostic = Vec::new();
write_report_diagnostics(
&mut diagnostic,
&report,
report_format::Format::Tree,
false,
Some("warn: snapshot could not be saved"),
true,
)
.expect("diagnostics");
assert_eq!(
String::from_utf8(diagnostic).expect("UTF-8"),
"warn: snapshot could not be saved\n"
);
}
#[test]
fn quiet_keeps_the_stale_answer_warning_and_a_verified_answer_has_none() {
const WARNING: &str = "warn: stale answer: served from the snapshot without filesystem \
verification; drop --stale-ok for a fresh answer\n";
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(root.path().join("one.txt"), b"one\n").expect("write");
let cache = tempfile::tempdir().expect("cache dir");
let command = |view: &str, format: &str, cache_policy: &str, stale_ok, quiet| Cli {
path: Some(root.path().to_path_buf()),
view: Some(view.to_string()),
format: format.to_string(),
size: "apparent".to_string(),
cache: cache_policy.to_string(),
cache_dir: Some(cache.path().to_path_buf()),
stale_ok,
quiet,
..cli()
};
let run = |command: Cli| {
let (mut out, mut diagnostic) = (Vec::new(), Vec::new());
command
.run(
&mut out,
&mut diagnostic,
false,
&TerminalFacts::default(),
ProgressIo::inert(),
)
.expect("report");
(
String::from_utf8(out).expect("UTF-8 stdout"),
String::from_utf8(diagnostic).expect("UTF-8 stderr"),
)
};
let (_, seeded) = run(command("tree", "text", "on", false, false));
assert!(!seeded.contains("warn:"), "a cold scan is not stale: {seeded}");
let (fresh_out, fresh) = run(command("summary", "text", "auto", false, false));
assert!(!fresh.contains("warn:"), "a verified answer is not stale: {fresh}");
let (_, fresh_quiet) = run(command("summary", "text", "auto", false, true));
assert!(fresh_quiet.is_empty(), "nothing to keep: {fresh_quiet:?}");
let (stale_out, stale) = run(command("summary", "text", "auto", true, false));
assert_eq!(stale_out, fresh_out, "an unchanged tree gives the same answer");
let warning = stale.find(WARNING).unwrap_or_else(|| panic!("stale marker: {stale}"));
let perf = stale.find("perf:").expect("a text report closes with perf:");
assert!(warning < perf, "warnings precede the performance footer: {stale}");
assert!(stale.trim_end().ends_with("cache only"), "{stale}");
let (quiet_out, quiet) = run(command("summary", "text", "auto", true, true));
assert_eq!(quiet_out, stale_out, "quiet leaves result stdout intact");
assert_eq!(quiet, WARNING, "quiet keeps the stale marker and nothing else");
for (view, format) in [("summary", "json"), ("list", "paths")] {
let (_, quiet) = run(command(view, format, "auto", true, true));
assert_eq!(quiet, WARNING, "{format} keeps the same marker under quiet");
let (_, fresh_quiet) = run(command(view, format, "auto", false, true));
assert!(!fresh_quiet.contains("warn:"), "{format}: {fresh_quiet}");
}
}
#[test]
fn the_performance_footer_measures_walked_bytes_as_the_answer_does() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(root.path().join("one.txt"), b"one\n").expect("write");
std::fs::write(root.path().join("two.txt"), b"two\n").expect("write");
for size in [SIZE_DEFAULT, "apparent"] {
let command = Cli {
path: Some(root.path().to_path_buf()),
view: Some("summary".to_string()),
size: size.to_string(),
..cli()
};
let (mut out, mut diagnostic) = (Vec::new(), Vec::new());
command
.run(
&mut out,
&mut diagnostic,
false,
&TerminalFacts::default(),
ProgressIo::inert(),
)
.expect("summary report");
let out = String::from_utf8(out).expect("UTF-8");
let diagnostic = String::from_utf8(diagnostic).expect("UTF-8 diagnostics");
let row = out.lines().next().expect("the summary row").trim_start();
let answer = row.split(" ").next().expect("the row's size");
let footer = diagnostic.lines().last().expect("the footer");
assert!(
footer.contains(&format!("to walk 2 files ({answer}) at ")),
"--size {size}: {out}"
);
}
}
#[test]
fn performance_footer_names_units_cache_work_and_metadata_tier() {
let footer = performance_footer(
PerformanceSummary {
walked_files: 12_345,
walked_bytes: 2_048,
walked_allocated: 50_565_120,
fresh_files: 3_000,
bytes_read: 2_048,
analysis_ns: 2_000_000_000,
cached_files: 2,
cached_bytes: 4_096,
source: ReportSource::WarmRevalidate,
},
&ControlCoverage::NotObserved,
Duration::from_millis(2_500),
SizeMetric::Apparent,
false,
);
assert_eq!(
footer,
"perf: took 2.50 s to walk 12,345 files (2.0 KiB) at 4,938 files/s (0.000 GiB/s); gitignore not read; content read 2.0 KiB at 1.0 KiB/s; analysis 3,000 fresh at 1,500 files/s, 2 cached (4.0 KiB); warm revalidation"
);
}
#[test]
fn large_performance_sizes_are_bold_gray_and_restore_gray_afterward() {
let performance = PerformanceSummary {
walked_files: 1,
walked_bytes: 1 << 30,
fresh_files: 1,
bytes_read: 1 << 30,
analysis_ns: 1_000_000_000,
cached_files: 1,
cached_bytes: 1 << 30,
..PerformanceSummary::default()
};
let plain = performance_footer(
performance,
&ControlCoverage::NotObserved,
Duration::from_secs(1),
SizeMetric::Apparent,
false,
);
assert!(!plain.contains('\u{1b}'));
assert!(plain.contains("to walk 1 file (1.0 GiB)"), "{plain}");
assert!(plain.contains("1 cached (1.0 GiB)"), "{plain}");
let colored = paint(
&performance_footer(
performance,
&ControlCoverage::NotObserved,
Duration::from_secs(1),
SizeMetric::Apparent,
true,
),
STYLE_PERFORMANCE,
true,
);
let bold_gray_size = report_format::styled_bytes(1 << 30, 0, true, true);
assert!(bold_gray_size.contains('\u{1b}'), "{bold_gray_size:?}");
assert_eq!(
colored.matches(&format!("{bold_gray_size}{STYLE_PERFORMANCE}")).count(),
4,
"walked, read, read-rate, and cached sizes each restore gray: {colored:?}"
);
}
#[test]
fn machine_formats_omit_the_performance_footer() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(root.path().join("one.txt"), b"one\n").expect("write");
for format in ["json", "jsonl", "yaml"] {
let command = Cli {
path: Some(root.path().to_path_buf()),
view: Some("summary".to_string()),
size: "apparent".to_string(),
format: format.to_string(),
..cli()
};
let (mut output, mut diagnostic) = (Vec::new(), Vec::new());
command
.run(
&mut output,
&mut diagnostic,
false,
&TerminalFacts::default(),
ProgressIo::inert(),
)
.expect("machine report");
let output = String::from_utf8(output).expect("machine UTF-8");
let diagnostic = String::from_utf8(diagnostic).expect("machine diagnostics");
assert!(!output.contains("perf:"), "{format}: {output}");
assert!(!diagnostic.contains("perf:"), "{format}: {diagnostic}");
}
}
#[test]
fn formats_parse_and_machine_formats_are_never_colorized() {
for (value, expected) in [
("text", report_format::Format::Text),
("tree", report_format::Format::Tree),
("paths", report_format::Format::Paths),
("long", report_format::Format::Long),
("json", report_format::Format::Json),
("jsonl", report_format::Format::Jsonl),
("yaml", report_format::Format::Yaml),
] {
let cli = Cli { format: value.to_string(), ..cli() };
assert_eq!(cli.parse_format().expect("format parses"), expected);
assert_eq!(cli.machine_format(), expected.is_machine());
}
let message = Cli { format: "xml".to_string(), ..cli() }
.parse_format()
.expect_err("rejected")
.to_string();
assert!(message.contains("text, tree, paths, long, json, jsonl, yaml"), "{message}");
}
#[test]
fn an_unparseable_request_costs_no_filesystem_work() {
let message = query_error(&Cli {
path: Some(PathBuf::from("/nonexistent-root-that-should-not-be-scanned")),
view: Some("bogus".to_string()),
..cli()
});
assert!(message.contains("expected one of"), "{message}");
}
#[test]
fn only_a_standalone_heading_loses_its_colon() {
assert_eq!(strip_heading_colon("SCOPE:"), "SCOPE");
assert_eq!(strip_heading_colon("CACHE MANAGEMENT:"), "CACHE MANAGEMENT");
assert_eq!(
strip_heading_colon("\u{1b}[1m\u{1b}[36mSCOPE:\u{1b}[0m"),
"\u{1b}[1m\u{1b}[36mSCOPE\u{1b}[0m"
);
for line in [
"Usage: fdu [OPTIONS] <PATH>",
"fdu: invalid --view \"bogus\": expected one of tree",
" Scope PATH, --scan-depth",
"note: omitted documents — requires content analysis",
"",
" --scan-depth <N> Limit scanning and retention to N entry levels",
] {
assert_eq!(strip_heading_colon(line), line, "{line:?} must keep its shape");
}
}
#[test]
fn every_help_heading_is_upper_case_and_bare() {
let mut rendered = Vec::new();
write_styled(&mut rendered, &Cli::command().render_help(), false).expect("render");
let rendered = String::from_utf8(rendered).expect("utf-8 help");
let headings: Vec<&str> = rendered
.lines()
.filter(|line| {
!line.is_empty()
&& !line.starts_with(char::is_whitespace)
&& line.chars().all(|c| c.is_uppercase() || c == ' ' || c == ':')
&& line.chars().any(char::is_alphabetic)
})
.collect();
assert!(headings.len() >= 8, "expected the section headings, got {headings:?}");
for heading in headings {
assert!(!heading.ends_with(':'), "{heading:?} still carries clap's colon");
assert_eq!(heading, heading.to_uppercase(), "{heading:?} is not upper case");
}
}
#[test]
fn paint_is_a_no_op_when_color_is_off() {
assert_eq!(paint("text", STYLE_HEADING, false), "text");
assert!(paint("text", STYLE_HEADING, true).contains("\u{1b}["));
}
#[test]
fn color_decision_has_stable_precedence_and_machine_output_is_plain() {
let auto_terminal = ColorContext {
when: ColorWhen::Auto,
json: false,
skill: false,
no_color_env: false,
force_color_env: false,
destination_is_terminal: true,
};
assert!(auto_terminal.enabled());
assert!(!ColorContext { destination_is_terminal: false, ..auto_terminal }.enabled());
assert!(
ColorContext { force_color_env: true, destination_is_terminal: false, ..auto_terminal }
.enabled()
);
assert!(
!ColorContext { no_color_env: true, force_color_env: true, ..auto_terminal }.enabled()
);
assert!(
ColorContext {
when: ColorWhen::Always,
no_color_env: true,
destination_is_terminal: false,
..auto_terminal
}
.enabled()
);
assert!(
!ColorContext { when: ColorWhen::Never, force_color_env: true, ..auto_terminal }
.enabled()
);
assert!(!ColorContext { json: true, ..auto_terminal }.enabled());
assert!(!ColorContext { skill: true, ..auto_terminal }.enabled());
}
#[test]
fn portable_skill_prefers_the_installed_command_and_names_its_own_build() {
let skill = compose_skill();
assert!(skill.starts_with("---\nname: fdu\n"));
assert!(!skill.contains('\r'), "the public skill must use portable LF endings");
assert!(skill.contains("complete fdu usage contract"));
assert!(skill.contains("needs no setup chat or prior session"));
assert!(skill.contains("command -v fdu"), "the installed command comes first");
assert!(
skill.contains("uvx --no-build fdu@latest "),
"the fallback is the latest release and requires a wheel"
);
assert!(!skill.contains("--from fdu=="), "no exact pin is left in the skill");
assert!(skill.contains("uv tool install --no-build fdu"));
assert!(skill.contains("uv tool upgrade --no-build fdu"));
assert!(skill.contains("cargo install --locked fdu"));
assert!(skill.contains(&format!("Generated by fdu `{}`", env!("FDU_BUILD_VERSION"))));
assert!(!skill.contains("__FDU_VERSION__"));
let frontmatter_end = skill[4..].find("\n---\n").expect("frontmatter closes") + 4 + 5;
assert!(
skill[frontmatter_end..].starts_with("<!-- generated by fdu"),
"the marker follows the frontmatter"
);
assert_eq!(
compose_skill_from("---\r\nversion: __FDU_VERSION__\r\n"),
format!("---\nversion: {}\n", env!("FDU_BUILD_VERSION"))
);
}
#[test]
fn install_skill_reports_each_file_and_refuses_a_foreign_one_with_exit_two() {
let sandbox = tempfile::tempdir().expect("tempdir");
let base = sandbox.path().join(".claude");
let skill = base.join("skills").join("fdu").join("SKILL.md");
let run = || {
let mut out = Vec::new();
let mut err = Vec::new();
let args = [
OsString::from("fdu"),
OsString::from("--install-skill"),
OsString::from("--agent-base"),
base.clone().into_os_string(),
];
let status = run_with_io(
&args,
&mut out,
&mut err,
false,
&TerminalFacts::default(),
ProgressIo::inert(),
);
(status, String::from_utf8(out).expect("utf-8"), String::from_utf8(err).expect("utf-8"))
};
let (status, out, err) = run();
assert_eq!(status, 0);
assert_eq!(out, format!("installed {}\n", skill.display()));
assert!(err.is_empty(), "{err}");
assert_eq!(std::fs::read_to_string(&skill).expect("read"), compose_skill());
let (status, out, err) = run();
assert_eq!(status, 0);
assert_eq!(out, format!("unchanged {}\n", skill.display()));
assert!(err.is_empty(), "{err}");
std::fs::write(&skill, "---\nname: fdu\n---\n# Written by hand\n").expect("write foreign");
let (status, out, err) = run();
assert_eq!(status, 2, "a foreign file is refused as a usage error");
assert!(out.is_empty(), "nothing is reported installed: {out}");
assert_eq!(
err,
format!(
"fdu: refusing to overwrite {}: fdu did not generate it (no `<!-- generated by fdu` marker); move it aside, then re-run fdu --install-skill\n",
skill.display()
)
);
assert_eq!(
std::fs::read_to_string(&skill).expect("read"),
"---\nname: fdu\n---\n# Written by hand\n",
"the foreign file is untouched"
);
let mut out = Vec::new();
let mut err = Vec::new();
let args = ["fdu", "--agent-base", "x", "."].map(OsString::from);
let status = run_with_io(
&args,
&mut out,
&mut err,
false,
&TerminalFacts::default(),
ProgressIo::inert(),
);
assert_eq!(status, 2);
assert!(String::from_utf8(err).expect("utf-8").contains("--install-skill"));
}
#[test]
fn install_skill_says_what_it_installed_before_a_write_fails() {
let sandbox = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(sandbox.path().join(".git")).expect("mark the project root");
let targets = skill_install::targets(sandbox.path(), None);
let staged = skill_install::staged_path(targets[1].parent().expect("parent"));
std::fs::create_dir_all(&staged).expect("block the second target's staged path");
let mut out = Vec::new();
let error = parse(&["fdu", "--install-skill"])
.install_skill_from(&mut out, sandbox.path())
.expect_err("the second write fails");
assert_eq!(
String::from_utf8(out).expect("utf-8"),
"installed .agents/skills/fdu/SKILL.md\n"
);
assert!(!is_usage_error(&error), "a write failure is not a usage error");
assert_eq!(error.to_string(), "cannot install the skill at .claude/skills/fdu/SKILL.md");
let rerun = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(rerun.path().join(".git")).expect("mark the project root");
let targets = skill_install::targets(rerun.path(), None);
std::fs::create_dir_all(skill_install::staged_path(targets[1].parent().expect("parent")))
.expect("block the second target's staged path");
let error = parse(&["fdu", "--install-skill"])
.install_skill_from(&mut ClosedStdout, rerun.path())
.expect_err("the second write fails");
assert_eq!(
error.to_string(),
"cannot install the skill at .claude/skills/fdu/SKILL.md",
"the install error survives a closed stdout"
);
let mut diagnostic = Vec::new();
assert_eq!(finish(Err(error), &mut diagnostic, false), 1, "exit 1, not the broken-pipe 0");
assert!(
String::from_utf8(diagnostic)
.expect("utf-8")
.starts_with("fdu: cannot install the skill at .claude/skills/fdu/SKILL.md\n")
);
let base = sandbox.path().join("home");
let skill_dir = base.join("skills").join("fdu");
std::fs::create_dir_all(skill_install::staged_path(&skill_dir))
.expect("block the staged path");
let mut out = Vec::new();
let mut err = Vec::new();
let args = [
OsString::from("fdu"),
OsString::from("--install-skill"),
OsString::from("--agent-base"),
base.into_os_string(),
];
let status = run_with_io(
&args,
&mut out,
&mut err,
false,
&TerminalFacts::default(),
ProgressIo::inert(),
);
assert_eq!(status, 1);
assert!(out.is_empty(), "nothing was installed, so nothing is reported: {out:?}");
let err = String::from_utf8(err).expect("utf-8");
let headline =
format!("fdu: cannot install the skill at {}\n", skill_dir.join("SKILL.md").display());
assert!(err.starts_with(&headline), "{err}");
assert!(!skill_dir.join("SKILL.md").exists(), "the failed target never became visible");
}
#[test]
fn machine_result_format_does_not_disable_fatal_stderr_color() {
let temp = tempfile::tempdir().expect("tempdir");
for (choice, colored) in [("always", true), ("never", false)] {
let args = [
OsString::from("fdu"),
temp.path().join("missing").into_os_string(),
OsString::from("--format=json"),
OsString::from(format!("--color={choice}")),
];
let (mut out, mut err) = (Vec::new(), Vec::new());
let status = run_with_io(
&args,
&mut out,
&mut err,
false,
&TerminalFacts::default(),
ProgressIo::inert(),
);
assert_eq!(status, 1);
assert!(out.is_empty());
assert_eq!(err.contains(&0x1b), colored);
}
}
#[test]
fn run_outcomes_and_broken_pipes_have_stable_exit_codes() {
let mut diagnostic = Vec::new();
assert_eq!(finish(Ok(RunOutcome::Complete), &mut diagnostic, false), 0);
assert_eq!(finish(Ok(RunOutcome::Partial), &mut diagnostic, false), 2);
let broken_pipe =
anyhow::Error::new(io::Error::new(io::ErrorKind::BrokenPipe, "reader closed"))
.context("render output");
assert_eq!(finish(Err(broken_pipe), &mut diagnostic, false), 0);
assert!(diagnostic.is_empty());
let args = [OsString::from("fdu"), OsString::from("--help")];
assert_eq!(
run_with_io(
&args,
&mut FailingWriter,
&mut diagnostic,
false,
&TerminalFacts::default(),
ProgressIo::inert()
),
1,
"a non-pipe help-output failure is fatal"
);
}
fn interactive_terminal() -> TerminalFacts {
TerminalFacts {
stderr_is_terminal: true,
term: Some(OsString::from("xterm-256color")),
term_may_be_unset: false,
ci: None,
vt_enabled: true,
}
}
#[test]
fn warnings_are_yellow_without_bold_and_fatal_errors_are_red_bold() {
let sgr = |text: &str| -> Vec<String> {
text.split("\u{1b}[")
.skip(1)
.filter_map(|part| part.split_once('m').map(|(code, _)| code))
.flat_map(|code| code.split(';').map(str::to_string))
.collect()
};
let warning = paint("warn: incomplete", STYLE_WARNING, true);
let warning_codes = sgr(&warning);
assert!(warning_codes.iter().any(|code| code == "33"), "yellow warning: {warning:?}");
assert!(!warning_codes.iter().any(|code| code == "1"), "warning is not bold: {warning:?}");
let mut diagnostic = Vec::new();
assert_eq!(finish(Err(anyhow::anyhow!("failed")), &mut diagnostic, true), 1);
let fatal = String::from_utf8(diagnostic).expect("UTF-8 diagnostic");
let error_codes = sgr(&fatal);
assert!(error_codes.iter().any(|code| code == "31"), "red error: {fatal:?}");
assert!(error_codes.iter().any(|code| code == "1"), "bold error: {fatal:?}");
}
fn parse(args: &[&str]) -> Cli {
Cli::try_parse_from(args).expect("the command line parses")
}
#[test]
fn quiet_and_tree_bar_size_flags_parse_with_their_defaults() {
assert!(!parse(&["fdu", "."]).quiet);
assert!(parse(&["fdu", "--quiet", "."]).quiet);
assert!(parse(&["fdu", "-q", "."]).quiet);
assert_eq!(parse(&["fdu", "."]).bar_size(), 10);
assert_eq!(parse(&["fdu", "--bar-size", "20", "."]).bar_size(), 20);
assert_eq!(parse(&["fdu", "--bar-size=0", "."]).bar_size(), 0);
assert_eq!(parse(&["fdu", "--bar-size", "-1", "."]).bar_size(), 0);
}
#[test]
fn the_progress_flag_parses_like_color_and_defaults_to_auto() {
assert_eq!(parse(&["fdu", "."]).progress, ProgressMode::Auto);
assert_eq!(parse(&["fdu", "--progress", "always", "."]).progress, ProgressMode::Always);
assert_eq!(parse(&["fdu", "--progress=never", "."]).progress, ProgressMode::Never);
assert_eq!(parse(&["fdu", "--progress", "auto", "."]).progress, ProgressMode::Auto);
let mut out = Vec::new();
let mut err = Vec::new();
let args = ["fdu", "--progress", "sometimes", "."].map(OsString::from);
let status = run_with_io(
&args,
&mut out,
&mut err,
false,
&TerminalFacts::default(),
ProgressIo::inert(),
);
assert_eq!(status, 2, "a value outside the grammar is a usage error");
assert!(out.is_empty());
let err = String::from_utf8(err).expect("UTF-8 diagnostics");
assert!(err.contains("invalid value 'sometimes' for '--progress <WHEN>'"), "{err}");
}
#[test]
fn help_places_progress_beside_color_and_says_always_never_reaches_a_pipe() {
let mut out = Vec::new();
let mut err = Vec::new();
let args = [OsString::from("fdu"), OsString::from("--help")];
run_with_io(
&args,
&mut out,
&mut err,
false,
&TerminalFacts::default(),
ProgressIo::inert(),
);
let help = String::from_utf8(out).expect("help is UTF-8");
let lines: Vec<&str> = help.lines().collect();
let color = lines
.iter()
.position(|line| line.trim_start().starts_with("--color <WHEN>"))
.expect("--color is in the help");
assert!(
lines[color + 1].trim_start().starts_with("--progress <WHEN>"),
"--progress follows --color:\n{help}"
);
let output = help.split("OUTPUT\n").nth(1).expect("an OUTPUT section");
let output = output.split("\n\n").next().expect("the section ends at a blank line");
assert!(output.contains("--progress <WHEN>"), "{output}");
let progress = output.split("--progress").nth(1).expect("the flag's help");
let progress = progress.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(progress.contains("unlike --color, never draws into a pipe or file"), "{progress}");
assert!(progress.contains("[default: auto]"), "{progress}");
assert!(output.contains("--bar-size <N>"), "{output}");
assert!(output.contains("-q, --quiet"), "{output}");
}
#[test]
fn the_progress_plan_draws_only_for_a_walking_run_at_an_interactive_terminal() {
let interactive = interactive_terminal();
let plan = progress_plan_of;
assert!(plan(&["fdu", "."], &interactive).draw);
assert!(plan(&["fdu", "--tree", "."], &interactive).draw);
assert!(plan(&["fdu", "--long", "."], &interactive).draw);
assert!(plan(&["fdu", "--format", "paths", "."], &interactive).draw);
assert!(!plan(&["fdu", "--format", "json", "."], &interactive).draw);
assert!(!plan(&["fdu", "--format", "jsonl", "."], &interactive).draw);
assert!(!plan(&["fdu", "--format", "yaml", "."], &interactive).draw);
assert!(plan(&["fdu", "--progress", "always", "--format", "json", "."], &interactive).draw);
assert!(!plan(&["fdu", "--progress", "never", "."], &interactive).draw);
assert!(!plan(&["fdu", "--quiet", "."], &interactive).draw);
assert!(!plan(&["fdu", "-q", "--progress", "always", "."], &interactive).draw);
for command in [
&["fdu", "--docs"][..],
&["fdu", "--skill"],
&["fdu", "--install-skill"],
&["fdu", "--cache-status"],
&["fdu", "--cache-clear", "."],
&["fdu", "--progress", "always", "--docs"],
&["fdu", "--progress", "always", "--install-skill", "--agent-base", "x"],
&["fdu", "--progress", "always", "--cache-status=all", "."],
] {
assert!(!plan(command, &interactive).draw, "{command:?} walks nothing");
}
for terminal in [
TerminalFacts::default(),
TerminalFacts { stderr_is_terminal: false, ..interactive_terminal() },
TerminalFacts { term: None, ..interactive_terminal() },
TerminalFacts { term: Some(OsString::from("dumb")), ..interactive_terminal() },
TerminalFacts { ci: Some(OsString::from("true")), ..interactive_terminal() },
TerminalFacts { vt_enabled: false, ..interactive_terminal() },
] {
for mode in ["auto", "always", "never"] {
assert!(
!plan(&["fdu", "--progress", mode, "."], &terminal).draw,
"{terminal:?} drew under --progress {mode}"
);
}
}
}
#[test]
fn the_progress_plan_names_the_root_and_follows_the_color_rule() {
let interactive = interactive_terminal();
let plan = |args: &[&str]| progress_plan_of(args, &interactive);
assert_eq!(plan(&["fdu", "."]).root, ".");
assert_eq!(
plan(&["fdu", "--cache-status"]).root,
".",
"a lifecycle command without a path reports on the current directory"
);
let home = home_directory().expect("the test runner has a home directory");
let under_home = home.join("wrk").join("github");
assert_eq!(
plan(&["fdu", under_home.to_str().expect("Unicode")]).root,
Path::new("~").join("wrk").join("github").display().to_string()
);
assert!(!plan(&["fdu", "--color", "never", "."]).color);
assert!(plan(&["fdu", "--color", "always", "."]).color);
assert!(
progress_plan_of(&["fdu", "--color", "always", "."], &TerminalFacts::default()).color,
"--color always colors a frame it will never draw; drawing is --progress's call"
);
assert!(!plan(&["fdu", "--color", "never", "--format", "json", "."]).draw);
assert_eq!(plan(&["fdu", "."]).size, SizeMetric::Allocated);
assert_eq!(plan(&["fdu", "--size", "apparent", "."]).size, SizeMetric::Apparent);
}
fn progress_plan_of(args: &[&str], terminal: &TerminalFacts) -> ProgressPlan {
let parsed = parse(args);
let request = parsed.resolved_request().expect("the request resolves");
parsed.progress_plan(terminal, &request)
}
#[test]
fn an_interactive_run_inside_the_delay_draws_no_progress_frame() {
let interactive = interactive_terminal();
let mut out = Vec::new();
let err = SharedBuffer::default();
let shipped = || ProgressIo {
out: Box::new(err.clone()),
timing: Timing::default(),
width: || 100,
interrupt: |_, _| {},
};
let args = ["fdu", "--progress", "always", "--docs"].map(OsString::from);
assert_eq!(
run_with_io(&args, &mut out, &mut err.clone(), true, &interactive, shipped()),
0
);
assert!(!out.is_empty());
assert!(err.contents().is_empty(), "{:?}", err.text());
let root = tempfile::tempdir().expect("tempdir");
let mut out = Vec::new();
let args = [
"fdu",
"--cache",
"off",
"--color",
"never",
"--progress",
"always",
root.path().to_str().expect("Unicode"),
]
.map(OsString::from);
assert_eq!(
run_with_io(&args, &mut out, &mut err.clone(), true, &interactive, shipped()),
0
);
assert!(!String::from_utf8(out).expect("UTF-8").contains("perf:"));
assert!(err.text().lines().last().is_some_and(|line| line.starts_with("perf:")));
assert!(!err.text().contains(ERASE_LINE), "{:?}", err.text());
}
fn wide_tree() -> tempfile::TempDir {
let root = tempfile::tempdir().expect("tempdir");
for dir in 0..40 {
let dir = root.path().join(format!("dir{dir}"));
std::fs::create_dir(&dir).expect("a directory");
for file in 0..25 {
std::fs::write(dir.join(format!("file{file}.txt")), b"some bytes\n")
.expect("a file");
}
}
root
}
fn drawing_io(err: &SharedBuffer) -> ProgressIo {
ProgressIo {
out: Box::new(err.clone()),
timing: Timing { delay: Duration::ZERO, tick: Duration::from_millis(1) },
width: || 100,
interrupt: |_, _| {},
}
}
fn drawn_frames(text: &str) -> usize {
text.matches(ERASE_LINE).count().saturating_sub(1)
}
fn run_until_drawn(
mut run: impl FnMut(&SharedBuffer) -> (u8, Vec<u8>),
) -> (u8, Vec<u8>, String) {
for _ in 0..5 {
let err = SharedBuffer::default();
let (status, out) = run(&err);
let text = err.text();
if drawn_frames(&text) >= 1 {
return (status, out, text);
}
}
panic!("the ticker drew no frame in five runs");
}
#[cfg(unix)]
#[test]
fn the_line_is_erased_before_the_first_warning() {
use std::os::unix::fs::PermissionsExt;
let root = wide_tree();
let denied = root.path().join("denied");
std::fs::create_dir(&denied).expect("a directory");
std::fs::write(denied.join("hidden.txt"), b"hidden").expect("a file");
std::fs::set_permissions(&denied, std::fs::Permissions::from_mode(0o000))
.expect("deny reads");
if std::fs::read_dir(&denied).is_ok() {
eprintln!("skipped: this process is not subject to Unix permission bits");
return;
}
let args = [
"fdu",
"--cache",
"off",
"--color",
"never",
"--progress",
"always",
root.path().to_str().expect("Unicode"),
]
.map(OsString::from);
let (status, out, text) = run_until_drawn(|err| {
let mut out = Vec::new();
let status = run_with_io(
&args,
&mut out,
&mut err.clone(),
false,
&interactive_terminal(),
drawing_io(err),
);
(status, out)
});
std::fs::set_permissions(&denied, std::fs::Permissions::from_mode(0o755))
.expect("restore permissions so the directory can be removed");
assert_eq!(status, 2, "a partial scan");
assert!(!String::from_utf8(out).expect("UTF-8").contains("perf:"));
assert!(text.lines().last().is_some_and(|line| line.starts_with("perf:")));
let warning = text.find("warn:").expect("a status warning on stderr");
let first_diagnostic = ["note:", "warn:", "tip:", "perf:"]
.iter()
.filter_map(|prefix| text.find(prefix))
.min()
.expect("a diagnostic after the scan");
assert!(
text[..first_diagnostic].ends_with(ERASE_LINE),
"the erase is the last thing before diagnostics:\n{text:?}"
);
assert!(text[..first_diagnostic].starts_with(&format!("{ERASE_LINE}⠋ ")), "{text:?}");
assert!(
!text[first_diagnostic..].contains(ERASE_LINE),
"the line was drawn after diagnostics"
);
assert!(!text[first_diagnostic..].contains('\r'), "{text:?}");
assert!(warning < text.rfind("perf:").expect("performance summary"));
}
#[test]
fn the_line_is_erased_before_an_error() {
let root = wide_tree();
let args = [
"fdu",
"--cache",
"off",
"--color",
"never",
"--progress",
"always",
root.path().to_str().expect("Unicode"),
]
.map(OsString::from);
let (status, _, text) = run_until_drawn(|err| {
let status = run_with_io(
&args,
&mut FailingWriter,
&mut err.clone(),
false,
&interactive_terminal(),
drawing_io(err),
);
(status, Vec::new())
});
assert_eq!(status, 1, "a report that cannot be written is fatal");
let error = text.find("fdu: ").expect("the error on stderr");
assert!(
text[..error].ends_with(ERASE_LINE),
"the erase is the last thing before the error:\n{text:?}"
);
assert_eq!(&text[error..], "fdu: output failed\n");
}
#[test]
fn a_non_interactive_run_draws_no_progress_frame() {
let root = wide_tree();
for terminal in [
TerminalFacts::default(),
TerminalFacts { stderr_is_terminal: false, ..interactive_terminal() },
TerminalFacts { term: Some(OsString::from("dumb")), ..interactive_terminal() },
TerminalFacts { ci: Some(OsString::from("true")), ..interactive_terminal() },
] {
for mode in ["auto", "always"] {
let err = SharedBuffer::default();
let mut out = Vec::new();
let args = [
"fdu",
"--cache",
"off",
"--color",
"never",
"--progress",
mode,
root.path().to_str().expect("Unicode"),
]
.map(OsString::from);
let status = run_with_io(
&args,
&mut out,
&mut err.clone(),
false,
&terminal,
drawing_io(&err),
);
assert_eq!(status, 0);
assert!(!String::from_utf8(out).expect("UTF-8").contains("perf:"));
assert!(err.text().lines().last().is_some_and(|line| line.starts_with("perf:")));
assert!(
!err.text().contains(ERASE_LINE),
"{terminal:?} --progress {mode}:\n{:?}",
err.text()
);
}
}
}
struct ClosedPipe {
stderr: SharedBuffer,
stderr_at_first_write: Option<String>,
}
impl Write for ClosedPipe {
fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
self.stderr_at_first_write.get_or_insert_with(|| self.stderr.text());
Err(io::Error::new(io::ErrorKind::BrokenPipe, "reader closed"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn a_drawing_run_whose_stdout_closes_early_ends_quietly_with_a_clean_line() {
let root = wide_tree();
let args = [
"fdu",
"--cache",
"off",
"--color",
"never",
"--progress",
"always",
root.path().to_str().expect("Unicode"),
]
.map(OsString::from);
let mut at_first_write = None;
let (status, _, text) = run_until_drawn(|err| {
let mut stdout = ClosedPipe { stderr: err.clone(), stderr_at_first_write: None };
let status = run_with_io(
&args,
&mut stdout,
&mut err.clone(),
false,
&interactive_terminal(),
drawing_io(err),
);
at_first_write = stdout.stderr_at_first_write;
(status, Vec::new())
});
assert_eq!(status, 0, "a consumer that has seen enough is not a failure");
assert_eq!(
at_first_write.as_deref(),
Some(text.as_str()),
"the line was erased, and nothing more drawn, before the report touched stdout"
);
assert!(text.starts_with(&format!("{ERASE_LINE}⠋ ")), "{text:?}");
assert!(text.ends_with(ERASE_LINE), "the erase is the last thing on stderr:\n{text:?}");
let after_last_frame = text.rsplit_once(ERASE_LINE).map(|(_, rest)| rest);
assert_eq!(after_last_frame, Some(""), "nothing follows the erase:\n{text:?}");
assert!(!text.contains("fdu:"), "a closed pipe is reported to nobody:\n{text:?}");
}
}