Skip to main content

f00_cli/
run.rs

1use std::io::{self, IsTerminal, Write};
2use std::path::PathBuf;
3use std::time::Instant;
4
5use anyhow::{Context, Result};
6use f00_compat::{apply_gnu_list_options, apply_gnu_output, parse_format_word, parse_sort_word};
7#[cfg(feature = "archives")]
8use f00_core::list_path;
9use f00_core::{
10    list_paths_with_errors, BlockSize, CliSymlinkMode, Config, ControlChars, HyperlinkWhen,
11    IndicatorStyle, ListOptions, ListOutcome, ListTiming, OutputMode, QuotingStyle, SortBy,
12    TimeField, TimeStyle,
13};
14use f00_format::format_listings;
15
16use crate::cli::Args;
17use crate::config::{load_user_config, resolve_args};
18
19/// Detect terminal width; fall back to 80.
20fn terminal_width() -> usize {
21    std::env::var("COLUMNS")
22        .ok()
23        .and_then(|s| s.parse().ok())
24        .filter(|&n: &usize| n > 0)
25        .or_else(|| terminal_size::terminal_size().map(|(terminal_size::Width(w), _)| w as usize))
26        .unwrap_or(80)
27}
28
29fn resolve_sort(args: &Args) -> SortBy {
30    if let Some(ref word) = args.sort {
31        if let Some(s) = parse_sort_word(word) {
32            return s;
33        }
34    }
35    if args.sort_none || args.unsorted_all {
36        SortBy::None
37    } else if args.sort_version {
38        SortBy::Version
39    } else if args.sort_time || args.access_time || args.change_time {
40        SortBy::Time
41    } else if args.sort_size {
42        SortBy::Size
43    } else if args.sort_extension {
44        SortBy::Extension
45    } else {
46        SortBy::Name
47    }
48}
49
50fn resolve_time_field(args: &Args) -> TimeField {
51    if let Some(ref word) = args.time {
52        return match word.to_ascii_lowercase().as_str() {
53            "atime" | "access" | "use" => TimeField::Accessed,
54            "ctime" | "status" | "change" => TimeField::Changed,
55            "birth" | "creation" => TimeField::Birth,
56            _ => TimeField::Modified,
57        };
58    }
59    if args.access_time {
60        TimeField::Accessed
61    } else if args.change_time {
62        TimeField::Changed
63    } else {
64        TimeField::Modified
65    }
66}
67
68fn resolve_output(args: &Args) -> OutputMode {
69    if let Some(ref word) = args.format {
70        if let Some(m) = parse_format_word(word) {
71            return m;
72        }
73    }
74    if args.csv {
75        OutputMode::Csv
76    } else if args.tsv {
77        OutputMode::Tsv
78    } else if args.json || args.json_full {
79        OutputMode::Json
80    } else if args.tree {
81        OutputMode::Tree
82    } else if args.long
83        || args.no_owner
84        || args.no_group_long
85        || args.numeric_uid_gid
86        || args.author
87    {
88        // -g/-o/-n/--author imply long format in GNU ls
89        OutputMode::Long
90    } else if args.one_per_line || args.zero {
91        // --zero implies -1 in GNU ls
92        OutputMode::OnePerLine
93    } else if args.commas {
94        OutputMode::Commas
95    } else if args.across {
96        OutputMode::Across
97    } else if args.columns {
98        OutputMode::Columns
99    } else {
100        OutputMode::Default
101    }
102}
103
104fn resolve_indicator(args: &Args, is_tty: bool) -> IndicatorStyle {
105    if let Some(ref word) = args.indicator_style {
106        if let Some(s) = IndicatorStyle::parse(word) {
107            return s;
108        }
109    }
110    // GNU: `-F` / `--classify[=WHEN]` — WHEN uses the same vocabulary as `--color`.
111    if let Some(when) = args.classify {
112        let when = f00_core::ColorWhen::from(when);
113        if when.enabled(is_tty) {
114            return IndicatorStyle::Classify;
115        }
116        // `--classify=never` or auto on non-TTY: do not classify (still honor -p / --file-type).
117    }
118    if args.file_type {
119        IndicatorStyle::FileType
120    } else if args.indicator_slash {
121        IndicatorStyle::Slash
122    } else {
123        IndicatorStyle::None
124    }
125}
126
127fn resolve_quoting(args: &Args) -> QuotingStyle {
128    if args.literal {
129        return QuotingStyle::Literal;
130    }
131    if args.escape {
132        return QuotingStyle::Escape;
133    }
134    if args.quote_name {
135        return QuotingStyle::C;
136    }
137    if let Some(ref word) = args.quoting_style {
138        if let Some(s) = QuotingStyle::parse(word) {
139            return s;
140        }
141    }
142    QuotingStyle::from_env().unwrap_or(QuotingStyle::Literal)
143}
144
145fn resolve_control_chars(args: &Args) -> ControlChars {
146    if args.hide_control_chars {
147        ControlChars::Hide
148    } else if args.show_control_chars {
149        ControlChars::Show
150    } else {
151        ControlChars::Auto
152    }
153}
154
155fn resolve_time_style(args: &Args) -> TimeStyle {
156    if args.full_time {
157        return TimeStyle::FullIso;
158    }
159    if let Some(ref s) = args.time_style {
160        if let Some(ts) = TimeStyle::parse(s) {
161            return ts;
162        }
163    }
164    TimeStyle::from_env().unwrap_or(TimeStyle::Locale)
165}
166
167fn resolve_block_size(args: &Args) -> BlockSize {
168    if let Some(ref s) = args.block_size {
169        if let Some(bs) = BlockSize::parse(s) {
170            return bs;
171        }
172    }
173    if args.human_readable {
174        BlockSize::HumanBinary
175    } else if args.si {
176        BlockSize::HumanSi
177    } else {
178        BlockSize::Bytes(1)
179    }
180}
181
182fn resolve_hyperlink(args: &Args) -> HyperlinkWhen {
183    match args.hyperlink {
184        None => HyperlinkWhen::Never,
185        Some(crate::cli::ColorArg::Auto) => HyperlinkWhen::Auto,
186        Some(crate::cli::ColorArg::Always) => HyperlinkWhen::Always,
187        Some(crate::cli::ColorArg::Never) => HyperlinkWhen::Never,
188    }
189}
190
191fn resolve_cli_symlink(args: &Args) -> CliSymlinkMode {
192    if args.dereference {
193        // -L supersedes -H
194        return CliSymlinkMode::Never; // handled via follow_links
195    }
196    if args.dereference_command_line {
197        CliSymlinkMode::Always
198    } else if args.dereference_command_line_symlink_to_dir {
199        CliSymlinkMode::DirOnly
200    } else {
201        CliSymlinkMode::Never
202    }
203}
204
205fn resolve_width(args: &Args) -> usize {
206    if let Some(w) = args.width {
207        return w;
208    }
209    terminal_width()
210}
211
212pub fn build_config(args: &Args) -> Config {
213    let is_stdout_tty = io::stdout().is_terminal();
214    let sort_by = resolve_sort(args);
215    let mut output = resolve_output(args);
216    output = apply_gnu_output(output, is_stdout_tty, args.gnu);
217
218    let mut all = args.all || args.unsorted_all;
219    let mut almost_all = args.almost_all;
220
221    let mut list = ListOptions {
222        all,
223        almost_all: almost_all || all,
224        sort_by,
225        reverse: args.reverse,
226        // Honor explicit `--group-directories-first` even under `--gnu` (GNU does).
227        dirs_first: args.dirs_first,
228        recursive: (args.recursive || args.tree) && !args.directory,
229        max_depth: args.max_depth,
230        gnu_mode: args.gnu,
231        follow_links: args.dereference,
232        directory: args.directory,
233        ignore_backups: args.ignore_backups,
234        ignore_patterns: args.ignore.clone(),
235        hide_patterns: args.hide.clone(),
236        use_ignore_files: args.ignore_files && !args.no_ignore && !args.gnu,
237        list_archives: args.archive && !args.gnu && !args.directory,
238        time_field: resolve_time_field(args),
239        cli_symlink: resolve_cli_symlink(args),
240        // `--threads 1` forces serial; `0` = auto rayon; `N>1` = fixed pool.
241        parallel: args.threads != 1,
242        threads: args.threads,
243        collect_timing: args.profile,
244        // Filled after we know output mode (long / -Z need expensive fields).
245        resolve_owner_group: false,
246        read_selinux: false,
247        linux_statx: true,
248        io_uring: cfg!(feature = "io-uring"),
249        // Adjusted below for `--tree` (headers not needed).
250        emit_dir_headers: true,
251        // Flipped below once output / color / classify are known.
252        full_metadata: true,
253    };
254
255    apply_gnu_list_options(&mut list, args.gnu);
256
257    // Classic ls: `-a` includes `.`/`..`; almost_all alone does not.
258    if all {
259        list.all = true;
260        list.almost_all = false;
261    } else if almost_all {
262        list.almost_all = true;
263        list.all = false;
264    }
265
266    // Strict GNU / -f: no icons, no git decorations.
267    let icons = if args.gnu || args.unsorted_all {
268        false
269    } else {
270        f00_core::IconsWhen::from(args.icons).enabled(is_stdout_tty)
271    };
272    let show_git = args.git && !args.gnu && !args.unsorted_all;
273
274    // -g implies long without owner; -o implies long without group.
275    let show_owner = !args.no_owner;
276    let show_group = !(args.no_group || args.no_group_long);
277
278    if args.full_time && !matches!(output, OutputMode::Long) {
279        output = OutputMode::Long;
280    }
281    // -Z alone often implies long in some ls builds; we show context in all modes.
282    if args.dired && !matches!(output, OutputMode::Long | OutputMode::OnePerLine) {
283        // dired is most useful with long; keep chosen mode otherwise.
284    }
285
286    // Expensive metadata only when the presentation needs it.
287    // JSON/CSV/TSV are machine dumps: resolve owner/group names unless `-n`.
288    let machine = matches!(output, OutputMode::Json | OutputMode::Csv | OutputMode::Tsv);
289    let needs_names = (matches!(output, OutputMode::Long)
290        && ((show_owner && !args.numeric_uid_gid) || (show_group && !args.numeric_uid_gid)))
291        || (machine && !args.numeric_uid_gid);
292    list.resolve_owner_group = needs_names || args.author || args.json_full;
293    // Full JSON always tries SELinux context when present; `-Z` still controls display elsewhere.
294    list.read_selinux = args.context || args.json_full;
295    list.linux_statx = true;
296    list.io_uring = args.io_uring && cfg!(feature = "io-uring");
297    // Tree does not use section headers; skip them for less work and cleaner depths.
298    list.emit_dir_headers = !matches!(output, OutputMode::Tree);
299
300    // Full metadata (statx) only when something actually needs size/mode/times.
301    // Short name listings with color/icons off can use readdir d_type only.
302    let color_on = if args.zero {
303        false
304    } else {
305        f00_core::ColorWhen::from(args.color).enabled(is_stdout_tty)
306    };
307    let indicator = resolve_indicator(args, is_stdout_tty);
308    let needs_mode = color_on
309        || !matches!(indicator, f00_core::IndicatorStyle::None)
310        || args.classify.is_some()
311        || args.file_type
312        || args.indicator_slash;
313    let needs_size_or_time = matches!(sort_by, f00_core::SortBy::Size | f00_core::SortBy::Time)
314        || matches!(
315            output,
316            OutputMode::Long | OutputMode::Json | OutputMode::Csv | OutputMode::Tsv
317        )
318        || args.size_blocks
319        || args.inode
320        || args.human_readable
321        || args.si;
322    list.full_metadata = list.resolve_owner_group
323        || list.read_selinux
324        || needs_mode
325        || needs_size_or_time
326        || list.follow_links
327        || args.recursive
328        || args.tree;
329
330    let _ = (&mut all, &mut almost_all);
331
332    // --zero disables color and hyperlinks in GNU ls.
333    let color = if args.zero {
334        f00_core::ColorWhen::Never
335    } else {
336        args.color.into()
337    };
338
339    let mut hyperlink = resolve_hyperlink(args);
340    if args.zero {
341        hyperlink = HyperlinkWhen::Never;
342    }
343
344    Config {
345        list,
346        output,
347        color,
348        human_sizes: args.human_readable || args.si,
349        si_sizes: args.si,
350        icons,
351        classify: matches!(indicator, IndicatorStyle::Classify),
352        indicator,
353        terminal_width: resolve_width(args),
354        is_stdout_tty,
355        show_owner,
356        show_group,
357        numeric_uid_gid: args.numeric_uid_gid,
358        show_inode: args.inode,
359        show_blocks: args.size_blocks,
360        full_time: args.full_time,
361        show_git,
362        quoting_style: resolve_quoting(args),
363        control_chars: resolve_control_chars(args),
364        show_author: args.author,
365        block_size: resolve_block_size(args),
366        kibibytes: args.kibibytes,
367        tabsize: args.tabsize.unwrap_or(8),
368        hyperlink,
369        show_context: args.context,
370        zero: args.zero,
371        dired: args.dired,
372        time_style: resolve_time_style(args),
373        // Per-listing override in format_listings for file operands.
374        emit_block_total: true,
375        json_full: args.json_full,
376    }
377}
378
379/// Prepare args: load config, apply argv0 / env / non-TTY auto-GNU merges.
380pub fn prepare_args(mut args: Args, as_ls: bool) -> Result<Args> {
381    let file = load_user_config(args.config.as_deref())?;
382    resolve_args(&mut args, file.as_ref(), as_ls);
383    // Strict / auto GNU disables decorations. Config may have set git=true;
384    // build_config also gates on `args.gnu`. Force git=false + icons never.
385    if args.gnu {
386        args.git = false;
387        args.icons = crate::cli::IconsArg::Never;
388        // Do **not** clear `dirs_first` here: `--group-directories-first` must
389        // still work in GNU mode (coreutils accepts it). Default stays off.
390    }
391    Ok(args)
392}
393
394/// Run the lister. Returns a GNU-aligned exit code: 0 / 1 / 2.
395pub fn run(args: Args) -> Result<i32> {
396    run_with_argv0(args, false)
397}
398
399/// Run with explicit argv0-as-ls mode (for tests / main).
400pub fn run_with_argv0(args: Args, as_ls: bool) -> Result<i32> {
401    let args = prepare_args(args, as_ls)?;
402
403    // Interactive browser: prefer the separate `f00-tui` binary. Optional
404    // embed via `--features tui` keeps `f00 --browse` for single-binary installs.
405    if args.browse {
406        #[cfg(feature = "tui")]
407        {
408            let start = args
409                .paths
410                .first()
411                .map(PathBuf::as_path)
412                .unwrap_or_else(|| std::path::Path::new("."));
413            let is_tty = io::stdout().is_terminal();
414            let icons = !args.gnu && f00_core::IconsWhen::from(args.icons).enabled(is_tty);
415            let code = f00_tui::run_browser(
416                start,
417                f00_tui::BrowserOptions {
418                    show_hidden: args.almost_all || args.all,
419                    icons,
420                    git: args.git && !args.gnu,
421                },
422            )?;
423            return Ok(code);
424        }
425        #[cfg(not(feature = "tui"))]
426        {
427            anyhow::bail!(
428                "interactive browser is the separate `f00-tui` binary \
429                 (install from the same release, or: cargo install f00-tui). \
430                 Embed with: cargo build -p f00 --features tui"
431            );
432        }
433    }
434
435    let config = build_config(&args);
436    let paths: Vec<PathBuf> = if args.paths.is_empty() {
437        vec![PathBuf::from(".")]
438    } else {
439        args.paths.clone()
440    };
441
442    let t_total = Instant::now();
443    let outcome = list_paths_with_archives(&paths, &config.list);
444    let core_timing = outcome.total_timing();
445
446    for err in &outcome.path_errors {
447        eprintln!("f00: {err}");
448    }
449
450    let exit_code = outcome.exit_code();
451    #[cfg(feature = "git")]
452    let listings = {
453        let mut listings = outcome.listings;
454        if config.show_git && args.git {
455            f00_git::annotate_listings(&mut listings);
456        }
457        #[cfg(feature = "plugins")]
458        {
459            crate::plugins_cmd::decorate_listings(listings)
460        }
461        #[cfg(not(feature = "plugins"))]
462        {
463            listings
464        }
465    };
466    #[cfg(all(not(feature = "git"), feature = "plugins"))]
467    let listings = crate::plugins_cmd::decorate_listings(outcome.listings);
468    #[cfg(all(not(feature = "git"), not(feature = "plugins")))]
469    let listings = outcome.listings;
470
471    let mut format_ms = 0u128;
472    if !listings.is_empty() {
473        let t_fmt = Instant::now();
474        let rendered = format_listings(&listings, &config).map_err(|e| anyhow::anyhow!(e))?;
475        format_ms = t_fmt.elapsed().as_millis();
476
477        let mut stdout = io::stdout().lock();
478        stdout
479            .write_all(rendered.as_bytes())
480            .context("writing output")?;
481    } else if exit_code == 0 {
482        eprintln!("f00: no listings produced");
483        return Ok(2);
484    }
485
486    if args.profile {
487        print_profile(&core_timing, format_ms, t_total.elapsed().as_millis());
488    }
489
490    Ok(exit_code)
491}
492
493fn print_profile(core: &ListTiming, format_ms: u128, total_ms: u128) {
494    eprintln!(
495        "f00 profile: readdir_ms={} stat_ms={} sort_ms={} format_ms={} total_ms={}",
496        core.readdir_ms, core.stat_ms, core.sort_ms, format_ms, total_ms
497    );
498}
499
500/// Like [`list_paths_with_errors`], but expands zip/tar when `list_archives` is set.
501fn list_paths_with_archives(paths: &[PathBuf], opts: &ListOptions) -> ListOutcome {
502    #[cfg(feature = "archives")]
503    {
504        if opts.list_archives {
505            let mut listings = Vec::new();
506            let mut path_errors = Vec::new();
507            let mut minor = 0usize;
508            for p in paths {
509                if f00_archive::is_archive(p) {
510                    match f00_archive::list_archive_as_listing(p) {
511                        Ok(l) => {
512                            minor += l.minor_errors;
513                            listings.push(l);
514                        }
515                        Err(e) => path_errors.push(f00_core::Error::Io(std::io::Error::other(
516                            format!("{}: {e}", p.display()),
517                        ))),
518                    }
519                } else {
520                    match list_path(p, opts) {
521                        Ok(l) => {
522                            minor += l.minor_errors;
523                            listings.push(l);
524                        }
525                        Err(e) => path_errors.push(e),
526                    }
527                }
528            }
529            return ListOutcome {
530                listings,
531                path_errors,
532                minor_errors: minor,
533            };
534        }
535    }
536    let _ = opts.list_archives;
537    list_paths_with_errors(paths, opts)
538}