Skip to main content

f00_format/
csv.rs

1//! CSV / TSV output modes.
2
3use f00_core::Entry;
4
5/// Format entries as CSV (header + rows).
6pub fn format_csv(entries: &[Entry]) -> String {
7    format_delimited(entries, ',')
8}
9
10/// Format entries as TSV (header + rows).
11pub fn format_tsv(entries: &[Entry]) -> String {
12    format_delimited(entries, '\t')
13}
14
15fn format_delimited(entries: &[Entry], sep: char) -> String {
16    let mut out = String::new();
17    // header
18    push_row(
19        &mut out,
20        sep,
21        &[
22            "name",
23            "path",
24            "kind",
25            "size",
26            "modified",
27            "mode",
28            "inode",
29            "uid",
30            "gid",
31            "nlink",
32            "symlink_target",
33            "context",
34        ],
35    );
36    for e in entries.iter().filter(|e| !e.is_dir_header) {
37        let modified = e
38            .modified_datetime()
39            .map(|d| d.to_rfc3339())
40            .unwrap_or_default();
41        let target = e
42            .symlink_target
43            .as_ref()
44            .map(|p| p.display().to_string())
45            .unwrap_or_default();
46        let fields = [
47            e.name.clone(),
48            e.path.display().to_string(),
49            e.kind.as_str().to_string(),
50            e.size.to_string(),
51            modified,
52            format!("{:o}", e.mode),
53            e.inode.to_string(),
54            e.uid.to_string(),
55            e.gid.to_string(),
56            e.nlink.to_string(),
57            target,
58            e.context.clone(),
59        ];
60        let refs: Vec<&str> = fields.iter().map(|s| s.as_str()).collect();
61        push_row(&mut out, sep, &refs);
62    }
63    out
64}
65
66fn push_row(out: &mut String, sep: char, fields: &[&str]) {
67    for (i, f) in fields.iter().enumerate() {
68        if i > 0 {
69            out.push(sep);
70        }
71        if sep == ',' {
72            push_csv_field(out, f);
73        } else {
74            // TSV: escape tabs/newlines lightly
75            for c in f.chars() {
76                match c {
77                    '\t' => out.push_str("\\t"),
78                    '\n' => out.push_str("\\n"),
79                    '\r' => out.push_str("\\r"),
80                    _ => out.push(c),
81                }
82            }
83        }
84    }
85    out.push('\n');
86}
87
88fn push_csv_field(out: &mut String, field: &str) {
89    if field.contains([',', '"', '\n', '\r']) {
90        out.push('"');
91        for c in field.chars() {
92            if c == '"' {
93                out.push_str("\"\"");
94            } else {
95                out.push(c);
96            }
97        }
98        out.push('"');
99    } else {
100        out.push_str(field);
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use f00_core::{Entry, EntryKind, GitStatus};
108    use std::path::PathBuf;
109
110    fn ent(name: &str) -> Entry {
111        Entry {
112            path: PathBuf::from(name),
113            name: name.into(),
114            kind: EntryKind::File,
115            size: 3,
116            modified: None,
117            created: None,
118            accessed: None,
119            changed: None,
120            mode: 0o644,
121            readonly: false,
122            symlink_target: None,
123            depth: 0,
124            git_status: GitStatus::Clean,
125            is_dir_header: false,
126            nlink: 1,
127            uid: 0,
128            gid: 0,
129            inode: 1,
130            blocks: 0,
131            owner: "u".into(),
132            group: "g".into(),
133            author: "u".into(),
134            context: String::new(),
135        }
136    }
137
138    #[test]
139    fn csv_has_header_and_row() {
140        let s = format_csv(&[ent("a.txt")]);
141        assert!(s.starts_with("name,path,"));
142        assert!(s.contains("a.txt"));
143    }
144
145    #[test]
146    fn tsv_uses_tabs() {
147        let s = format_tsv(&[ent("b")]);
148        assert!(s.contains('\t'));
149        assert!(s.contains("b"));
150    }
151}