Skip to main content

f00_format/
long.rs

1use chrono::{DateTime, Local};
2
3use f00_core::{Config, Entry, IndicatorStyle, TimeStyle};
4
5use crate::color::Colorizer;
6use crate::human::{block_display_with_unit, format_size_bytes};
7use crate::hyperlink::hyperlink_name;
8use crate::icons::icon_prefix;
9use crate::perms::{classify_suffix, format_permissions};
10use crate::quoting::display_name;
11
12/// Format a single long-listing line (no trailing newline).
13///
14/// When `dired` is enabled, `name_offsets` receives `(start, end)` byte offsets
15/// of the raw (pre-color) name within the returned line string.
16pub fn format_long_line(
17    entry: &Entry,
18    colorizer: &Colorizer,
19    config: &Config,
20    widths: &LongWidths,
21) -> String {
22    format_long_line_dired(entry, colorizer, config, widths, None)
23}
24
25/// Like [`format_long_line`] but records dired name offsets into the full output.
26pub fn format_long_line_dired(
27    entry: &Entry,
28    colorizer: &Colorizer,
29    config: &Config,
30    widths: &LongWidths,
31    dired_offsets: Option<&mut Vec<(usize, usize)>>,
32) -> String {
33    let perms = format_permissions(entry);
34    let nlink = entry.nlink.to_string();
35    let owner = if config.show_owner {
36        entry.owner_display(config.numeric_uid_gid)
37    } else {
38        String::new()
39    };
40    let group = if config.show_group {
41        entry.group_display(config.numeric_uid_gid)
42    } else {
43        String::new()
44    };
45    let author = if config.show_author {
46        entry.author_display(config.numeric_uid_gid)
47    } else {
48        String::new()
49    };
50
51    let gnu = config.list.gnu_mode;
52    let size = format_size_field(entry, config);
53    let mtime = format_entry_time(entry, config);
54    let icon = icon_prefix(entry, config.icons);
55    let suffix = classify_suffix(entry, config.indicator_style());
56
57    let quoted = display_name(
58        &entry.name,
59        config.quoting_style,
60        config.hide_control_chars(),
61    );
62    let name_core = format!("{icon}{quoted}{suffix}");
63    let arrow_target = if let Some(target) = &entry.symlink_target {
64        let tq = display_name(
65            &target.display().to_string(),
66            config.quoting_style,
67            config.hide_control_chars(),
68        );
69        format!(" -> {tq}")
70    } else {
71        String::new()
72    };
73    let name_colored = if entry.symlink_target.is_some() {
74        colorizer.paint_symlink_name(entry, &name_core, &arrow_target, gnu)
75    } else {
76        colorizer.paint_name(entry, &name_core)
77    };
78    let name = hyperlink_name(&entry.path, &name_colored, config.hyperlink_enabled());
79
80    let git = if config.show_git {
81        entry
82            .git_status
83            .as_char()
84            .map(|c| format!(" {} ", colorizer.paint_git_char(c)))
85            .unwrap_or_else(|| "   ".to_string())
86    } else {
87        String::new()
88    };
89
90    // Build metadata columns; apply modern theme colors when not --gnu.
91    let mut parts = Vec::new();
92    if config.show_inode {
93        let s = format!("{:>w$}", entry.inode, w = widths.inode);
94        parts.push(colorizer.paint_meta(&s, gnu));
95    }
96    if config.show_blocks {
97        let s = format!(
98            "{:>w$}",
99            block_display_with_unit(entry.blocks, config.blocks_unit()),
100            w = widths.blocks
101        );
102        parts.push(colorizer.paint_meta(&s, gnu));
103    }
104    if config.show_context {
105        let ctx = if entry.context.is_empty() {
106            "?"
107        } else {
108            entry.context.as_str()
109        };
110        let s = format!("{:<w$}", ctx, w = widths.context);
111        parts.push(colorizer.paint_meta(&s, gnu));
112    }
113    parts.push(colorizer.paint_perms(&perms, gnu));
114    {
115        let s = format!("{:>w$}", nlink, w = widths.nlink);
116        parts.push(colorizer.paint_meta(&s, gnu));
117    }
118    if config.show_owner {
119        let s = format!("{:<w$}", owner, w = widths.owner);
120        parts.push(colorizer.paint_user(&s, gnu));
121    }
122    if config.show_group {
123        let s = format!("{:<w$}", group, w = widths.group);
124        parts.push(colorizer.paint_user(&s, gnu));
125    }
126    if config.show_author {
127        let s = format!("{:<w$}", author, w = widths.author);
128        parts.push(colorizer.paint_user(&s, gnu));
129    }
130    {
131        let s = format!("{:>w$}", size, w = widths.size);
132        parts.push(colorizer.paint_size(&s, entry.size, gnu));
133    }
134    parts.push(colorizer.paint_time(&mtime, gnu));
135    let prefix = parts.join(" ");
136    // prefix + git + " " + name
137    let line_without_name = format!("{prefix}{git} ");
138    if let Some(offsets) = dired_offsets {
139        let start = line_without_name.len();
140        // Record offsets of the plain quoted name (without color/hyperlink) for dired.
141        // GNU uses the positions of the displayed filename in the raw output stream.
142        // We use the painted/hyperlinked name as written.
143        let end = start + name.len();
144        offsets.push((start, end));
145    }
146    format!("{line_without_name}{name}")
147}
148
149fn format_size_field(entry: &Entry, config: &Config) -> String {
150    format_size_bytes(
151        entry.size,
152        config.block_size,
153        config.human_sizes,
154        config.si_sizes,
155    )
156}
157
158#[derive(Debug, Clone, Default)]
159pub struct LongWidths {
160    pub inode: usize,
161    pub blocks: usize,
162    pub context: usize,
163    pub nlink: usize,
164    pub owner: usize,
165    pub group: usize,
166    pub author: usize,
167    pub size: usize,
168}
169
170impl LongWidths {
171    pub fn compute(entries: &[Entry], config: &Config) -> Self {
172        let mut w = Self {
173            inode: 1,
174            blocks: 1,
175            context: 1,
176            nlink: 1,
177            owner: 1,
178            group: 1,
179            author: 1,
180            size: 1,
181        };
182        for e in entries.iter().filter(|e| !e.is_dir_header) {
183            w.inode = w.inode.max(decimal_digits(e.inode));
184            let blocks_n = block_display_with_unit(e.blocks, config.blocks_unit());
185            w.blocks = w.blocks.max(decimal_digits(blocks_n));
186            if config.show_context {
187                let ctx = if e.context.is_empty() {
188                    "?"
189                } else {
190                    e.context.as_str()
191                };
192                w.context = w.context.max(ctx.len());
193            }
194            w.nlink = w.nlink.max(decimal_digits(e.nlink));
195            if config.show_owner {
196                w.owner = w.owner.max(e.owner_display(config.numeric_uid_gid).len());
197            }
198            if config.show_group {
199                w.group = w.group.max(e.group_display(config.numeric_uid_gid).len());
200            }
201            if config.show_author {
202                w.author = w.author.max(e.author_display(config.numeric_uid_gid).len());
203            }
204            w.size = w.size.max(format_size_field(e, config).len());
205        }
206        w
207    }
208}
209
210/// Decimal digit count for u64 without allocating.
211fn decimal_digits(mut n: u64) -> usize {
212    if n == 0 {
213        return 1;
214    }
215    let mut d = 0;
216    while n > 0 {
217        n /= 10;
218        d += 1;
219    }
220    d
221}
222
223/// Format many entries in long mode with aligned columns.
224pub fn format_long(entries: &[Entry], colorizer: &Colorizer, config: &Config) -> String {
225    let widths = LongWidths::compute(entries, config);
226    // Rough capacity: ~80 bytes/line for typical long rows.
227    let mut out = String::with_capacity(entries.len().saturating_mul(96));
228    let mut dired_global: Vec<(usize, usize)> = Vec::new();
229    let ending = config.line_ending();
230
231    let write_total = |out: &mut String, section: &[Entry]| {
232        if config.zero || !config.emit_block_total {
233            return;
234        }
235        let total: u64 = section
236            .iter()
237            .filter(|e| !e.is_dir_header)
238            .map(|e| crate::human::block_display_with_unit(e.blocks, config.blocks_unit()))
239            .fold(0u64, u64::saturating_add);
240        out.push_str(&format!("total {total}"));
241        out.push_str(ending);
242    };
243
244    let write_entry = |out: &mut String, dired_global: &mut Vec<(usize, usize)>, entry: &Entry| {
245        if config.dired {
246            let base = out.len();
247            let mut local = Vec::new();
248            let line = format_long_line_dired(entry, colorizer, config, &widths, Some(&mut local));
249            for (s, e) in local {
250                dired_global.push((base + s, base + e));
251            }
252            out.push_str(&line);
253            out.push_str(ending);
254        } else {
255            out.push_str(&format_long_line(entry, colorizer, config, &widths));
256            out.push_str(ending);
257        }
258    };
259
260    // Walk sections (optional dir headers for `-R`). GNU prints `total` *before*
261    // any entry lines in each section.
262    let mut i = 0;
263    let has_headers = entries.iter().any(|e| e.is_dir_header);
264    if !has_headers {
265        write_total(&mut out, entries);
266        for entry in entries {
267            write_entry(&mut out, &mut dired_global, entry);
268        }
269    } else {
270        while i < entries.len() {
271            if entries[i].is_dir_header {
272                if !out.is_empty() {
273                    out.push_str(if config.zero { "\0" } else { "\n" });
274                }
275                out.push_str(&format!("{}:", entries[i].path.display()));
276                out.push_str(ending);
277                i += 1;
278            }
279            let start = i;
280            while i < entries.len() && !entries[i].is_dir_header {
281                i += 1;
282            }
283            let section = &entries[start..i];
284            write_total(&mut out, section);
285            for entry in section {
286                write_entry(&mut out, &mut dired_global, entry);
287            }
288        }
289    }
290
291    if config.dired && !config.zero {
292        // GNU: //DIRED// start end start end ...
293        out.push_str("//DIRED//");
294        for (s, e) in &dired_global {
295            out.push_str(&format!(" {s} {e}"));
296        }
297        out.push('\n');
298        out.push_str(&format!(
299            "//DIRED-OPTIONS// --quoting-style={}\n",
300            quoting_style_word(config.quoting_style)
301        ));
302    }
303    out
304}
305
306fn quoting_style_word(style: f00_core::QuotingStyle) -> &'static str {
307    use f00_core::QuotingStyle::*;
308    match style {
309        Literal => "literal",
310        Locale => "locale",
311        Shell => "shell",
312        ShellAlways => "shell-always",
313        ShellEscape => "shell-escape",
314        ShellEscapeAlways => "shell-escape-always",
315        C => "c",
316        Escape => "escape",
317    }
318}
319
320/// Legacy signature used by older tests/callers.
321pub fn format_long_simple(
322    entries: &[Entry],
323    colorizer: &Colorizer,
324    human: bool,
325    icons: bool,
326    classify: bool,
327) -> String {
328    let mut config = Config {
329        human_sizes: human,
330        icons,
331        show_git: false,
332        show_owner: true,
333        show_group: true,
334        ..Config::default()
335    };
336    if classify {
337        config.classify = true;
338        config.indicator = IndicatorStyle::Classify;
339    }
340    format_long(entries, colorizer, &config)
341}
342
343/// Re-export-friendly wrapper matching prior API.
344pub fn format_long_line_simple(
345    entry: &Entry,
346    colorizer: &Colorizer,
347    human: bool,
348    icons: bool,
349    classify: bool,
350    size_width: usize,
351) -> String {
352    let mut config = Config {
353        human_sizes: human,
354        icons,
355        show_git: false,
356        ..Config::default()
357    };
358    if classify {
359        config.classify = true;
360    }
361    let widths = LongWidths {
362        size: size_width,
363        nlink: 1,
364        owner: 1,
365        group: 1,
366        author: 1,
367        inode: 1,
368        blocks: 1,
369        context: 1,
370    };
371    format_long_line(entry, colorizer, &config, &widths)
372}
373
374fn format_entry_time(entry: &Entry, config: &Config) -> String {
375    let style = if config.full_time {
376        TimeStyle::FullIso
377    } else {
378        config.time_style.clone()
379    };
380    let field = config.list.time_field;
381    match entry.datetime_for(field) {
382        Some(dt) => format_time_style(dt, &style),
383        None => "            ".to_string(),
384    }
385}
386
387pub fn format_time_style(dt: DateTime<Local>, style: &TimeStyle) -> String {
388    match style {
389        TimeStyle::FullIso => dt.format("%Y-%m-%d %H:%M:%S.%f %z").to_string(),
390        TimeStyle::LongIso => dt.format("%Y-%m-%d %H:%M").to_string(),
391        TimeStyle::Iso => {
392            let now = Local::now();
393            let six_months = chrono::Duration::days(365 / 2);
394            if (now - dt).abs() > six_months {
395                dt.format("%Y-%m-%d").to_string()
396            } else {
397                dt.format("%m-%d %H:%M").to_string()
398            }
399        }
400        TimeStyle::Locale => format_ls_time(dt),
401        TimeStyle::Format(fmt) => dt.format(fmt).to_string(),
402    }
403}
404
405/// Approximate GNU ls time format: `Mon DD HH:MM` or `Mon DD  YYYY` if old.
406fn format_ls_time(dt: DateTime<Local>) -> String {
407    let now = Local::now();
408    let six_months = chrono::Duration::days(365 / 2);
409    if (now - dt).abs() > six_months {
410        dt.format("%b %e  %Y").to_string()
411    } else {
412        dt.format("%b %e %H:%M").to_string()
413    }
414}