1use std::collections::BTreeSet;
23use std::io::{BufRead, BufReader};
24use std::path::Path;
25
26use crate::application::ports::RuntimeEntityPort;
27use crate::application::CreateDocumentInput;
28use crate::runtime::RedDBRuntime;
29use crate::storage::import::{CsvConfig, CsvImporter};
30
31pub const POSITIONAL_ALIAS: &str = "t";
33
34#[derive(Debug, Clone)]
36pub struct EphemeralTable {
37 pub collection: String,
39 pub alias: String,
41 pub rows_imported: usize,
43}
44
45struct DataFileSpec {
46 display: String,
47 base_collection: String,
48 format: EphemeralFormat,
49}
50
51#[derive(Debug)]
57pub enum EphemeralError {
58 NotAFile { path: String },
60 UnsupportedExtension { path: String, ext: String },
62 EmptyStem { path: String },
64 Import { path: String, source: String },
66 Json { path: String, source: String },
68 JsonShape { path: String, source: String },
70}
71
72impl std::fmt::Display for EphemeralError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 EphemeralError::NotAFile { path } => {
76 write!(f, "cannot read data file '{path}': no such file")
77 }
78 EphemeralError::UnsupportedExtension { path, ext } => write!(
79 f,
80 "unsupported data file '{path}': '.{ext}' is not a supported ephemeral data file \
81 (expected .csv, .tsv, .tab, .json, .jsonl, or .ndjson)"
82 ),
83 EphemeralError::EmptyStem { path } => write!(
84 f,
85 "cannot derive a table name from '{path}': the file stem is empty"
86 ),
87 EphemeralError::Import { path, source } => {
88 write!(f, "failed to load '{path}': {source}")
89 }
90 EphemeralError::Json { path, source } => {
91 write!(f, "failed to parse '{path}': {source}")
92 }
93 EphemeralError::JsonShape { path, source } => {
94 write!(f, "failed to load '{path}': {source}")
95 }
96 }
97 }
98}
99
100impl std::error::Error for EphemeralError {}
101
102#[must_use]
109pub fn sanitize_stem(stem: &str) -> Option<String> {
110 let mut out = String::with_capacity(stem.len());
111 let mut prev_underscore = false;
112 for ch in stem.chars() {
113 if ch.is_ascii_alphanumeric() {
114 out.push(ch.to_ascii_lowercase());
115 prev_underscore = false;
116 } else if !prev_underscore {
117 out.push('_');
118 prev_underscore = true;
119 }
120 }
121 let trimmed = out.trim_matches('_');
122 if trimmed.is_empty() {
123 return None;
124 }
125 if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
127 Some(format!("_{trimmed}"))
128 } else {
129 Some(trimmed.to_string())
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134enum EphemeralFormat {
135 Delimited(u8),
136 JsonArray,
137 Ndjson,
138}
139
140fn format_for_extension(ext: &str) -> Option<EphemeralFormat> {
142 match ext {
143 "csv" => Some(EphemeralFormat::Delimited(b',')),
144 "tsv" | "tab" => Some(EphemeralFormat::Delimited(b'\t')),
145 "json" => Some(EphemeralFormat::JsonArray),
146 "jsonl" | "ndjson" => Some(EphemeralFormat::Ndjson),
147 _ => None,
148 }
149}
150
151impl RedDBRuntime {
152 pub fn materialize_data_file(&self, path: &Path) -> Result<EphemeralTable, EphemeralError> {
160 let mut tables = self.materialize_data_files(&[path])?;
161 Ok(tables.remove(0))
162 }
163
164 pub fn materialize_data_files(
173 &self,
174 paths: &[&Path],
175 ) -> Result<Vec<EphemeralTable>, EphemeralError> {
176 let specs = paths
177 .iter()
178 .map(|path| data_file_spec(path))
179 .collect::<Result<Vec<_>, _>>()?;
180 let mut used_collections = BTreeSet::new();
181 let mut tables = Vec::with_capacity(specs.len());
182
183 for (index, spec) in specs.iter().enumerate() {
184 let collection = unique_collection_name(&spec.base_collection, &mut used_collections);
185 let alias = positional_alias(index, specs.len());
186 let rows_imported = self.import_data_file(paths[index], &collection, spec)?;
187
188 if alias != collection {
194 self.import_data_file(paths[index], &alias, spec)?;
195 }
196
197 tables.push(EphemeralTable {
198 collection,
199 alias,
200 rows_imported,
201 });
202 }
203
204 Ok(tables)
205 }
206
207 fn import_data_file(
210 &self,
211 path: &Path,
212 collection: &str,
213 spec: &DataFileSpec,
214 ) -> Result<usize, EphemeralError> {
215 match spec.format {
216 EphemeralFormat::Delimited(delimiter) => {
217 self.import_csv_into(path, collection, delimiter, &spec.display)
218 }
219 EphemeralFormat::JsonArray => {
220 self.import_json_array_into(path, collection, &spec.display)
221 }
222 EphemeralFormat::Ndjson => self.import_ndjson_into(path, collection, &spec.display),
223 }
224 }
225
226 fn import_csv_into(
229 &self,
230 path: &Path,
231 collection: &str,
232 delimiter: u8,
233 display: &str,
234 ) -> Result<usize, EphemeralError> {
235 let importer = CsvImporter::new(CsvConfig {
236 collection: collection.to_string(),
237 has_header: true,
238 delimiter,
239 skip_errors: false,
240 ..CsvConfig::default()
241 });
242
243 let store = self.inner.db.store();
244 let _ = store.get_or_create_collection(collection);
249 let stats =
250 importer
251 .import_file(path, store.as_ref())
252 .map_err(|e| EphemeralError::Import {
253 path: display.to_string(),
254 source: e.to_string(),
255 })?;
256
257 self.note_table_write(collection);
260
261 Ok(stats.records_imported)
262 }
263
264 fn import_json_array_into(
265 &self,
266 path: &Path,
267 collection: &str,
268 display: &str,
269 ) -> Result<usize, EphemeralError> {
270 let raw = std::fs::read_to_string(path).map_err(|e| EphemeralError::Json {
271 path: display.to_string(),
272 source: e.to_string(),
273 })?;
274 let parsed: crate::serde_json::Value =
275 crate::serde_json::from_str(&raw).map_err(|e| EphemeralError::Json {
276 path: display.to_string(),
277 source: e.to_string(),
278 })?;
279 let crate::serde_json::Value::Array(values) = parsed else {
280 return Err(EphemeralError::JsonShape {
281 path: display.to_string(),
282 source: "top-level JSON value must be an array of document objects".to_string(),
283 });
284 };
285
286 for (idx, value) in values.iter().enumerate() {
287 if !matches!(value, crate::serde_json::Value::Object(_)) {
288 return Err(EphemeralError::JsonShape {
289 path: display.to_string(),
290 source: format!("element {} is not a JSON object", idx + 1),
291 });
292 }
293 }
294
295 self.insert_documents(collection, values, display)
296 }
297
298 fn import_ndjson_into(
299 &self,
300 path: &Path,
301 collection: &str,
302 display: &str,
303 ) -> Result<usize, EphemeralError> {
304 let file = std::fs::File::open(path).map_err(|e| EphemeralError::Json {
305 path: display.to_string(),
306 source: e.to_string(),
307 })?;
308 let mut values = Vec::new();
309 for (idx, line) in BufReader::new(file).lines().enumerate() {
310 let line_number = idx + 1;
311 let line = line.map_err(|e| EphemeralError::Json {
312 path: display.to_string(),
313 source: format!("line {line_number}: {e}"),
314 })?;
315 let trimmed = line.trim();
316 if trimmed.is_empty() {
317 continue;
318 }
319 let value: crate::serde_json::Value =
320 crate::serde_json::from_str(trimmed).map_err(|e| EphemeralError::Json {
321 path: display.to_string(),
322 source: format!("line {line_number}: {e}"),
323 })?;
324 if !matches!(value, crate::serde_json::Value::Object(_)) {
325 return Err(EphemeralError::JsonShape {
326 path: display.to_string(),
327 source: format!("line {line_number} is not a JSON object"),
328 });
329 }
330 values.push(value);
331 }
332
333 self.insert_documents(collection, values, display)
334 }
335
336 fn insert_documents(
337 &self,
338 collection: &str,
339 values: Vec<crate::serde_json::Value>,
340 display: &str,
341 ) -> Result<usize, EphemeralError> {
342 let rows_imported = values.len();
343 self.execute_query(&format!("CREATE DOCUMENT {collection}"))
344 .map_err(|e| EphemeralError::Import {
345 path: display.to_string(),
346 source: e.to_string(),
347 })?;
348
349 for value in values {
350 self.create_document(CreateDocumentInput {
351 collection: collection.to_string(),
352 body: value,
353 metadata: Vec::new(),
354 node_links: Vec::new(),
355 vector_links: Vec::new(),
356 })
357 .map_err(|e| EphemeralError::Import {
358 path: display.to_string(),
359 source: e.to_string(),
360 })?;
361 }
362
363 Ok(rows_imported)
364 }
365}
366
367fn data_file_spec(path: &Path) -> Result<DataFileSpec, EphemeralError> {
368 let display = path.display().to_string();
369
370 if !path.is_file() {
371 return Err(EphemeralError::NotAFile { path: display });
372 }
373
374 let ext = path
375 .extension()
376 .and_then(|e| e.to_str())
377 .unwrap_or("")
378 .to_ascii_lowercase();
379 let format =
380 format_for_extension(&ext).ok_or_else(|| EphemeralError::UnsupportedExtension {
381 path: display.clone(),
382 ext: ext.clone(),
383 })?;
384
385 let stem = path
386 .file_stem()
387 .and_then(|s| s.to_str())
388 .unwrap_or_default();
389 let collection = sanitize_stem(stem).ok_or_else(|| EphemeralError::EmptyStem {
390 path: display.clone(),
391 })?;
392
393 Ok(DataFileSpec {
394 display,
395 base_collection: collection,
396 format,
397 })
398}
399
400fn positional_alias(index: usize, total: usize) -> String {
401 if total == 1 {
402 POSITIONAL_ALIAS.to_string()
403 } else {
404 format!("t{}", index + 1)
405 }
406}
407
408fn unique_collection_name(base: &str, used: &mut BTreeSet<String>) -> String {
409 if used.insert(base.to_string()) {
410 return base.to_string();
411 }
412 for suffix in 2usize.. {
413 let candidate = format!("{base}_{suffix}");
414 if used.insert(candidate.clone()) {
415 return candidate;
416 }
417 }
418 unreachable!("unbounded suffix search must eventually find an unused collection name")
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn sanitize_stem_basic() {
427 assert_eq!(sanitize_stem("data").as_deref(), Some("data"));
428 assert_eq!(sanitize_stem("Users").as_deref(), Some("users"));
429 }
430
431 #[test]
432 fn sanitize_stem_collapses_and_trims() {
433 assert_eq!(
434 sanitize_stem("vendas-2026 (v2)").as_deref(),
435 Some("vendas_2026_v2")
436 );
437 assert_eq!(
438 sanitize_stem("__weird__name__").as_deref(),
439 Some("weird_name")
440 );
441 }
442
443 #[test]
444 fn sanitize_stem_leading_digit_prefixed() {
445 assert_eq!(sanitize_stem("2026sales").as_deref(), Some("_2026sales"));
446 }
447
448 #[test]
449 fn sanitize_stem_all_punctuation_is_none() {
450 assert_eq!(sanitize_stem("---"), None);
451 assert_eq!(sanitize_stem(""), None);
452 }
453
454 #[test]
455 fn delimiter_inference() {
456 assert_eq!(
457 format_for_extension("csv"),
458 Some(EphemeralFormat::Delimited(b','))
459 );
460 assert_eq!(
461 format_for_extension("tsv"),
462 Some(EphemeralFormat::Delimited(b'\t'))
463 );
464 assert_eq!(
465 format_for_extension("tab"),
466 Some(EphemeralFormat::Delimited(b'\t'))
467 );
468 assert_eq!(
469 format_for_extension("json"),
470 Some(EphemeralFormat::JsonArray)
471 );
472 assert_eq!(format_for_extension("jsonl"), Some(EphemeralFormat::Ndjson));
473 assert_eq!(
474 format_for_extension("ndjson"),
475 Some(EphemeralFormat::Ndjson)
476 );
477 assert_eq!(format_for_extension("txt"), None);
478 }
479}