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 — the fastest du replacement, with file tree analysis.
For every directory in a tree at once, fdu reports its size, file count,
recency, and file kinds, and on request its lines of code and prose volume.
It walks the tree on several threads through each platform's native
directory interface; on a million-file tree it runs about 9x as fast as du
on macOS and 2.6x as fast on Linux, ahead of pdu, diskus, and dust.
Content metrics are cached between runs, and every capability is also a
Rust and Python API.
Speed details: https://github.com/jlevy/fdu/blob/main/docs/speed.md
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;
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, initial) =
Self::start_watch(request, delivery, format, render, indicator)?;
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))?;
}
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,
&mut 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,
&mut 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 start_watch(
request: &Request,
delivery: &Delivery,
format: report_format::Format,
render: report_format::RenderOptions,
indicator: Option<(ProgressPlan, ProgressIo)>,
) -> anyhow::Result<(fdu_core::watch_session::Session, Report)> {
use fdu_core::watch_session::Session;
let (session, answer) = if let Some((plan, progress_io)) = indicator {
let progress = Progress::new();
let mut ticker = Ticker::start(plan, progress.clone(), Instant::now(), progress_io);
let started =
Session::start_with_progress(request.clone(), delivery.clone(), &progress)
.and_then(|mut session| {
let answer = session.changed_report_with_progress(
SystemTime::now(),
format,
render,
&progress,
)?;
Ok((session, answer))
});
ticker.stop();
started?
} else {
let mut session = Session::start(request.clone(), delivery.clone())?;
let answer = session.changed_report(SystemTime::now(), format, render)?;
(session, answer)
};
Ok((session, answer.expect("a session's first answer is always given")))
}
#[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: &mut 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 Some(report) = session.changed_report(generated_at, format, render)? else {
return Ok(());
};
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");
}
#[cfg(feature = "watch")]
#[test]
fn a_watch_start_draws_through_its_first_answer_and_stops_before_writing_it() {
let root = wide_tree();
let command = parse(&[
"fdu",
"--watch",
"--view",
"files",
"--cache",
"off",
"--color",
"never",
"--progress",
"always",
root.path().to_str().expect("Unicode"),
]);
let request = command.request(root.path(), SystemTime::now()).expect("request");
let delivery = Delivery {
cache: CachePolicy::Off,
stale_ok: false,
cache_path: None,
workers: fdu_core::query::Workers::default(),
batch_size: fdu_core::ScanConfig::default().batch_size,
order: fdu_core::ScanOrder::default(),
watch: command.watch_delivery().expect("watch delivery"),
accept_partial: false,
};
let render = report_format::RenderOptions { color: false, bar_size: 0 };
let mut texts = Vec::new();
for _ in 0..5 {
let err = SharedBuffer::default();
let plan = command.progress_plan(&interactive_terminal(), &request);
assert!(plan.draw, "an interactive watch start draws");
let (session, answer) = Cli::start_watch(
&request,
&delivery,
report_format::Format::Text,
render,
Some((plan, drawing_io(&err))),
)
.expect("a watch start");
drop(session);
let text = err.text();
assert!(!answer.sections.is_empty(), "the start built the first answer");
if drawn_frames(&text) >= 1 {
assert!(
text.ends_with(ERASE_LINE),
"the line is erased once the answer exists:\n{text:?}"
);
}
if text.contains("Summarizing") {
return;
}
texts.push(text);
}
panic!("no frame showed the first answer's build in five runs: {texts:?}");
}
#[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:?}");
}
}