apif_source_row/
detect.rs1use 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}