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(feature = "watch")]
use fdu_core::query::parse_when;
use fdu_core::query::{
AxisNames, Delivery, IgnoredEntries, ReadSpec, ReportSource, Request, RequestError,
RequestSpec, SizeMetric, ViewSpec, 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};
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__=";
const STYLE_HEADING: AnsiStyle = AnsiColor::Cyan.on_default().bold();
const STYLE_WARNING: AnsiStyle = AnsiColor::Yellow.on_default().bold();
pub(crate) const STYLE_ERROR: AnsiStyle = AnsiColor::Red.on_default().bold();
const STYLE_CAUSE: AnsiStyle = AnsiStyle::new().dimmed();
const STYLE_PERFORMANCE: AnsiStyle = AnsiColor::BrightBlack.on_default();
#[cfg(feature = "watch")]
const STYLE_WATCH_RULE: AnsiStyle = AnsiColor::BrightBlack.on_default();
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"Examples:
fdu . directory sizes (metadata only)
fdu . --exclude-ignored 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 more commands, cache behavior, and the full usage guide.";
macro_rules! docs_guide {
($watch_composition:literal, $mode_flags:literal) => {
concat!(
r"fdu — a fast, incremental file roll-up engine.
START HERE
A report requires a PATH. Use `.` for the current directory.
fdu . directory sizes (the default)
fdu . --exclude-ignored 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 2, with at most 10 children per directory. Hidden and ignored entries
are included; .gitignore is read to label ignored 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 languages,
words selects documents, and lines or a multi-analyzer set selects families.
Name --view for a different projection; it always wins.
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=largest --limit=100 the 100 largest files
fdu PATH --view=files --kind=file --modified-since=1h files changed lately
fdu PATH --view=files --only-ignored --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
--sort and --limit still override them. files alone is complete: every
matching entry, in name order. full keeps its bounded digest, 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 directory roll-ups, depth 2, ten children per directory.
Files contribute to totals without new leaf rows. --depth all expands levels;
--limit all removes row caps. Flat list limits apply to the whole result.
--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
Content --analyze none|lines|code|words|all which file bodies are read
Selection --include, --exclude, --depth, --limit which entries are considered
--exclude-ignored, --only-ignored
View list,summary,tree,families,types,extensions,languages,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. lines comes with any analyzer, free.
languages is metadata-only by default; code adds standard LOC.
documents requires any enabled analyzer.
Analysis streams every eligible file through EOF; files are never size-truncated.
--analysis-workers bounds concurrency.
--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.
--cache=only never opens source files and fails if requested content is absent.
CACHE BEHAVIOR
No ordinary view requires a preexisting cache. Metadata-only one-shot reports
still inspect current metadata; under --cache=auto they skip loading a snapshot
that cannot make that work cheaper, though a complete indexed scan may write one.
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.
--cache=only is different: it does no filesystem verification, requires a
compatible snapshot and content sidecar for the requested analysis, and labels
its answer stale. --cache=off neither reads nor writes fdu's cache.
IGNORE RULES
Every report reads each .gitignore in the tree, and summary, tree, and extension
rows end with how much of their size its rules ignore, as `(128 B ignored)`.
A directory a rule ignores is ignored with everything below it. Unignored is not
tracked: .git is unignored unless a rule names it. --exclude-ignored and
--only-ignored select one side after the scan; they do not prune metadata work
or content 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,
and matching 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,
ignored shares under that directory do not.
OUTPUT AND AUTOMATION
Every machine report uses fdu.report/7; watch changes use fdu.stream/2.
Cache status is its own document in every machine format: fdu.cache/2.
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.
One-shot text reports end with a gray performance line; machine formats omit it.
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; 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, --analysis-workers"
);
#[cfg(not(feature = "watch"))]
const DOCS: &str = docs_guide!("", "--cache, --analysis-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, action = ArgAction::SetTrue, help_heading = "SELECTION")]
pub exclude_ignored: bool,
#[arg(long, action = ArgAction::SetTrue, help_heading = "SELECTION")]
pub only_ignored: bool,
#[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 = "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, 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 = "POLICY", default_value = "auto", help_heading = "EXECUTION")]
pub cache: String,
#[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);
}
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 delivery = Delivery {
cache: self.parse_cache_policy().map_err(|error| usage(&error))?,
cache_path: default_cache_path(path),
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 indicator = progress_plan.draw.then_some((progress_plan, progress_io));
return Self::run_watch(out, diagnostic, format, &request, &delivery, color, indicator);
}
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 rendered_text =
matches!(format, report_format::Format::Text | report_format::Format::Tree)
.then(|| report_format::render(&report, format, color));
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(&report, format, color, out)
.and_then(|()| out.flush())
.map_err(Into::into),
),
};
if let Err(error) = pending_save.join() {
let _ = writeln!(
diagnostic,
"{}",
paint(&format!("warning: {error}"), STYLE_WARNING, stderr_is_terminal)
);
}
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)?;
}
writeln!(
out,
"{}",
paint(
&performance_footer(
performance,
&report.ignore_rules,
report_started.elapsed(),
request.query.selection.size,
),
STYLE_PERFORMANCE,
color,
)
)?;
for note in
display_notes(&request.query.views, request.basis.content, performance.bytes_read)
{
writeln!(out, "{}", paint(¬e, STYLE_PERFORMANCE, color))?;
}
}
if matches!(format, report_format::Format::Paths | report_format::Format::Long) {
for note in report_format::flat_diagnostics(&report) {
writeln!(diagnostic, "{note}")?;
}
}
if matches!(format, report_format::Format::Text | report_format::Format::Tree)
&& !report.status.complete
{
let color =
ColorContext::from_environment(self.color, false, false, stderr_is_terminal)
.enabled();
for warning in status_warnings(&report.status) {
let _ = writeln!(diagnostic, "{}", paint(&warning, STYLE_WARNING, color));
}
}
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 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: 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,
color: bool,
indicator: Option<(ProgressPlan, ProgressIo)>,
) -> anyhow::Result<RunOutcome> {
use fdu_core::query::ViewSpec;
use fdu_core::watch_session::Session;
let interval = delivery
.watch
.expect("run() builds a watch delivery before it takes the watch path")
.interval;
let mut session = match indicator {
Some((plan, progress_io)) => {
let progress = Progress::new();
let mut ticker = Ticker::start(plan, progress.clone(), Instant::now(), progress_io);
let session =
Session::start_with_progress(request.clone(), delivery.clone(), &progress);
ticker.stop();
session?
}
None => Session::start(request.clone(), delivery.clone())?,
};
Self::persist_live(&mut session, diagnostic, color);
let streams_changes = request.query.views.contains(&ViewSpec::Files)
&& !matches!(
format,
report_format::Format::Tree
| report_format::Format::Paths
| report_format::Format::Long
);
let has_aggregates =
!streams_changes || request.query.views.iter().any(|view| *view != ViewSpec::Files);
if format == report_format::Format::Yaml {
write!(out, "{}", report_format::document_start(format))?;
}
let initial = session.report(SystemTime::now())?;
report_format::write(&initial, format, color, out)?;
if matches!(format, report_format::Format::Paths | report_format::Format::Long) {
for note in report_format::flat_diagnostics(&initial) {
writeln!(diagnostic, "{note}")?;
}
}
out.flush()?;
let mut dirty_since_render = false;
let mut last_render = SystemTime::now();
loop {
let Some(batch) = session.next_batch(interval)? else {
if has_aggregates && dirty_since_render {
Self::render_live(out, diagnostic, &session, format, color)?;
dirty_since_render = false;
last_render = SystemTime::now();
}
Self::persist_live(&mut session, diagnostic, color);
continue;
};
Self::render_watch_changes(out, diagnostic, &batch.changes, format, streams_changes)?;
out.flush()?;
dirty_since_render |= batch.dirty;
let elapsed = last_render.elapsed().unwrap_or_default();
if has_aggregates && dirty_since_render && elapsed >= interval {
Self::render_live(out, diagnostic, &session, format, color)?;
dirty_since_render = false;
last_render = SystemTime::now();
}
Self::persist_live(&mut session, diagnostic, color);
}
}
#[cfg(feature = "watch")]
fn persist_live(
session: &mut fdu_core::watch_session::Session,
diagnostic: &mut dyn Write,
color: bool,
) {
if let fdu_core::watch_session::SaveOutcome::Failed(error) =
session.persist_due(Instant::now())
{
let _ = writeln!(
diagnostic,
"{}",
paint(&format!("warning: {error}"), STYLE_WARNING, color)
);
}
}
#[cfg(feature = "watch")]
fn render_watch_changes(
out: &mut dyn Write,
diagnostic: &mut dyn Write,
changes: &[fdu_core::watch_session::Change],
format: report_format::Format,
streams_changes: bool,
) -> std::io::Result<()> {
for change in changes {
if change.kind == fdu_core::watch_session::ChangeKind::Invalidate
&& matches!(format, report_format::Format::Paths | report_format::Format::Long)
{
writeln!(diagnostic, "{}", report_format::render_change(change, format))?;
} else if streams_changes
|| change.kind == fdu_core::watch_session::ChangeKind::Invalidate
{
writeln!(out, "{}", report_format::render_change(change, format))?;
}
}
Ok(())
}
#[cfg(feature = "watch")]
fn render_live(
out: &mut dyn Write,
diagnostic: &mut dyn Write,
session: &fdu_core::watch_session::Session,
format: report_format::Format,
color: bool,
) -> anyhow::Result<()> {
let generated_at = SystemTime::now();
let report = session.report(generated_at)?;
if matches!(format, report_format::Format::Text | report_format::Format::Tree) {
writeln!(
out,
"\n{}",
paint(&report_format::watch_rule(generated_at), STYLE_WATCH_RULE, color)
)?;
} else if format == report_format::Format::Yaml {
write!(out, "{}", report_format::document_start(format))?;
}
report_format::write(&report, format, color, out)?;
if matches!(format, report_format::Format::Paths | report_format::Format::Long) {
for note in report_format::flat_diagnostics(&report) {
writeln!(diagnostic, "{note}")?;
}
}
out.flush()?;
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) -> anyhow::Result<RunOutcome> {
let root = self.path.as_deref().unwrap_or_else(|| Path::new("."));
let cache_dir = fdu_core::default_cache_path(root)
.and_then(|path| path.parent().map(Path::to_path_buf));
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: {} {}.",
removed.snapshots,
plural(removed.snapshots, "snapshot", "snapshots")
)?;
}
if removed.leftovers > 0 {
writeln!(
out,
"Also reclaimed: {} {} fdu left behind.",
removed.leftovers,
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: {left} {}; fdu --cache-status=all lists {}.",
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} staging {} another fdu may still be \
writing.",
plural(staging, "file", "files")
)?;
}
}
(CacheScope::Root, _) => {
let path = fdu_core::default_cache_path(root);
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 fdu_core::default_cache_path(root) {
Some(path) => vec![fdu_core::cache_status(&path)?],
None => Vec::new(),
},
};
self.write_cache_status(out, &statuses, scope)?;
}
Ok(RunOutcome::Complete)
}
fn write_cache_status(
&self,
out: &mut dyn Write,
statuses: &[fdu_core::CacheStatus],
scope: CacheScope,
) -> anyhow::Result<()> {
let format = self.parse_format().map_err(|e| usage(&e))?;
writeln!(out, "{}", report_format::render_cache_status(statuses, scope, format))?;
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_selection()?,
depth: self.depth.as_deref(),
limit: self.limit.as_deref(),
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 ignored_selection(&self) -> anyhow::Result<Option<&'static str>> {
match (self.exclude_ignored, self.only_ignored) {
(false, false) => Ok(None),
(true, false) => Ok(Some(IgnoredEntries::Exclude.label())),
(false, true) => Ok(Some(IgnoredEntries::Only.label())),
(true, true) => Err(usage(&anyhow::anyhow!(
"--exclude-ignored and --only-ignored select opposite entries; use one of them"
))),
}
}
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,
) -> 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),
report_format::human_bytes(performance.cached_bytes)
)
};
let read_rate = if performance.bytes_read == 0 || performance.analysis_ns == 0 {
String::new()
} else {
format!(
" at {}/s",
report_format::human_bytes(rate_per_second(
performance.bytes_read,
performance.analysis_ns,
))
)
};
let rules = match ignore_rules {
ControlCoverage::NotObserved => "no ignore rules".to_string(),
ControlCoverage::Observed(observed) => {
let refused = if observed.refused > 0 {
format!(", {} refused", human_count(observed.refused))
} else {
String::new()
};
format!(
"ignore rules {} {}{refused}",
human_count(observed.applied),
plural_u64(observed.applied, "file", "files")
)
}
};
format!(
"Performance: walked {} {} / {}; {rules}; content read {}{}; analysis {fresh}, {cached}; {}; total {}",
human_count(performance.walked_files),
plural_u64(performance.walked_files, "file", "files"),
report_format::human_bytes(walked_bytes),
report_format::human_bytes(performance.bytes_read),
read_rate,
performance_source(performance.source),
human_duration(total),
)
}
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 {
let rate = rate_per_second(units, elapsed_ns);
match rate {
0..=999 => human_count(rate),
1_000..=999_999 => format!("{}k", scaled_decimal(u128::from(rate), 1_000, 1)),
1_000_000..=999_999_999 => {
format!("{}M", scaled_decimal(u128::from(rate), 1_000_000, 1))
}
_ => format!("{}G", scaled_decimal(u128::from(rate), 1_000_000_000, 1)),
}
}
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))
}
}
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 }
}
const fn view_displays_analysis(view: ViewSpec) -> bool {
matches!(view, ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents)
}
#[cfg(test)]
#[derive(Debug)]
struct ResolvedViews {
selected: Vec<ViewSpec>,
omitted: Vec<ViewSpec>,
}
#[cfg(test)]
fn resolve_views(spec: Option<&str>, profile: AnalysisSet) -> anyhow::Result<ResolvedViews> {
let (selected, omitted) =
ViewSpec::resolve(spec, profile, "--view").map_err(|message| anyhow::anyhow!(message))?;
Ok(ResolvedViews { selected, omitted })
}
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!("warning: {}: {}", path.display(), issue.message)
}
_ => format!("warning: {}", issue.message),
})
.collect();
if status.errors_omitted > 0 {
warnings.push(format!(
"warning: {} more {} omitted; details are kept for the first {}",
human_count(status.errors_omitted),
if status.errors_omitted == 1 { "error" } else { "errors" },
fdu_core::MAX_RETAINED_ISSUES,
));
}
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(':' | ' ' | '"' | '\'' | '`' | ')' | ','))
})
}
fn display_notes(views: &[ViewSpec], profile: AnalysisSet, bytes_read: u64) -> Vec<String> {
if profile.is_enabled() && !views.iter().any(|view| view_displays_analysis(*view)) {
return vec![format!(
"note: --analyze {} read {}; no selected view displays content metrics — try --view families, languages, or full",
profile.labels().join(","),
report_format::human_bytes(bytes_read),
)];
}
Vec::new()
}
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,
cli.machine_format(),
cli.skill,
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 {
if color { format!("{style}{text}{style:#}") } else { text.to_string() }
}
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::{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,
[
"warning: docs/a.md: Permission denied (os error 13)",
"warning: I/O error at /abs/src: Permission denied (os error 13)",
"warning: content analysis results became stale",
"warning: 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("warning: 1 more error omitted; details are kept for the first 64")
);
let many = status_warnings(&status(Vec::new(), 1_234));
assert_eq!(many, ["warning: 1,234 more errors omitted; details are kept for the first 64"]);
}
#[test]
fn the_ignored_selection_flags_pick_one_side_and_refuse_both() {
assert_eq!(
cli().resolved_query().expect("parses").selection.ignored,
IgnoredEntries::Include
);
let exclude = Cli { exclude_ignored: true, ..cli() }.resolved_query().expect("parses");
assert_eq!(exclude.selection.ignored, IgnoredEntries::Exclude);
let only = Cli { only_ignored: true, ..cli() }.resolved_query().expect("parses");
assert_eq!(only.selection.ignored, IgnoredEntries::Only);
assert_eq!(
query_error(&Cli { exclude_ignored: true, only_ignored: true, ..cli() }),
"--exclude-ignored and --only-ignored select opposite entries; use one of them"
);
}
#[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",
"--only-ignored",
"/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: --only-ignored 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)
};
assert_eq!(
footer(&ControlCoverage::NotObserved),
"Performance: walked 7 files / 269 B; no ignore rules; content read 0 B; analysis 0 fresh, 0 cached; cold scan; total 3.0 ms"
);
assert!(
performance_footer(
performance,
&ControlCoverage::NotObserved,
Duration::from_millis(3),
SizeMetric::Allocated,
)
.starts_with("Performance: walked 7 files / 28 KiB; ")
);
let observed = |applied, refusals: Vec<RefusedControl>| {
ControlCoverage::Observed(ControlObservation {
limits: fdu_core::ControlLimits::default(),
applied,
refused: u64::try_from(refusals.len()).expect("a handful"),
refusals,
})
};
assert!(footer(&observed(1, Vec::new())).contains("; ignore rules 1 file; "));
let refused = RefusedControl {
path: PathBuf::from(".gitignore"),
reason: ControlRefusalReason::LineLimit,
};
assert!(
footer(&observed(0, vec![refused])).contains("; ignore rules 0 files, 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,
exclude_ignored: false,
only_ignored: false,
depth: None,
limit: None,
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,
cache: "off".to_string(),
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_undocumented_readonly_cache_alias_is_rejected() {
assert_eq!(
Cli { cache: "read-only".to_string(), ..cli() }
.parse_cache_policy()
.expect("the canonical policy name parses"),
CachePolicy::ReadOnly
);
let error = Cli { cache: "readonly".to_string(), ..cli() }
.parse_cache_policy()
.expect_err("an unreleased alias must not become a contract");
assert_eq!(
error.to_string(),
"invalid --cache \"readonly\": expected one of auto, refresh, read-only, only, 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 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(2));
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 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 {
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::Languages),
(AnalysisSet::NONE.with_words(), ViewSpec::Documents),
(AnalysisSet::ALL, ViewSpec::Families),
];
for (profile, expected) in cases {
assert_eq!(ViewSpec::default_for(profile), expected, "default view for {profile:?}");
if profile.is_enabled() {
assert!(
view_displays_analysis(ViewSpec::default_for(profile)),
"a paid-for run must default to a view that shows what it bought"
);
}
}
}
#[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::Documents], "documents needs content");
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()).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 the_display_contract_reports_unspent_reads_from_the_cli() {
let unspent = resolve_views(Some("tree"), AnalysisSet::ALL).expect("resolve");
let notes = display_notes(&unspent.selected, AnalysisSet::ALL, 1_200);
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("no selected view displays content metrics"), "{notes:?}");
assert!(notes[0].contains("1.1 KiB"), "the note quantifies what was read: {notes:?}");
let (_, suggested) = notes[0].split_once("try --view ").expect("the note suggests views");
for view in suggested.split([',', ' ']).filter(|word| !word.is_empty() && *word != "or") {
let resolved = ViewSpec::resolve(Some(view), AnalysisSet::ALL, "--view");
assert!(resolved.is_ok(), "the note suggests --view {view}: {resolved:?}");
}
let spent = resolve_views(Some("families"), AnalysisSet::ALL).expect("resolve");
assert!(display_notes(&spent.selected, AnalysisSet::ALL, 1_200).is_empty());
let plain = resolve_views(None, AnalysisSet::NONE).expect("resolve");
assert!(display_notes(&plain.selected, AnalysisSet::NONE, 0).is_empty());
let omitted = resolve_views(Some("full"), AnalysisSet::NONE).expect("resolve");
assert!(!omitted.omitted.is_empty(), "full without analyzers must drop documents");
assert!(
display_notes(&omitted.selected, AnalysisSet::NONE, 0).is_empty(),
"the omission travels on the report now"
);
}
#[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 {
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/7\""), "{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 = Vec::new();
command
.run(&mut plain, &mut Vec::new(), false, &TerminalFacts::default(), ProgressIo::inert())
.expect("plain report");
let plain = String::from_utf8(plain).expect("plain UTF-8");
let footer =
plain.lines().find(|line| line.contains("Performance:")).expect("performance footer");
assert!(
footer.starts_with(
"Performance: walked 2 files / 8 B; ignore rules 0 files; content read 8 B at "
),
"{plain}"
);
assert!(footer.contains("2 fresh at "), "{footer}");
assert!(footer.contains("0 cached"), "{footer}");
assert!(footer.contains("cold scan; total "), "{footer}");
assert!(!footer.contains('\u{1b}'), "color-disabled output must not contain ANSI");
let mut colored = Vec::new();
Cli { color: ColorWhen::Always, ..command }
.run(
&mut colored,
&mut Vec::new(),
false,
&TerminalFacts::default(),
ProgressIo::inert(),
)
.expect("colored report");
let colored = String::from_utf8(colored).expect("colored UTF-8");
let footer = colored
.lines()
.find(|line| line.contains("Performance:"))
.expect("colored performance footer");
assert!(
footer.starts_with("\u{1b}[90mPerformance:"),
"the footer must use terminal gray when color is active: {colored:?}"
);
let note =
colored.lines().find(|line| line.contains("note: --analyze")).expect("colored note");
assert!(note.starts_with("\u{1b}[90mnote:"), "notes share the footer style: {colored:?}");
}
#[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 = Vec::new();
command
.run(
&mut out,
&mut Vec::new(),
false,
&TerminalFacts::default(),
ProgressIo::inert(),
)
.expect("summary report");
let out = String::from_utf8(out).expect("UTF-8");
let row = out.lines().next().expect("the summary row").trim_start();
let answer = row.split(" ").next().expect("the row's size");
let footer =
out.lines().find(|line| line.starts_with("Performance:")).expect("the footer");
assert!(
footer.starts_with(&format!("Performance: walked 2 files / {answer}; ")),
"--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,
);
assert_eq!(
footer,
"Performance: walked 12,345 files / 2.0 KiB; no ignore rules; content read 2.0 KiB at 1.0 KiB/s; analysis 3,000 fresh at 1.5k files/s, 2 cached / 4.0 KiB; warm revalidation; total 2.50 s"
);
}
#[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 = Vec::new();
command
.run(
&mut output,
&mut Vec::new(),
false,
&TerminalFacts::default(),
ProgressIo::inert(),
)
.expect("machine report");
let output = String::from_utf8(output).expect("machine UTF-8");
assert!(!output.contains("Performance:"), "{format}: {output}");
}
}
#[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("command -v fdu"), "the installed command comes first");
assert!(skill.contains("uvx fdu@latest "), "the fallback is the latest release");
assert!(!skill.contains("--from fdu=="), "no exact pin is left in the skill");
assert!(skill.contains("uv tool install fdu"));
assert!(skill.contains("uv tool upgrade 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 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,
}
}
fn parse(args: &[&str]) -> Cli {
Cli::try_parse_from(args).expect("the command line parses")
}
#[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}");
}
#[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);
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_writes_nothing_to_stderr() {
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("Performance:"));
assert!(err.contents().is_empty(), "{:?}", 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("Performance:"));
let warning = text.find("warning:").expect("a status warning on stderr");
assert!(
text[..warning].ends_with(ERASE_LINE),
"the erase is the last thing before the warning:\n{text:?}"
);
assert!(text[..warning].starts_with(&format!("{ERASE_LINE}⠋ ")), "{text:?}");
assert!(!text[warning..].contains(ERASE_LINE), "the line was drawn after the warning");
assert!(!text[warning..].contains('\r'), "{text:?}");
}
#[test]
fn the_line_is_erased_before_an_error() {
let root = wide_tree();
let args = [
"fdu",
"--cache",
"off",
"--color",
"never",
"--progress",
"always",
root.path().to_str().expect("Unicode"),
]
.map(OsString::from);
let (status, _, text) = run_until_drawn(|err| {
let status = run_with_io(
&args,
&mut FailingWriter,
&mut err.clone(),
false,
&interactive_terminal(),
drawing_io(err),
);
(status, Vec::new())
});
assert_eq!(status, 1, "a report that cannot be written is fatal");
let error = text.find("fdu: ").expect("the error on stderr");
assert!(
text[..error].ends_with(ERASE_LINE),
"the erase is the last thing before the error:\n{text:?}"
);
assert_eq!(&text[error..], "fdu: output failed\n");
}
#[test]
fn a_non_interactive_run_writes_nothing_extra_to_stderr() {
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("Performance:"));
assert!(
err.contents().is_empty(),
"{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:?}");
}
}