Skip to main content

apif_source_row/
detect.rs

1use std::path::Path;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub enum SourceFormat {
5    Csv,
6    Tsv,
7    Ndjson,
8}
9
10impl std::fmt::Display for SourceFormat {
11    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12        match self {
13            SourceFormat::Csv => write!(f, "csv"),
14            SourceFormat::Tsv => write!(f, "tsv"),
15            SourceFormat::Ndjson => write!(f, "ndjson"),
16        }
17    }
18}
19
20impl std::str::FromStr for SourceFormat {
21    type Err = String;
22
23    fn from_str(s: &str) -> Result<Self, Self::Err> {
24        match s.trim_ascii().to_ascii_lowercase().as_str() {
25            "csv" => Ok(SourceFormat::Csv),
26            "tsv" => Ok(SourceFormat::Tsv),
27            "ndjson" | "json" | "jsonl" => Ok(SourceFormat::Ndjson),
28            other => Err(format!("unknown source format: {other}")),
29        }
30    }
31}
32
33pub fn detect_format(path: &Path) -> Result<SourceFormat, apif_source_error::SourceError> {
34    let filename = path
35        .file_name()
36        .map(|n| n.to_string_lossy().to_string())
37        .unwrap_or_default()
38        .to_ascii_lowercase();
39
40    if filename.ends_with(".tsv") || filename.ends_with(".tab") {
41        return Ok(SourceFormat::Tsv);
42    }
43    if filename.ends_with(".ndjson") || filename.ends_with(".jsonl") {
44        return Ok(SourceFormat::Ndjson);
45    }
46    if filename.ends_with(".json") {
47        let content = std::fs::read_to_string(path).map_err(|e| {
48            apif_source_error::SourceError::FileOpenFailed(path.display().to_string(), e)
49        })?;
50        return Ok(detect_format_from_content(&content));
51    }
52    if filename.ends_with(".csv") {
53        return Ok(SourceFormat::Csv);
54    }
55
56    let content = std::fs::read_to_string(path).map_err(|e| {
57        apif_source_error::SourceError::FileOpenFailed(path.display().to_string(), e)
58    })?;
59    Ok(detect_format_from_content(&content))
60}
61
62pub(crate) fn detect_format_from_content(content: &str) -> SourceFormat {
63    let first_line = content.lines().next().unwrap_or("");
64
65    if first_line.trim_start().starts_with('{') {
66        return SourceFormat::Ndjson;
67    }
68
69    if first_line.contains('\t') && !first_line.contains(',') {
70        return SourceFormat::Tsv;
71    }
72
73    SourceFormat::Csv
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn detect_csv_extension() {
82        assert_eq!(
83            detect_format(Path::new("data/users.csv")).unwrap(),
84            SourceFormat::Csv
85        );
86    }
87
88    #[test]
89    fn detect_tsv_extension() {
90        assert_eq!(
91            detect_format(Path::new("data/data.tsv")).unwrap(),
92            SourceFormat::Tsv
93        );
94        assert_eq!(
95            detect_format(Path::new("data/data.tab")).unwrap(),
96            SourceFormat::Tsv
97        );
98    }
99
100    #[test]
101    fn detect_ndjson_extension() {
102        assert_eq!(
103            detect_format(Path::new("data/logs.ndjson")).unwrap(),
104            SourceFormat::Ndjson
105        );
106        assert_eq!(
107            detect_format(Path::new("data/logs.jsonl")).unwrap(),
108            SourceFormat::Ndjson
109        );
110    }
111
112    #[test]
113    fn detect_from_content_json() {
114        assert_eq!(
115            detect_format_from_content("{\"id\":1}\n{\"id\":2}\n"),
116            SourceFormat::Ndjson
117        );
118    }
119
120    #[test]
121    fn detect_from_content_tsv() {
122        assert_eq!(
123            detect_format_from_content("id\tname\n1\tAlice\n"),
124            SourceFormat::Tsv
125        );
126    }
127
128    #[test]
129    fn detect_from_content_csv_default() {
130        assert_eq!(
131            detect_format_from_content("id,name\n1,Alice\n"),
132            SourceFormat::Csv
133        );
134    }
135
136    #[test]
137    fn format_from_str() {
138        assert_eq!("csv".parse::<SourceFormat>(), Ok(SourceFormat::Csv));
139        assert_eq!("tsv".parse::<SourceFormat>(), Ok(SourceFormat::Tsv));
140        assert_eq!("ndjson".parse::<SourceFormat>(), Ok(SourceFormat::Ndjson));
141        assert_eq!("json".parse::<SourceFormat>(), Ok(SourceFormat::Ndjson));
142        assert_eq!("jsonl".parse::<SourceFormat>(), Ok(SourceFormat::Ndjson));
143        assert!("unknown".parse::<SourceFormat>().is_err());
144    }
145
146    #[test]
147    fn format_display() {
148        assert_eq!(format!("{}", SourceFormat::Csv), "csv");
149        assert_eq!(format!("{}", SourceFormat::Tsv), "tsv");
150        assert_eq!(format!("{}", SourceFormat::Ndjson), "ndjson");
151    }
152}