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(e.inode.to_string().len());
164            w.blocks = w.blocks.max(
165                block_display_with_unit(e.blocks, config.blocks_unit())
166                    .to_string()
167                    .len(),
168            );
169            if config.show_context {
170                let ctx = if e.context.is_empty() {
171                    "?"
172                } else {
173                    e.context.as_str()
174                };
175                w.context = w.context.max(ctx.len());
176            }
177            w.nlink = w.nlink.max(e.nlink.to_string().len());
178            if config.show_owner {
179                w.owner = w.owner.max(e.owner_display(config.numeric_uid_gid).len());
180            }
181            if config.show_group {
182                w.group = w.group.max(e.group_display(config.numeric_uid_gid).len());
183            }
184            if config.show_author {
185                w.author = w.author.max(e.author_display(config.numeric_uid_gid).len());
186            }
187            w.size = w.size.max(format_size_field(e, config).len());
188        }
189        w
190    }
191}
192
193/// Format many entries in long mode with aligned columns.
194pub fn format_long(entries: &[Entry], colorizer: &Colorizer, config: &Config) -> String {
195    let widths = LongWidths::compute(entries, config);
196    let mut out = String::new();
197    let mut dired_global: Vec<(usize, usize)> = Vec::new();
198    let ending = config.line_ending();
199
200    for entry in entries {
201        if entry.is_dir_header {
202            if !out.is_empty() {
203                out.push_str(if config.zero { "\0" } else { "\n" });
204            }
205            out.push_str(&format!("{}:", entry.path.display()));
206            out.push_str(ending);
207            continue;
208        }
209        if config.dired {
210            let base = out.len();
211            let mut local = Vec::new();
212            let line = format_long_line_dired(entry, colorizer, config, &widths, Some(&mut local));
213            for (s, e) in local {
214                dired_global.push((base + s, base + e));
215            }
216            out.push_str(&line);
217            out.push_str(ending);
218        } else {
219            out.push_str(&format_long_line(entry, colorizer, config, &widths));
220            out.push_str(ending);
221        }
222    }
223
224    if config.dired && !config.zero {
225        // GNU: //DIRED// start end start end ...
226        out.push_str("//DIRED//");
227        for (s, e) in &dired_global {
228            out.push_str(&format!(" {s} {e}"));
229        }
230        out.push('\n');
231        out.push_str(&format!(
232            "//DIRED-OPTIONS// --quoting-style={}\n",
233            quoting_style_word(config.quoting_style)
234        ));
235    }
236    out
237}
238
239fn quoting_style_word(style: f00_core::QuotingStyle) -> &'static str {
240    use f00_core::QuotingStyle::*;
241    match style {
242        Literal => "literal",
243        Locale => "locale",
244        Shell => "shell",
245        ShellAlways => "shell-always",
246        ShellEscape => "shell-escape",
247        ShellEscapeAlways => "shell-escape-always",
248        C => "c",
249        Escape => "escape",
250    }
251}
252
253/// Legacy signature used by older tests/callers.
254pub fn format_long_simple(
255    entries: &[Entry],
256    colorizer: &Colorizer,
257    human: bool,
258    icons: bool,
259    classify: bool,
260) -> String {
261    let mut config = Config {
262        human_sizes: human,
263        icons,
264        show_git: false,
265        show_owner: true,
266        show_group: true,
267        ..Config::default()
268    };
269    if classify {
270        config.classify = true;
271        config.indicator = IndicatorStyle::Classify;
272    }
273    format_long(entries, colorizer, &config)
274}
275
276/// Re-export-friendly wrapper matching prior API.
277pub fn format_long_line_simple(
278    entry: &Entry,
279    colorizer: &Colorizer,
280    human: bool,
281    icons: bool,
282    classify: bool,
283    size_width: usize,
284) -> String {
285    let mut config = Config {
286        human_sizes: human,
287        icons,
288        show_git: false,
289        ..Config::default()
290    };
291    if classify {
292        config.classify = true;
293    }
294    let widths = LongWidths {
295        size: size_width,
296        nlink: 1,
297        owner: 1,
298        group: 1,
299        author: 1,
300        inode: 1,
301        blocks: 1,
302        context: 1,
303    };
304    format_long_line(entry, colorizer, &config, &widths)
305}
306
307fn format_entry_time(entry: &Entry, config: &Config) -> String {
308    let style = if config.full_time {
309        TimeStyle::FullIso
310    } else {
311        config.time_style.clone()
312    };
313    let field = config.list.time_field;
314    match entry.datetime_for(field) {
315        Some(dt) => format_time_style(dt, &style),
316        None => "            ".to_string(),
317    }
318}
319
320pub fn format_time_style(dt: DateTime<Local>, style: &TimeStyle) -> String {
321    match style {
322        TimeStyle::FullIso => dt.format("%Y-%m-%d %H:%M:%S.%f %z").to_string(),
323        TimeStyle::LongIso => dt.format("%Y-%m-%d %H:%M").to_string(),
324        TimeStyle::Iso => {
325            let now = Local::now();
326            let six_months = chrono::Duration::days(365 / 2);
327            if (now - dt).abs() > six_months {
328                dt.format("%Y-%m-%d").to_string()
329            } else {
330                dt.format("%m-%d %H:%M").to_string()
331            }
332        }
333        TimeStyle::Locale => format_ls_time(dt),
334        TimeStyle::Format(fmt) => dt.format(fmt).to_string(),
335    }
336}
337
338/// Approximate GNU ls time format: `Mon DD HH:MM` or `Mon DD  YYYY` if old.
339fn format_ls_time(dt: DateTime<Local>) -> String {
340    let now = Local::now();
341    let six_months = chrono::Duration::days(365 / 2);
342    if (now - dt).abs() > six_months {
343        dt.format("%b %e  %Y").to_string()
344    } else {
345        dt.format("%b %e %H:%M").to_string()
346    }
347}