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 size = format_size_field(entry, config);
52    let mtime = format_entry_time(entry, config);
53    let icon = icon_prefix(entry, config.icons);
54    let suffix = classify_suffix(entry, config.indicator_style());
55
56    let quoted = display_name(
57        &entry.name,
58        config.quoting_style,
59        config.hide_control_chars(),
60    );
61    let mut name_plain = format!("{icon}{quoted}{suffix}");
62    if let Some(target) = &entry.symlink_target {
63        let tq = display_name(
64            &target.display().to_string(),
65            config.quoting_style,
66            config.hide_control_chars(),
67        );
68        name_plain = format!("{name_plain} -> {tq}");
69    }
70    let name_colored = colorizer.paint_name(entry, &name_plain);
71    let name = hyperlink_name(&entry.path, &name_colored, config.hyperlink_enabled());
72
73    let git = if config.show_git {
74        entry
75            .git_status
76            .as_char()
77            .map(|c| format!(" {} ", colorizer.paint_git_char(c)))
78            .unwrap_or_else(|| "   ".to_string())
79    } else {
80        String::new()
81    };
82
83    let mut parts = Vec::new();
84    if config.show_inode {
85        parts.push(format!("{:>w$}", entry.inode, w = widths.inode));
86    }
87    if config.show_blocks {
88        parts.push(format!(
89            "{:>w$}",
90            block_display_with_unit(entry.blocks, config.blocks_unit()),
91            w = widths.blocks
92        ));
93    }
94    if config.show_context {
95        let ctx = if entry.context.is_empty() {
96            "?"
97        } else {
98            entry.context.as_str()
99        };
100        parts.push(format!("{:<w$}", ctx, w = widths.context));
101    }
102    parts.push(perms);
103    parts.push(format!("{:>w$}", nlink, w = widths.nlink));
104    if config.show_owner {
105        parts.push(format!("{:<w$}", owner, w = widths.owner));
106    }
107    if config.show_group {
108        parts.push(format!("{:<w$}", group, w = widths.group));
109    }
110    if config.show_author {
111        parts.push(format!("{:<w$}", author, w = widths.author));
112    }
113    parts.push(format!("{:>w$}", size, w = widths.size));
114    parts.push(mtime);
115    let prefix = parts.join(" ");
116    // prefix + git + " " + name
117    let line_without_name = format!("{prefix}{git} ");
118    if let Some(offsets) = dired_offsets {
119        let start = line_without_name.len();
120        // Record offsets of the plain quoted name (without color/hyperlink) for dired.
121        // GNU uses the positions of the displayed filename in the raw output stream.
122        // We use the painted/hyperlinked name as written.
123        let end = start + name.len();
124        offsets.push((start, end));
125    }
126    format!("{line_without_name}{name}")
127}
128
129fn format_size_field(entry: &Entry, config: &Config) -> String {
130    format_size_bytes(
131        entry.size,
132        config.block_size,
133        config.human_sizes,
134        config.si_sizes,
135    )
136}
137
138#[derive(Debug, Clone, Default)]
139pub struct LongWidths {
140    pub inode: usize,
141    pub blocks: usize,
142    pub context: usize,
143    pub nlink: usize,
144    pub owner: usize,
145    pub group: usize,
146    pub author: usize,
147    pub size: usize,
148}
149
150impl LongWidths {
151    pub fn compute(entries: &[Entry], config: &Config) -> Self {
152        let mut w = Self {
153            inode: 1,
154            blocks: 1,
155            context: 1,
156            nlink: 1,
157            owner: 1,
158            group: 1,
159            author: 1,
160            size: 1,
161        };
162        for e in entries.iter().filter(|e| !e.is_dir_header) {
163            w.inode = w.inode.max(decimal_digits(e.inode));
164            let blocks_n = block_display_with_unit(e.blocks, config.blocks_unit());
165            w.blocks = w.blocks.max(decimal_digits(blocks_n));
166            if config.show_context {
167                let ctx = if e.context.is_empty() {
168                    "?"
169                } else {
170                    e.context.as_str()
171                };
172                w.context = w.context.max(ctx.len());
173            }
174            w.nlink = w.nlink.max(decimal_digits(e.nlink));
175            if config.show_owner {
176                w.owner = w.owner.max(e.owner_display(config.numeric_uid_gid).len());
177            }
178            if config.show_group {
179                w.group = w.group.max(e.group_display(config.numeric_uid_gid).len());
180            }
181            if config.show_author {
182                w.author = w.author.max(e.author_display(config.numeric_uid_gid).len());
183            }
184            w.size = w.size.max(format_size_field(e, config).len());
185        }
186        w
187    }
188}
189
190/// Decimal digit count for u64 without allocating.
191fn decimal_digits(mut n: u64) -> usize {
192    if n == 0 {
193        return 1;
194    }
195    let mut d = 0;
196    while n > 0 {
197        n /= 10;
198        d += 1;
199    }
200    d
201}
202
203/// Format many entries in long mode with aligned columns.
204pub fn format_long(entries: &[Entry], colorizer: &Colorizer, config: &Config) -> String {
205    let widths = LongWidths::compute(entries, config);
206    // Rough capacity: ~80 bytes/line for typical long rows.
207    let mut out = String::with_capacity(entries.len().saturating_mul(96));
208    let mut dired_global: Vec<(usize, usize)> = Vec::new();
209    let ending = config.line_ending();
210
211    for entry in entries {
212        if entry.is_dir_header {
213            if !out.is_empty() {
214                out.push_str(if config.zero { "\0" } else { "\n" });
215            }
216            out.push_str(&format!("{}:", entry.path.display()));
217            out.push_str(ending);
218            continue;
219        }
220        if config.dired {
221            let base = out.len();
222            let mut local = Vec::new();
223            let line = format_long_line_dired(entry, colorizer, config, &widths, Some(&mut local));
224            for (s, e) in local {
225                dired_global.push((base + s, base + e));
226            }
227            out.push_str(&line);
228            out.push_str(ending);
229        } else {
230            out.push_str(&format_long_line(entry, colorizer, config, &widths));
231            out.push_str(ending);
232        }
233    }
234
235    if config.dired && !config.zero {
236        // GNU: //DIRED// start end start end ...
237        out.push_str("//DIRED//");
238        for (s, e) in &dired_global {
239            out.push_str(&format!(" {s} {e}"));
240        }
241        out.push('\n');
242        out.push_str(&format!(
243            "//DIRED-OPTIONS// --quoting-style={}\n",
244            quoting_style_word(config.quoting_style)
245        ));
246    }
247    out
248}
249
250fn quoting_style_word(style: f00_core::QuotingStyle) -> &'static str {
251    use f00_core::QuotingStyle::*;
252    match style {
253        Literal => "literal",
254        Locale => "locale",
255        Shell => "shell",
256        ShellAlways => "shell-always",
257        ShellEscape => "shell-escape",
258        ShellEscapeAlways => "shell-escape-always",
259        C => "c",
260        Escape => "escape",
261    }
262}
263
264/// Legacy signature used by older tests/callers.
265pub fn format_long_simple(
266    entries: &[Entry],
267    colorizer: &Colorizer,
268    human: bool,
269    icons: bool,
270    classify: bool,
271) -> String {
272    let mut config = Config {
273        human_sizes: human,
274        icons,
275        show_git: false,
276        show_owner: true,
277        show_group: true,
278        ..Config::default()
279    };
280    if classify {
281        config.classify = true;
282        config.indicator = IndicatorStyle::Classify;
283    }
284    format_long(entries, colorizer, &config)
285}
286
287/// Re-export-friendly wrapper matching prior API.
288pub fn format_long_line_simple(
289    entry: &Entry,
290    colorizer: &Colorizer,
291    human: bool,
292    icons: bool,
293    classify: bool,
294    size_width: usize,
295) -> String {
296    let mut config = Config {
297        human_sizes: human,
298        icons,
299        show_git: false,
300        ..Config::default()
301    };
302    if classify {
303        config.classify = true;
304    }
305    let widths = LongWidths {
306        size: size_width,
307        nlink: 1,
308        owner: 1,
309        group: 1,
310        author: 1,
311        inode: 1,
312        blocks: 1,
313        context: 1,
314    };
315    format_long_line(entry, colorizer, &config, &widths)
316}
317
318fn format_entry_time(entry: &Entry, config: &Config) -> String {
319    let style = if config.full_time {
320        TimeStyle::FullIso
321    } else {
322        config.time_style.clone()
323    };
324    let field = config.list.time_field;
325    match entry.datetime_for(field) {
326        Some(dt) => format_time_style(dt, &style),
327        None => "            ".to_string(),
328    }
329}
330
331pub fn format_time_style(dt: DateTime<Local>, style: &TimeStyle) -> String {
332    match style {
333        TimeStyle::FullIso => dt.format("%Y-%m-%d %H:%M:%S.%f %z").to_string(),
334        TimeStyle::LongIso => dt.format("%Y-%m-%d %H:%M").to_string(),
335        TimeStyle::Iso => {
336            let now = Local::now();
337            let six_months = chrono::Duration::days(365 / 2);
338            if (now - dt).abs() > six_months {
339                dt.format("%Y-%m-%d").to_string()
340            } else {
341                dt.format("%m-%d %H:%M").to_string()
342            }
343        }
344        TimeStyle::Locale => format_ls_time(dt),
345        TimeStyle::Format(fmt) => dt.format(fmt).to_string(),
346    }
347}
348
349/// Approximate GNU ls time format: `Mon DD HH:MM` or `Mon DD  YYYY` if old.
350fn format_ls_time(dt: DateTime<Local>) -> String {
351    let now = Local::now();
352    let six_months = chrono::Duration::days(365 / 2);
353    if (now - dt).abs() > six_months {
354        dt.format("%b %e  %Y").to_string()
355    } else {
356        dt.format("%b %e %H:%M").to_string()
357    }
358}