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 {
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) -> IndicatorStyle {
105    if let Some(ref word) = args.indicator_style {
106        if let Some(s) = IndicatorStyle::parse(word) {
107            return s;
108        }
109    }
110    if args.classify {
111        IndicatorStyle::Classify
112    } else if args.file_type {
113        IndicatorStyle::FileType
114    } else if args.indicator_slash {
115        IndicatorStyle::Slash
116    } else {
117        IndicatorStyle::None
118    }
119}
120
121fn resolve_quoting(args: &Args) -> QuotingStyle {
122    if args.literal {
123        return QuotingStyle::Literal;
124    }
125    if args.escape {
126        return QuotingStyle::Escape;
127    }
128    if args.quote_name {
129        return QuotingStyle::C;
130    }
131    if let Some(ref word) = args.quoting_style {
132        if let Some(s) = QuotingStyle::parse(word) {
133            return s;
134        }
135    }
136    QuotingStyle::from_env().unwrap_or(QuotingStyle::Literal)
137}
138
139fn resolve_control_chars(args: &Args) -> ControlChars {
140    if args.hide_control_chars {
141        ControlChars::Hide
142    } else if args.show_control_chars {
143        ControlChars::Show
144    } else {
145        ControlChars::Auto
146    }
147}
148
149fn resolve_time_style(args: &Args) -> TimeStyle {
150    if args.full_time {
151        return TimeStyle::FullIso;
152    }
153    if let Some(ref s) = args.time_style {
154        if let Some(ts) = TimeStyle::parse(s) {
155            return ts;
156        }
157    }
158    TimeStyle::from_env().unwrap_or(TimeStyle::Locale)
159}
160
161fn resolve_block_size(args: &Args) -> BlockSize {
162    if let Some(ref s) = args.block_size {
163        if let Some(bs) = BlockSize::parse(s) {
164            return bs;
165        }
166    }
167    if args.human_readable {
168        BlockSize::HumanBinary
169    } else if args.si {
170        BlockSize::HumanSi
171    } else {
172        BlockSize::Bytes(1)
173    }
174}
175
176fn resolve_hyperlink(args: &Args) -> HyperlinkWhen {
177    match args.hyperlink.as_deref() {
178        None => HyperlinkWhen::Never,
179        Some(s) => HyperlinkWhen::parse(s).unwrap_or(HyperlinkWhen::Always),
180    }
181}
182
183fn resolve_cli_symlink(args: &Args) -> CliSymlinkMode {
184    if args.dereference {
185        // -L supersedes -H
186        return CliSymlinkMode::Never; // handled via follow_links
187    }
188    if args.dereference_command_line {
189        CliSymlinkMode::Always
190    } else if args.dereference_command_line_symlink_to_dir {
191        CliSymlinkMode::DirOnly
192    } else {
193        CliSymlinkMode::Never
194    }
195}
196
197fn resolve_width(args: &Args) -> usize {
198    if let Some(w) = args.width {
199        return w;
200    }
201    terminal_width()
202}
203
204pub fn build_config(args: &Args) -> Config {
205    let is_stdout_tty = io::stdout().is_terminal();
206    let sort_by = resolve_sort(args);
207    let mut output = resolve_output(args);
208    output = apply_gnu_output(output, is_stdout_tty, args.gnu);
209
210    let mut all = args.all || args.unsorted_all;
211    let mut almost_all = args.almost_all;
212
213    let mut list = ListOptions {
214        all,
215        almost_all: almost_all || all,
216        sort_by,
217        reverse: args.reverse,
218        dirs_first: args.dirs_first && !args.gnu,
219        recursive: (args.recursive || args.tree) && !args.directory,
220        max_depth: args.max_depth,
221        gnu_mode: args.gnu,
222        follow_links: args.dereference,
223        directory: args.directory,
224        ignore_backups: args.ignore_backups,
225        ignore_patterns: args.ignore.clone(),
226        hide_patterns: args.hide.clone(),
227        use_ignore_files: args.ignore_files && !args.no_ignore && !args.gnu,
228        list_archives: args.archive && !args.gnu && !args.directory,
229        time_field: resolve_time_field(args),
230        cli_symlink: resolve_cli_symlink(args),
231        // `--threads 1` forces serial; `0` = auto rayon; `N>1` = fixed pool.
232        parallel: args.threads != 1,
233        threads: args.threads,
234        collect_timing: args.profile,
235        // Filled after we know output mode (long / -Z need expensive fields).
236        resolve_owner_group: false,
237        read_selinux: false,
238        linux_statx: true,
239        io_uring: cfg!(feature = "io-uring"),
240        // Adjusted below for `--tree` (headers not needed).
241        emit_dir_headers: true,
242    };
243
244    apply_gnu_list_options(&mut list, args.gnu);
245
246    // Classic ls: `-a` includes `.`/`..`; almost_all alone does not.
247    if all {
248        list.all = true;
249        list.almost_all = false;
250    } else if almost_all {
251        list.almost_all = true;
252        list.all = false;
253    }
254
255    // Strict GNU / -f: no icons, no git decorations.
256    let icons = if args.gnu || args.unsorted_all {
257        false
258    } else {
259        f00_core::IconsWhen::from(args.icons).enabled(is_stdout_tty)
260    };
261    let show_git = args.git && !args.gnu && !args.unsorted_all;
262
263    // -g implies long without owner; -o implies long without group.
264    let show_owner = !args.no_owner;
265    let show_group = !(args.no_group || args.no_group_long);
266
267    if args.full_time && !matches!(output, OutputMode::Long) {
268        output = OutputMode::Long;
269    }
270    // -Z alone often implies long in some ls builds; we show context in all modes.
271    if args.dired && !matches!(output, OutputMode::Long | OutputMode::OnePerLine) {
272        // dired is most useful with long; keep chosen mode otherwise.
273    }
274
275    // Expensive metadata only when the presentation needs it.
276    // JSON/CSV/TSV are machine dumps: resolve owner/group names unless `-n`.
277    let machine = matches!(output, OutputMode::Json | OutputMode::Csv | OutputMode::Tsv);
278    let needs_names = (matches!(output, OutputMode::Long)
279        && ((show_owner && !args.numeric_uid_gid) || (show_group && !args.numeric_uid_gid)))
280        || (machine && !args.numeric_uid_gid);
281    list.resolve_owner_group = needs_names || args.author;
282    list.read_selinux = args.context;
283    list.linux_statx = true;
284    list.io_uring = args.io_uring && cfg!(feature = "io-uring");
285    // Tree does not use section headers; skip them for less work and cleaner depths.
286    list.emit_dir_headers = !matches!(output, OutputMode::Tree);
287
288    let _ = (&mut all, &mut almost_all);
289
290    // --zero disables color and hyperlinks in GNU ls.
291    let color = if args.zero {
292        f00_core::ColorWhen::Never
293    } else {
294        args.color.into()
295    };
296
297    let mut hyperlink = resolve_hyperlink(args);
298    if args.zero {
299        hyperlink = HyperlinkWhen::Never;
300    }
301
302    Config {
303        list,
304        output,
305        color,
306        human_sizes: args.human_readable || args.si,
307        si_sizes: args.si,
308        icons,
309        classify: args.classify,
310        indicator: resolve_indicator(args),
311        terminal_width: resolve_width(args),
312        is_stdout_tty,
313        show_owner,
314        show_group,
315        numeric_uid_gid: args.numeric_uid_gid,
316        show_inode: args.inode,
317        show_blocks: args.size_blocks,
318        full_time: args.full_time,
319        show_git,
320        quoting_style: resolve_quoting(args),
321        control_chars: resolve_control_chars(args),
322        show_author: args.author,
323        block_size: resolve_block_size(args),
324        kibibytes: args.kibibytes,
325        tabsize: args.tabsize.unwrap_or(8),
326        hyperlink,
327        show_context: args.context,
328        zero: args.zero,
329        dired: args.dired,
330        time_style: resolve_time_style(args),
331    }
332}
333
334/// Prepare args: load config, apply argv0 / env merges. Returns owned Args.
335pub fn prepare_args(mut args: Args, as_ls: bool) -> Result<Args> {
336    let file = load_user_config(args.config.as_deref())?;
337    resolve_args(&mut args, file.as_ref(), as_ls);
338    // Strict GNU disables git unless user re-enables after — config may set git=true;
339    // apply_gnu at build_config handles decorations. Also force git=false when --gnu.
340    if args.gnu {
341        args.git = false;
342        args.icons = crate::cli::IconsArg::Never;
343        args.dirs_first = false;
344    }
345    Ok(args)
346}
347
348/// Run the lister. Returns a GNU-aligned exit code: 0 / 1 / 2.
349pub fn run(args: Args) -> Result<i32> {
350    run_with_argv0(args, false)
351}
352
353/// Run with explicit argv0-as-ls mode (for tests / main).
354pub fn run_with_argv0(args: Args, as_ls: bool) -> Result<i32> {
355    let args = prepare_args(args, as_ls)?;
356
357    // Interactive browser (feature-gated).
358    if args.browse {
359        #[cfg(feature = "tui")]
360        {
361            let start = args
362                .paths
363                .first()
364                .map(PathBuf::as_path)
365                .unwrap_or_else(|| std::path::Path::new("."));
366            let is_tty = io::stdout().is_terminal();
367            let icons = !args.gnu && f00_core::IconsWhen::from(args.icons).enabled(is_tty);
368            let code = f00_tui::run_browser(
369                start,
370                f00_tui::BrowserOptions {
371                    show_hidden: args.almost_all || args.all,
372                    icons,
373                    git: args.git && !args.gnu,
374                },
375            )?;
376            return Ok(code);
377        }
378        #[cfg(not(feature = "tui"))]
379        {
380            anyhow::bail!("TUI browser requires building with --features tui");
381        }
382    }
383
384    let config = build_config(&args);
385    let paths: Vec<PathBuf> = if args.paths.is_empty() {
386        vec![PathBuf::from(".")]
387    } else {
388        args.paths.clone()
389    };
390
391    let t_total = Instant::now();
392    let outcome = list_paths_with_archives(&paths, &config.list);
393    let core_timing = outcome.total_timing();
394
395    for err in &outcome.path_errors {
396        eprintln!("f00: {err}");
397    }
398
399    let exit_code = outcome.exit_code();
400    #[cfg(feature = "git")]
401    let listings = {
402        let mut listings = outcome.listings;
403        if config.show_git && args.git {
404            f00_git::annotate_listings(&mut listings);
405        }
406        #[cfg(feature = "plugins")]
407        {
408            crate::plugins_cmd::decorate_listings(listings)
409        }
410        #[cfg(not(feature = "plugins"))]
411        {
412            listings
413        }
414    };
415    #[cfg(all(not(feature = "git"), feature = "plugins"))]
416    let listings = crate::plugins_cmd::decorate_listings(outcome.listings);
417    #[cfg(all(not(feature = "git"), not(feature = "plugins")))]
418    let listings = outcome.listings;
419
420    let mut format_ms = 0u128;
421    if !listings.is_empty() {
422        let t_fmt = Instant::now();
423        let rendered = format_listings(&listings, &config).map_err(|e| anyhow::anyhow!(e))?;
424        format_ms = t_fmt.elapsed().as_millis();
425
426        let mut stdout = io::stdout().lock();
427        stdout
428            .write_all(rendered.as_bytes())
429            .context("writing output")?;
430    } else if exit_code == 0 {
431        eprintln!("f00: no listings produced");
432        return Ok(2);
433    }
434
435    if args.profile {
436        print_profile(&core_timing, format_ms, t_total.elapsed().as_millis());
437    }
438
439    Ok(exit_code)
440}
441
442fn print_profile(core: &ListTiming, format_ms: u128, total_ms: u128) {
443    eprintln!(
444        "f00 profile: readdir_ms={} stat_ms={} sort_ms={} format_ms={} total_ms={}",
445        core.readdir_ms, core.stat_ms, core.sort_ms, format_ms, total_ms
446    );
447}
448
449/// Like [`list_paths_with_errors`], but expands zip/tar when `list_archives` is set.
450fn list_paths_with_archives(paths: &[PathBuf], opts: &ListOptions) -> ListOutcome {
451    #[cfg(feature = "archives")]
452    {
453        if opts.list_archives {
454            let mut listings = Vec::new();
455            let mut path_errors = Vec::new();
456            let mut minor = 0usize;
457            for p in paths {
458                if f00_archive::is_archive(p) {
459                    match f00_archive::list_archive_as_listing(p) {
460                        Ok(l) => {
461                            minor += l.minor_errors;
462                            listings.push(l);
463                        }
464                        Err(e) => path_errors.push(f00_core::Error::Io(std::io::Error::other(
465                            format!("{}: {e}", p.display()),
466                        ))),
467                    }
468                } else {
469                    match list_path(p, opts) {
470                        Ok(l) => {
471                            minor += l.minor_errors;
472                            listings.push(l);
473                        }
474                        Err(e) => path_errors.push(e),
475                    }
476                }
477            }
478            return ListOutcome {
479                listings,
480                path_errors,
481                minor_errors: minor,
482            };
483        }
484    }
485    let _ = opts.list_archives;
486    list_paths_with_errors(paths, opts)
487}