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    for entry in entries {
232        if entry.is_dir_header {
233            if !out.is_empty() {
234                out.push_str(if config.zero { "\0" } else { "\n" });
235            }
236            out.push_str(&format!("{}:", entry.path.display()));
237            out.push_str(ending);
238            continue;
239        }
240        if config.dired {
241            let base = out.len();
242            let mut local = Vec::new();
243            let line = format_long_line_dired(entry, colorizer, config, &widths, Some(&mut local));
244            for (s, e) in local {
245                dired_global.push((base + s, base + e));
246            }
247            out.push_str(&line);
248            out.push_str(ending);
249        } else {
250            out.push_str(&format_long_line(entry, colorizer, config, &widths));
251            out.push_str(ending);
252        }
253    }
254
255    if config.dired && !config.zero {
256        // GNU: //DIRED// start end start end ...
257        out.push_str("//DIRED//");
258        for (s, e) in &dired_global {
259            out.push_str(&format!(" {s} {e}"));
260        }
261        out.push('\n');
262        out.push_str(&format!(
263            "//DIRED-OPTIONS// --quoting-style={}\n",
264            quoting_style_word(config.quoting_style)
265        ));
266    }
267    out
268}
269
270fn quoting_style_word(style: f00_core::QuotingStyle) -> &'static str {
271    use f00_core::QuotingStyle::*;
272    match style {
273        Literal => "literal",
274        Locale => "locale",
275        Shell => "shell",
276        ShellAlways => "shell-always",
277        ShellEscape => "shell-escape",
278        ShellEscapeAlways => "shell-escape-always",
279        C => "c",
280        Escape => "escape",
281    }
282}
283
284/// Legacy signature used by older tests/callers.
285pub fn format_long_simple(
286    entries: &[Entry],
287    colorizer: &Colorizer,
288    human: bool,
289    icons: bool,
290    classify: bool,
291) -> String {
292    let mut config = Config {
293        human_sizes: human,
294        icons,
295        show_git: false,
296        show_owner: true,
297        show_group: true,
298        ..Config::default()
299    };
300    if classify {
301        config.classify = true;
302        config.indicator = IndicatorStyle::Classify;
303    }
304    format_long(entries, colorizer, &config)
305}
306
307/// Re-export-friendly wrapper matching prior API.
308pub fn format_long_line_simple(
309    entry: &Entry,
310    colorizer: &Colorizer,
311    human: bool,
312    icons: bool,
313    classify: bool,
314    size_width: usize,
315) -> String {
316    let mut config = Config {
317        human_sizes: human,
318        icons,
319        show_git: false,
320        ..Config::default()
321    };
322    if classify {
323        config.classify = true;
324    }
325    let widths = LongWidths {
326        size: size_width,
327        nlink: 1,
328        owner: 1,
329        group: 1,
330        author: 1,
331        inode: 1,
332        blocks: 1,
333        context: 1,
334    };
335    format_long_line(entry, colorizer, &config, &widths)
336}
337
338fn format_entry_time(entry: &Entry, config: &Config) -> String {
339    let style = if config.full_time {
340        TimeStyle::FullIso
341    } else {
342        config.time_style.clone()
343    };
344    let field = config.list.time_field;
345    match entry.datetime_for(field) {
346        Some(dt) => format_time_style(dt, &style),
347        None => "            ".to_string(),
348    }
349}
350
351pub fn format_time_style(dt: DateTime<Local>, style: &TimeStyle) -> String {
352    match style {
353        TimeStyle::FullIso => dt.format("%Y-%m-%d %H:%M:%S.%f %z").to_string(),
354        TimeStyle::LongIso => dt.format("%Y-%m-%d %H:%M").to_string(),
355        TimeStyle::Iso => {
356            let now = Local::now();
357            let six_months = chrono::Duration::days(365 / 2);
358            if (now - dt).abs() > six_months {
359                dt.format("%Y-%m-%d").to_string()
360            } else {
361                dt.format("%m-%d %H:%M").to_string()
362            }
363        }
364        TimeStyle::Locale => format_ls_time(dt),
365        TimeStyle::Format(fmt) => dt.format(fmt).to_string(),
366    }
367}
368
369/// Approximate GNU ls time format: `Mon DD HH:MM` or `Mon DD  YYYY` if old.
370fn format_ls_time(dt: DateTime<Local>) -> String {
371    let now = Local::now();
372    let six_months = chrono::Duration::days(365 / 2);
373    if (now - dt).abs() > six_months {
374        dt.format("%b %e  %Y").to_string()
375    } else {
376        dt.format("%b %e %H:%M").to_string()
377    }
378}