faucet_source_rest/
format.rs1use faucet_core::FaucetError;
9use serde_json::{Map, Value};
10
11pub async fn parse_csv(
16 bytes: &[u8],
17 delimiter: u8,
18 has_headers: bool,
19) -> Result<Vec<Value>, FaucetError> {
20 use futures::StreamExt as _;
21 let mut rdr = csv_async::AsyncReaderBuilder::new()
22 .has_headers(false)
23 .delimiter(delimiter)
24 .flexible(true)
25 .create_reader(bytes);
26 let mut records = rdr.records();
27 let mut headers: Option<Vec<String>> = None;
28 let mut out = Vec::new();
29 while let Some(rec) = records.next().await {
30 let rec = rec.map_err(|e| FaucetError::Source(format!("rest: CSV parse error: {e}")))?;
31 if has_headers && headers.is_none() {
32 headers = Some(rec.iter().map(str::to_string).collect());
33 continue;
34 }
35 let mut obj = Map::new();
36 for (i, field) in rec.iter().enumerate() {
37 let key = headers
38 .as_ref()
39 .and_then(|h| h.get(i).cloned())
40 .unwrap_or_else(|| format!("column_{i}"));
41 obj.insert(key, Value::String(field.to_string()));
42 }
43 out.push(Value::Object(obj));
44 }
45 Ok(out)
46}
47
48#[cfg(feature = "excel")]
50pub fn parse_excel(
51 bytes: &[u8],
52 sheet: Option<&str>,
53 header_row: usize,
54) -> Result<Vec<Value>, FaucetError> {
55 use calamine::{Data, Reader, Xlsx};
56 let cursor = std::io::Cursor::new(bytes.to_vec());
57 let mut wb: Xlsx<_> = calamine::open_workbook_from_rs(cursor)
58 .map_err(|e| FaucetError::Source(format!("rest: opening Excel workbook: {e}")))?;
59 let names = wb.sheet_names().to_vec();
60 let name = match sheet {
61 Some(s) if names.iter().any(|n| n == s) => s.to_string(),
62 Some(s) => match s.parse::<usize>() {
63 Ok(idx) => names.get(idx).cloned().ok_or_else(|| {
64 FaucetError::Source(format!("rest: Excel sheet index {idx} out of range"))
65 })?,
66 Err(_) => {
67 return Err(FaucetError::Source(format!(
68 "rest: Excel sheet '{s}' not found (available: {})",
69 names.join(", ")
70 )));
71 }
72 },
73 None => names
74 .first()
75 .cloned()
76 .ok_or_else(|| FaucetError::Source("rest: Excel workbook has no worksheets".into()))?,
77 };
78 let range = wb
79 .worksheet_range(&name)
80 .map_err(|e| FaucetError::Source(format!("rest: reading Excel sheet '{name}': {e}")))?;
81 let rows: Vec<&[Data]> = range.rows().collect();
82 let header = rows.get(header_row).ok_or_else(|| {
83 FaucetError::Source(format!(
84 "rest: Excel header_row {header_row} is beyond the sheet ({} rows)",
85 rows.len()
86 ))
87 })?;
88 let headers: Vec<String> = header.iter().map(cell_to_string).collect();
89 let mut out = Vec::new();
90 for row in rows.iter().skip(header_row + 1) {
91 let mut obj = Map::new();
92 for (i, cell) in row.iter().enumerate() {
93 let key = headers
94 .get(i)
95 .cloned()
96 .filter(|k| !k.is_empty())
97 .unwrap_or_else(|| format!("column_{i}"));
98 obj.insert(key, cell_to_value(cell));
99 }
100 out.push(Value::Object(obj));
101 }
102 Ok(out)
103}
104
105#[cfg(not(feature = "excel"))]
108pub fn parse_excel(
109 _bytes: &[u8],
110 _sheet: Option<&str>,
111 _header_row: usize,
112) -> Result<Vec<Value>, FaucetError> {
113 Err(FaucetError::Config(
114 "rest: `response_format: excel` requires the crate's `excel` feature — rebuild the CLI \
115 with `--features source-rest-excel`"
116 .into(),
117 ))
118}
119
120#[cfg(feature = "excel")]
121fn cell_to_string(cell: &calamine::Data) -> String {
122 use calamine::Data;
123 match cell {
124 Data::String(s) => s.clone(),
125 Data::Empty => String::new(),
126 other => other.to_string(),
127 }
128}
129
130#[cfg(feature = "excel")]
131fn cell_to_value(cell: &calamine::Data) -> Value {
132 use calamine::Data;
133 match cell {
134 Data::Empty => Value::Null,
135 Data::String(s) => Value::String(s.clone()),
136 Data::Bool(b) => Value::Bool(*b),
137 Data::Int(i) => Value::from(*i),
138 Data::Float(f) => serde_json::Number::from_f64(*f)
139 .map(Value::Number)
140 .unwrap_or(Value::Null),
141 Data::DateTime(dt) => Value::String(dt.to_string()),
142 Data::DateTimeIso(s) | Data::DurationIso(s) => Value::String(s.clone()),
143 Data::Error(e) => Value::String(format!("{e:?}")),
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[tokio::test]
152 async fn csv_with_headers() {
153 let recs = parse_csv(b"id,name\n1,Alice\n2,Bob\n", b',', true)
154 .await
155 .unwrap();
156 assert_eq!(recs.len(), 2);
157 assert_eq!(recs[0]["id"], "1");
158 assert_eq!(recs[0]["name"], "Alice");
159 assert_eq!(recs[1]["name"], "Bob");
160 }
161
162 #[tokio::test]
163 async fn csv_without_headers_generates_names() {
164 let recs = parse_csv(b"1,Alice\n", b',', false).await.unwrap();
165 assert_eq!(recs[0]["column_0"], "1");
166 assert_eq!(recs[0]["column_1"], "Alice");
167 }
168
169 #[tokio::test]
170 async fn csv_custom_delimiter_and_embedded_newline() {
171 let recs = parse_csv(b"a;b\n1;\"x\ny\"\n", b';', true).await.unwrap();
172 assert_eq!(recs.len(), 1);
173 assert_eq!(recs[0]["b"], "x\ny");
174 }
175
176 #[cfg(not(feature = "excel"))]
177 #[test]
178 fn excel_without_feature_errors() {
179 assert!(
180 parse_excel(b"x", None, 0)
181 .unwrap_err()
182 .to_string()
183 .contains("excel")
184 );
185 }
186
187 #[cfg(feature = "excel")]
188 #[test]
189 fn cell_conversions_cover_all_variants() {
190 use calamine::Data;
191 assert_eq!(cell_to_value(&Data::Empty), Value::Null);
192 assert_eq!(
193 cell_to_value(&Data::String("s".into())),
194 Value::String("s".into())
195 );
196 assert_eq!(cell_to_value(&Data::Bool(true)), Value::Bool(true));
197 assert_eq!(cell_to_value(&Data::Int(7)), Value::from(7i64));
198 assert_eq!(cell_to_value(&Data::Float(1.5)), Value::from(1.5));
199 assert!(
200 cell_to_value(&Data::DateTime(calamine::ExcelDateTime::new(
201 44_000.0,
202 calamine::ExcelDateTimeType::DateTime,
203 false
204 )))
205 .is_string()
206 );
207 assert!(cell_to_value(&Data::DateTimeIso("2020".into())).is_string());
208 assert!(cell_to_value(&Data::DurationIso("PT1H".into())).is_string());
209 assert!(cell_to_value(&Data::Error(calamine::CellErrorType::Div0)).is_string());
210 assert_eq!(cell_to_string(&Data::String("k".into())), "k");
211 assert_eq!(cell_to_string(&Data::Empty), "");
212 assert_eq!(cell_to_string(&Data::Int(3)), "3");
213 }
214
215 #[cfg(feature = "excel")]
216 #[test]
217 fn excel_sheet_selection_and_error_paths() {
218 let xlsx = include_bytes!("../tests/fixtures/sample.xlsx");
219 let recs = parse_excel(xlsx, Some("1"), 0).unwrap();
221 assert_eq!(recs[0]["k"], "x");
222 assert!(
224 parse_excel(xlsx, Some("99"), 0)
225 .unwrap_err()
226 .to_string()
227 .contains("out of range")
228 );
229 assert!(
231 parse_excel(xlsx, Some("Nope"), 0)
232 .unwrap_err()
233 .to_string()
234 .contains("not found")
235 );
236 assert!(
238 parse_excel(xlsx, None, 9999)
239 .unwrap_err()
240 .to_string()
241 .contains("beyond the sheet")
242 );
243 assert!(parse_excel(b"not-a-workbook", None, 0).is_err());
245 }
246}