Skip to main content

datacraft_csv/
lib.rs

1//! CSV directory driver for the DataCraft engine (phase 30, item M18;
2//! ADR-7): the out-of-tree proof that a capability-poor data source docks
3//! through the core-owned [`StoragePort`] alone — this crate depends on
4//! nothing but `datacraft-core`.
5//!
6//! A *database* is a directory; its tables are the `*.csv` files inside.
7//! The header row names the columns, every column is Short Text, and any
8//! write rewrites the whole file once (sibling temp file + rename), so a
9//! call is atomic — either every operation of the call lands or none.
10//! Rows are addressed positionally in file order (data rows after the
11//! header). Non-text domain values render deterministically through the
12//! core's cell display (`Null` becomes an empty cell).
13//!
14//! ## Column type inference (L7)
15//!
16//! The schema kind of each column is inferred at open, deterministically,
17//! from a full scan: all-integer columns become `LongInteger`, all-float
18//! columns `Double`, all-ISO-date columns (`YYYY-MM-DD`) `DateTime`;
19//! anything else — and every ambiguous column — stays Short Text. Quoting
20//! is the user's escape hatch: one quoted cell pins the whole column to
21//! Short Text, and empty cells don't vote. Cell **values** still read as
22//! text; only the schema kind is inferred (the datasheet formats and
23//! parses by the declared kind). The inference and the quote mask live in
24//! `datacraft_core::import` — shared with the `ImportFile` chain (M50).
25
26use std::path::{Path, PathBuf};
27
28use datacraft_core::import::{infer_field_kinds, quote_mask};
29
30use datacraft_core::port::{
31    DatabaseFormat, DriverCapabilities, DriverId, OpenedDatabase, PortError, Relationship,
32    RowRange, StoragePort, Support, window_slice,
33};
34use datacraft_core::schema::{FieldDef, FieldValue, TableDef};
35
36/// One CSV directory as a database.
37#[derive(Debug, Default)]
38pub struct CsvPort {
39    dir: Option<PathBuf>,
40    tables: Vec<TableDef>,
41}
42
43impl CsvPort {
44    /// Creates an unattached driver.
45    pub fn new() -> Self {
46        Self::default()
47    }
48}
49
50impl StoragePort for CsvPort {
51    fn driver_id(&self) -> DriverId {
52        DriverId::new("csv")
53    }
54
55    /// The driver's fixed family (M23): the inventory reports it so a
56    /// composed binary can advertise the CSV driver offline.
57    fn source_format(&self) -> Option<DatabaseFormat> {
58        Some(DatabaseFormat::Csv)
59    }
60
61    /// The honest set (phase 30, M18): table/row/file creation served
62    /// with one atomic rewrite per call; no schema alteration, no
63    /// passwords, no engine-assigned AutoNumbers, and none of the
64    /// Jet-style inventories.
65    fn capabilities(&self) -> DriverCapabilities {
66        DriverCapabilities {
67            read_tables: true,
68            write_rows: true,
69            create_table: true,
70            drop_table: true,
71            create_database: true,
72            alter_table: false,
73            rename_table: false,
74            atomic_batch: true,
75            passwords: false,
76            autonumber: Support::No,
77            relationships: Support::No,
78            native_queries: Support::No,
79            macros: Support::No,
80            vba: Support::No,
81            triggers: Support::No,
82            stored_procedures: Support::No,
83        }
84    }
85
86    fn open(&mut self, path: &Path, _password: Option<&str>) -> Result<OpenedDatabase, PortError> {
87        if !path.is_dir() {
88            return Err(PortError::OpenFailed {
89                detail: format!(
90                    "{}: not a CSV directory — this driver opens directories of *.csv tables",
91                    path.display()
92                ),
93            });
94        }
95        let tables = collect_tables(path)?;
96        let name = path.file_stem().map_or_else(
97            || "database".to_owned(),
98            |s| s.to_string_lossy().into_owned(),
99        );
100        self.tables = tables;
101        self.dir = Some(path.to_path_buf());
102        Ok(OpenedDatabase {
103            name,
104            format: DatabaseFormat::Csv,
105            lock_file: None,
106        })
107    }
108
109    fn close(&mut self) {
110        self.dir = None;
111        self.tables.clear();
112    }
113
114    fn create_database(&mut self, path: &Path, format: DatabaseFormat) -> Result<(), PortError> {
115        if format != DatabaseFormat::Csv {
116            return Err(PortError::Unsupported {
117                reason: "the CSV driver creates CSV directories only — use the matching \
118                         backend for Jet4 or SQLite files"
119                    .to_owned(),
120            });
121        }
122        if path.exists() {
123            return Err(PortError::WriteFailed {
124                detail: format!(
125                    "{}: already exists — creating would overwrite it",
126                    path.display()
127                ),
128            });
129        }
130        std::fs::create_dir_all(path).map_err(|e| PortError::WriteFailed {
131            detail: format!("{}: {e}", path.display()),
132        })
133    }
134
135    fn tables(&self) -> Vec<TableDef> {
136        self.tables.clone()
137    }
138
139    fn create_table(&mut self, definition: &TableDef) -> Result<(), PortError> {
140        let dir = self.dir.as_ref().ok_or_else(no_database)?;
141        // The Jet and SQLite drivers validate; a CSV header cannot carry an
142        // empty or duplicate name either (the open path would reject it).
143        definition.validate().map_err(|e| PortError::WriteFailed {
144            detail: format!("table {:?}: {e}", definition.name),
145        })?;
146        let file_name = table_file_name(&definition.name)?;
147        let file = dir.join(file_name);
148        if file.exists() {
149            return Err(PortError::WriteFailed {
150                detail: format!("{}: table already exists", definition.name),
151            });
152        }
153        let header = definition
154            .fields
155            .iter()
156            .map(|f| f.name.clone())
157            .collect::<Vec<String>>();
158        write_rows(&file, &[header])?;
159        // The in-memory catalog grows with the directory (sorted, like
160        // the listing at open).
161        self.tables.push(definition.clone());
162        self.tables.sort_by(|a, b| a.name.cmp(&b.name));
163        Ok(())
164    }
165
166    fn drop_table(&mut self, name: &str) -> Result<String, PortError> {
167        let dir = self.dir.as_ref().ok_or_else(no_database)?;
168        let (canonical, _) = self
169            .tables
170            .iter()
171            .find(|def| def.name.eq_ignore_ascii_case(name))
172            .ok_or_else(|| PortError::TableNotFound(name.to_owned()))
173            .map(|def| (def.name.clone(), ()))?;
174        let file = dir.join(table_file_name(&canonical)?);
175        std::fs::remove_file(&file).map_err(|e| PortError::WriteFailed {
176            detail: format!("{}: {e}", file.display()),
177        })?;
178        self.tables.retain(|def| def.name != canonical);
179        Ok(canonical)
180    }
181
182    fn read_rows(&self, table: &str, range: RowRange) -> Result<Vec<Vec<FieldValue>>, PortError> {
183        let rows = self.rows_of(table)?;
184        let (start, end) = window_slice(table, range, rows.len())?;
185        Ok(rows[start..end]
186            .iter()
187            .map(|row| {
188                row.iter()
189                    .map(|cell| FieldValue::Text(cell.clone()))
190                    .collect()
191            })
192            .collect())
193    }
194
195    fn insert_row(&mut self, table: &str, values: &[FieldValue]) -> Result<(), PortError> {
196        let arity = self.definition_of(table)?.fields.len();
197        let canonical = self.definition_of(table)?.name.clone();
198        if values.len() != arity {
199            return Err(PortError::WriteFailed {
200                detail: format!(
201                    "insert into {:?}: expected {} values, found {}",
202                    canonical,
203                    arity,
204                    values.len()
205                ),
206            });
207        }
208        let mut rows = self.rows_of(&canonical)?;
209        rows.push(values.iter().map(to_cell).collect::<Vec<String>>());
210        self.write_table(&canonical, rows)
211    }
212
213    fn update_row(
214        &mut self,
215        table: &str,
216        row: usize,
217        values: &[FieldValue],
218    ) -> Result<(), PortError> {
219        let arity = self.definition_of(table)?.fields.len();
220        let canonical = self.definition_of(table)?.name.clone();
221        if values.len() != arity {
222            return Err(PortError::WriteFailed {
223                detail: format!(
224                    "update {:?}: expected {} values, found {}",
225                    canonical,
226                    arity,
227                    values.len()
228                ),
229            });
230        }
231        let mut rows = self.rows_of(&canonical)?;
232        if row >= rows.len() {
233            return Err(out_of_range(&canonical, row, rows.len()));
234        }
235        let target = &mut rows[row];
236        *target = values.iter().map(to_cell).collect::<Vec<String>>();
237        self.write_table(&canonical, rows)
238    }
239
240    fn delete_row(&mut self, table: &str, row: usize) -> Result<(), PortError> {
241        let canonical = self.definition_of(table)?.name.clone();
242        let mut rows = self.rows_of(&canonical)?;
243        if row >= rows.len() {
244            return Err(out_of_range(&canonical, row, rows.len()));
245        }
246        rows.remove(row);
247        self.write_table(&canonical, rows)
248    }
249
250    /// One read-modify-write per batch: every operation applies to the
251    /// in-memory row set and the file is rewritten once — the batch is
252    /// atomic (ADR-7 `atomic_batch`).
253    fn write_row_ops(
254        &mut self,
255        table: &str,
256        ops: &[datacraft_core::port::RowWriteOp],
257    ) -> Result<(), PortError> {
258        let arity = self.definition_of(table)?.fields.len();
259        let canonical = self.definition_of(table)?.name.clone();
260        let mut rows = self.rows_of(&canonical)?;
261        for op in ops {
262            match op {
263                datacraft_core::port::RowWriteOp::Insert(values) => {
264                    if values.len() != arity {
265                        return Err(PortError::WriteFailed {
266                            detail: format!(
267                                "batch insert into {:?}: expected {} values, found {}",
268                                canonical,
269                                arity,
270                                values.len()
271                            ),
272                        });
273                    }
274                    rows.push(values.iter().map(to_cell).collect::<Vec<String>>());
275                }
276                datacraft_core::port::RowWriteOp::Update { row, values } => {
277                    if values.len() != arity {
278                        return Err(PortError::WriteFailed {
279                            detail: format!(
280                                "batch update of {:?}: expected {} values, found {}",
281                                canonical,
282                                arity,
283                                values.len()
284                            ),
285                        });
286                    }
287                    if *row >= rows.len() {
288                        return Err(out_of_range(&canonical, *row, rows.len()));
289                    }
290                    let target = &mut rows[*row];
291                    *target = values.iter().map(to_cell).collect::<Vec<String>>();
292                }
293                datacraft_core::port::RowWriteOp::Delete { row } => {
294                    if *row >= rows.len() {
295                        return Err(out_of_range(&canonical, *row, rows.len()));
296                    }
297                    rows.remove(*row);
298                }
299            }
300        }
301        self.write_table(&canonical, rows)
302    }
303
304    fn relationships(&self) -> Result<Vec<Relationship>, PortError> {
305        Ok(Vec::new())
306    }
307}
308
309impl CsvPort {
310    /// The canonical definition of a catalogued table.
311    fn definition_of(&self, table: &str) -> Result<&TableDef, PortError> {
312        self.tables
313            .iter()
314            .find(|def| def.name.eq_ignore_ascii_case(table))
315            .ok_or_else(|| PortError::TableNotFound(table.to_owned()))
316    }
317
318    /// Data rows of one table, parsed fresh per call (stateless,
319    /// deterministic).
320    fn rows_of(&self, table: &str) -> Result<Vec<Vec<String>>, PortError> {
321        let dir = self.dir.as_ref().ok_or_else(no_database)?;
322        let def = self.definition_of(table)?;
323        let file = dir.join(table_file_name(&def.name)?);
324        let records = parse_records(&file, &read_table_text(&file)?)?;
325        Ok(records.into_iter().skip(1).collect())
326    }
327
328    /// Rewrites one table file in a single atomic replace.
329    fn write_table(&mut self, table: &str, rows: Vec<Vec<String>>) -> Result<(), PortError> {
330        let dir = self.dir.as_ref().ok_or_else(no_database)?;
331        let def = self.definition_of(table)?.clone();
332        let header: Vec<String> = def.fields.iter().map(|f| f.name.clone()).collect();
333        let file = dir.join(table_file_name(&def.name)?);
334        let mut all = Vec::with_capacity(rows.len() + 1);
335        all.push(header);
336        all.extend(rows);
337        write_rows(&file, &all)
338    }
339}
340
341/// Maps a domain value onto a CSV cell deterministically.
342fn to_cell(value: &FieldValue) -> String {
343    match value {
344        FieldValue::Null => String::new(),
345        FieldValue::Text(text) => text.clone(),
346        other => datacraft_core::present::display_value(other),
347    }
348}
349
350/// Table names become file names: alphanumeric plus `_`/`-`, so a
351/// hostile name can never escape the directory (LESSONS #1).
352fn table_file_name(name: &str) -> Result<String, PortError> {
353    let ok = !name.is_empty()
354        && name
355            .chars()
356            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
357    if !ok {
358        return Err(PortError::WriteFailed {
359            detail: format!(
360                "table name {name:?} is not usable as a CSV file name — use letters, \
361                 digits, '_' and '-'"
362            ),
363        });
364    }
365    Ok(format!("{name}.csv"))
366}
367
368/// Catalogues the directory's `*.csv` files in sorted stem order.
369fn collect_tables(dir: &Path) -> Result<Vec<TableDef>, PortError> {
370    let mut names: Vec<String> = std::fs::read_dir(dir)
371        .map_err(|e| PortError::OpenFailed {
372            detail: format!("{}: {e}", dir.display()),
373        })?
374        .filter_map(|entry| entry.ok())
375        .filter(|entry| entry.path().is_file())
376        .filter_map(|entry| {
377            let path = entry.path();
378            let stem = path.file_stem()?.to_string_lossy().into_owned();
379            let extension = path.extension()?.to_string_lossy().to_lowercase();
380            (extension == "csv").then_some(stem)
381        })
382        .collect();
383    names.sort();
384    let mut tables = Vec::with_capacity(names.len());
385    for stem in names {
386        let file = dir.join(format!("{stem}.csv"));
387        let text = read_table_text(&file)?;
388        let records = parse_records(&file, &text)?;
389        let header = records.first().ok_or_else(|| PortError::OpenFailed {
390            detail: format!("{}: the CSV file has no header row", file.display()),
391        })?;
392        let mask = quote_mask(&text);
393        let kinds = infer_field_kinds(&records, &mask);
394        let mut fields = Vec::with_capacity(header.len());
395        for (index, column) in header.iter().enumerate() {
396            if column.is_empty() {
397                return Err(PortError::OpenFailed {
398                    detail: format!("{}: header column {index} has no name", file.display()),
399                });
400            }
401            fields.push(FieldDef::new(column.clone(), kinds[index]));
402        }
403        tables.push(TableDef::new(stem, fields));
404    }
405    Ok(tables)
406}
407
408/// Reads one table file as UTF-8 text with a leading BOM stripped — the
409/// single read that feeds parsing and the L7 quote mask alike;
410/// non-UTF-8 content fails with a locatable reason (LESSONS #4).
411fn read_table_text(file: &Path) -> Result<String, PortError> {
412    let bytes = std::fs::read(file).map_err(|e| PortError::OpenFailed {
413        detail: format!("{}: {e}", file.display()),
414    })?;
415    let text = String::from_utf8(bytes).map_err(|e| PortError::OpenFailed {
416        detail: format!("{}: not valid UTF-8: {e}", file.display()),
417    })?;
418    match text.strip_prefix('\u{feff}') {
419        Some(stripped) => Ok(stripped.to_owned()),
420        None => Ok(text),
421    }
422}
423
424/// Parses one CSV text into uniform records; ragged rows fail with a
425/// locatable reason naming the file (LESSONS #4). The caller reads the
426/// file once (the same text feeds the L7 quote mask).
427fn parse_records(file: &Path, text: &str) -> Result<Vec<Vec<String>>, PortError> {
428    let mut reader = csv::ReaderBuilder::new()
429        .has_headers(false)
430        .flexible(false)
431        .from_reader(text.as_bytes());
432    let mut records: Vec<Vec<String>> = Vec::new();
433    for (index, record) in reader.records().enumerate() {
434        let record = record.map_err(|e| PortError::OpenFailed {
435            detail: format!("{}: row {}: {e}", file.display(), index + 1),
436        })?;
437        // `flexible(false)` makes a ragged row a reader *error*, so every
438        // pushed record has the header's arity by construction.
439        records.push(record.into_iter().map(String::from).collect());
440    }
441    Ok(records)
442}
443
444/// Writes one CSV file through a sibling temp file renamed over the
445/// target: an interrupted write can never truncate the table, and the
446/// rename makes the call atomic.
447fn write_rows(file: &Path, rows: &[Vec<String>]) -> Result<(), PortError> {
448    let temp = file.with_extension("csv.tmp");
449    let write = || -> Result<(), PortError> {
450        let mut writer = csv::Writer::from_path(&temp).map_err(|e| PortError::WriteFailed {
451            detail: format!("{}: {e}", temp.display()),
452        })?;
453        for row in rows {
454            writer
455                .write_record(row)
456                .map_err(|e| PortError::WriteFailed {
457                    detail: format!("{}: {e}", temp.display()),
458                })?;
459        }
460        writer.flush().map_err(|e| PortError::WriteFailed {
461            detail: format!("{}: {e}", temp.display()),
462        })?;
463        std::fs::rename(&temp, file).map_err(|e| PortError::WriteFailed {
464            detail: format!("{} -> {}: {e}", temp.display(), file.display()),
465        })?;
466        Ok(())
467    };
468    let outcome = write();
469    if outcome.is_err() {
470        let _ = std::fs::remove_file(&temp);
471    }
472    outcome
473}
474
475fn out_of_range(table: &str, row: usize, row_count: usize) -> PortError {
476    PortError::OutOfRange {
477        table: table.to_owned(),
478        start: row,
479        len: 1,
480        row_count,
481    }
482}
483
484fn no_database() -> PortError {
485    PortError::Unsupported {
486        reason: "no database is open".to_owned(),
487    }
488}