Skip to main content

joaju_csv/
lib.rs

1//! # joaju-csv
2//!
3//! A fast and deterministic Rust library for tabular CSV manipulation,
4//! relational projections, filtering, and header canonicalization.
5//!
6//! > ⚠️ **Early Development Notice (pre-1.0 / alpha)**: This library is currently in early
7//! > active development (`0.1.0`). It is NOT recommended for production use beyond experimental
8//! > testing and research. APIs are subject to breaking changes prior to a stable 1.0.0 release.
9//!
10//! ## Quick Example
11//!
12//! ```rust
13//! use joaju_csv::Dataset;
14//!
15//! let csv = "id,name,age\n1,Ana,30\n2,Bruno,25\n";
16//! let ds = Dataset::from_csv(csv).unwrap();
17//!
18//! let filtered = ds.apply_where("age >= 30").unwrap();
19//! assert_eq!(filtered.row_count(), 1);
20//!
21//! let projected = filtered.apply_select("name as Employee").unwrap();
22//! assert_eq!(projected.headers(), &["Employee"]);
23//! ```
24
25use regex::Regex;
26use std::collections::HashMap;
27use std::fs;
28use std::path::Path;
29use thiserror::Error;
30
31#[derive(Error, Debug)]
32pub enum CsvError {
33    #[error("I/O error: {0}")]
34    Io(#[from] std::io::Error),
35
36    #[error("CSV parsing error: {0}")]
37    Csv(#[from] csv::Error),
38
39    #[error("Column not found: {0}")]
40    ColumnNotFound(String),
41
42    #[error("Invalid WHERE clause: {0}")]
43    InvalidWhereClause(String),
44
45    #[error("Invalid SELECT clause: {0}")]
46    InvalidSelectClause(String),
47
48    #[error("Empty dataset")]
49    EmptyDataset,
50}
51
52pub type SsotError = CsvError;
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Dataset {
56    headers: Vec<String>,
57    rows: Vec<Vec<String>>,
58}
59
60impl Dataset {
61    pub fn new(headers: Vec<String>, rows: Vec<Vec<String>>) -> Self {
62        Self { headers, rows }
63    }
64
65    pub fn headers(&self) -> &[String] {
66        &self.headers
67    }
68
69    pub fn row_count(&self) -> usize {
70        self.rows.len()
71    }
72
73    pub fn column_count(&self) -> usize {
74        self.headers.len()
75    }
76
77    pub fn get_row(&self, index: usize) -> Option<&[String]> {
78        self.rows.get(index).map(|r| r.as_slice())
79    }
80
81    pub fn rows(&self) -> &[Vec<String>] {
82        &self.rows
83    }
84
85    pub fn from_csv(content: &str) -> Result<Self, CsvError> {
86        let clean_content: Vec<&str> = content
87            .lines()
88            .map(|l| l.trim())
89            .filter(|l| !l.is_empty() && !l.starts_with("%%") && !l.starts_with('#'))
90            .collect();
91
92        if clean_content.is_empty() {
93            return Err(CsvError::EmptyDataset);
94        }
95
96        let first_line = clean_content[0];
97        let delimiter = if first_line.contains('\t') {
98            b'\t'
99        } else if first_line.contains(';') && !first_line.contains(',') {
100            b';'
101        } else {
102            b','
103        };
104
105        let joined_body = clean_content.join("\n");
106        let mut reader = csv::ReaderBuilder::new()
107            .delimiter(delimiter)
108            .has_headers(true)
109            .flexible(true)
110            .trim(csv::Trim::All)
111            .from_reader(joined_body.as_bytes());
112
113        let headers = reader
114            .headers()?
115            .iter()
116            .map(|h| h.trim().to_string())
117            .collect::<Vec<_>>();
118
119        let mut rows = Vec::new();
120        for record in reader.records() {
121            let rec = record?;
122            let mut row = Vec::with_capacity(headers.len());
123            for field in rec.iter() {
124                row.push(field.trim().to_string());
125            }
126            while row.len() < headers.len() {
127                row.push(String::new());
128            }
129            rows.push(row);
130        }
131
132        Ok(Self { headers, rows })
133    }
134
135    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, CsvError> {
136        let content = fs::read_to_string(path)?;
137        Self::from_csv(&content)
138    }
139
140    pub fn to_csv(&self) -> Result<String, CsvError> {
141        let mut wtr = csv::WriterBuilder::new().from_writer(vec![]);
142        wtr.write_record(&self.headers)?;
143        for row in &self.rows {
144            wtr.write_record(row)?;
145        }
146        wtr.flush()?;
147        let bytes = wtr.into_inner().map_err(|e| e.into_error())?;
148        Ok(String::from_utf8_lossy(&bytes).to_string())
149    }
150
151    pub fn apply_select(&self, select_expr: &str) -> Result<Self, CsvError> {
152        let trimmed = select_expr.trim();
153        if trimmed.is_empty() || trimmed == "*" {
154            return Ok(self.clone());
155        }
156
157        let mut projected_headers = Vec::new();
158        let mut col_indices = Vec::new();
159
160        let re_alias = Regex::new(r"(?i)^(.+?)\s+as\s+(.+)$").unwrap();
161
162        for item in trimmed.split(',') {
163            let raw_item = item.trim();
164            if raw_item.is_empty() {
165                continue;
166            }
167
168            let (src_name, target_name) = if let Some(caps) = re_alias.captures(raw_item) {
169                let src = caps.get(1).unwrap().as_str().trim();
170                let dst = caps.get(2).unwrap().as_str().trim();
171                (src, dst)
172            } else {
173                (raw_item, raw_item)
174            };
175
176            let idx = find_pedigree_synonym_column(src_name, &self.headers)
177                .ok_or_else(|| CsvError::ColumnNotFound(src_name.to_string()))?;
178
179            col_indices.push(idx);
180            projected_headers.push(target_name.to_string());
181        }
182
183        if projected_headers.is_empty() {
184            return Err(CsvError::InvalidSelectClause(
185                "No columns selected".to_string(),
186            ));
187        }
188
189        let mut projected_rows = Vec::with_capacity(self.rows.len());
190        for row in &self.rows {
191            let mut new_row = Vec::with_capacity(col_indices.len());
192            for &idx in &col_indices {
193                new_row.push(row.get(idx).cloned().unwrap_or_default());
194            }
195            projected_rows.push(new_row);
196        }
197
198        Ok(Self {
199            headers: projected_headers,
200            rows: projected_rows,
201        })
202    }
203
204    pub fn apply_where(&self, where_expr: &str) -> Result<Self, CsvError> {
205        let trimmed = where_expr.trim();
206        if trimmed.is_empty() {
207            return Ok(self.clone());
208        }
209
210        let clauses: Vec<&str> = if trimmed.contains(" and ") {
211            trimmed.split(" and ").collect()
212        } else if trimmed.contains(" AND ") {
213            trimmed.split(" AND ").collect()
214        } else if trimmed.contains("&&") {
215            trimmed.split("&&").collect()
216        } else {
217            vec![trimmed]
218        };
219
220        let parsed_clauses: Result<Vec<Condition>, CsvError> = clauses
221            .iter()
222            .map(|c| Condition::parse(c.trim(), &self.headers))
223            .collect();
224
225        let conditions = parsed_clauses?;
226
227        let mut filtered_rows = Vec::new();
228        for row in &self.rows {
229            let mut matches_all = true;
230            for cond in &conditions {
231                if !cond.evaluate(row) {
232                    matches_all = false;
233                    break;
234                }
235            }
236            if matches_all {
237                filtered_rows.push(row.clone());
238            }
239        }
240
241        Ok(Self {
242            headers: self.headers.clone(),
243            rows: filtered_rows,
244        })
245    }
246
247    pub fn apply_query(
248        &self,
249        select_expr: Option<&str>,
250        where_expr: Option<&str>,
251    ) -> Result<Self, CsvError> {
252        let mut result = if let Some(w) = where_expr {
253            self.apply_where(w)?
254        } else {
255            self.clone()
256        };
257
258        if let Some(s) = select_expr {
259            result = result.apply_select(s)?;
260        }
261
262        Ok(result)
263    }
264
265    pub fn canonicalize_pedigree_pt(&self) -> Self {
266        let new_headers = self
267            .headers
268            .iter()
269            .map(|h| {
270                let lower = h.to_lowercase();
271                for (pt, _, synonyms) in PEDIGREE_SYNONYM_GROUPS {
272                    if synonyms.iter().any(|&s| s == lower) {
273                        return pt.to_string();
274                    }
275                }
276                h.clone()
277            })
278            .collect();
279
280        Self {
281            headers: new_headers,
282            rows: self.rows.clone(),
283        }
284    }
285
286    pub fn canonicalize_pedigree_official(&self) -> Self {
287        let new_headers = self
288            .headers
289            .iter()
290            .map(|h| {
291                let lower = h.to_lowercase();
292                for (_, official, synonyms) in PEDIGREE_SYNONYM_GROUPS {
293                    if synonyms.iter().any(|&s| s == lower) {
294                        return official.to_string();
295                    }
296                }
297                h.clone()
298            })
299            .collect();
300
301        Self {
302            headers: new_headers,
303            rows: self.rows.clone(),
304        }
305    }
306}
307
308pub fn parse_block_directives(block: &str) -> HashMap<String, String> {
309    let mut map = HashMap::new();
310    for line in block.lines() {
311        let trimmed = line.trim();
312        if trimmed.starts_with("%%") {
313            let body = trimmed.trim_start_matches('%').trim();
314            if let Some((k, v)) = body.split_once('=') {
315                map.insert(k.trim().to_lowercase(), v.trim().to_string());
316            }
317        }
318    }
319    map
320}
321
322pub fn split_block_content(block: &str) -> (HashMap<String, String>, String) {
323    let directives = parse_block_directives(block);
324    let body_lines: Vec<&str> = block
325        .lines()
326        .map(|l| l.trim())
327        .filter(|l| !l.starts_with("%%"))
328        .collect();
329    (directives, body_lines.join("\n"))
330}
331
332#[derive(Debug, Clone)]
333enum Operator {
334    Eq,
335    Neq,
336    Lte,
337    Gte,
338    Lt,
339    Gt,
340}
341
342#[derive(Debug, Clone)]
343struct Condition {
344    col_index: usize,
345    op: Operator,
346    target_value: String,
347}
348
349impl Condition {
350    fn parse(clause: &str, headers: &[String]) -> Result<Self, CsvError> {
351        let raw = clause.trim();
352        let ops = [
353            ("<=", Operator::Lte),
354            (">=", Operator::Gte),
355            ("!=", Operator::Neq),
356            ("==", Operator::Eq),
357            ("=", Operator::Eq),
358            ("<", Operator::Lt),
359            (">", Operator::Gt),
360        ];
361
362        for (sym, op) in ops {
363            if let Some((left, right)) = raw.split_once(sym) {
364                let col_name = left.trim();
365                let target = right
366                    .trim()
367                    .trim_matches('"')
368                    .trim_matches('\'')
369                    .to_string();
370
371                let col_index = find_pedigree_synonym_column(col_name, headers)
372                    .ok_or_else(|| CsvError::ColumnNotFound(col_name.to_string()))?;
373
374                return Ok(Self {
375                    col_index,
376                    op,
377                    target_value: target,
378                });
379            }
380        }
381
382        Err(CsvError::InvalidWhereClause(format!(
383            "Unrecognized operator in '{}'",
384            raw
385        )))
386    }
387
388    fn evaluate(&self, row: &[String]) -> bool {
389        let val = match row.get(self.col_index) {
390            Some(v) => v.trim(),
391            None => "",
392        };
393
394        if let (Ok(num_val), Ok(num_target)) =
395            (val.parse::<f64>(), self.target_value.parse::<f64>())
396        {
397            match self.op {
398                Operator::Eq => (num_val - num_target).abs() < f64::EPSILON,
399                Operator::Neq => (num_val - num_target).abs() >= f64::EPSILON,
400                Operator::Lte => num_val <= num_target,
401                Operator::Gte => num_val >= num_target,
402                Operator::Lt => num_val < num_target,
403                Operator::Gt => num_val > num_target,
404            }
405        } else {
406            let left = val.to_lowercase();
407            let right = self.target_value.to_lowercase();
408            match self.op {
409                Operator::Eq => left == right,
410                Operator::Neq => left != right,
411                Operator::Lte => left <= right,
412                Operator::Gte => left >= right,
413                Operator::Lt => left < right,
414                Operator::Gt => left > right,
415            }
416        }
417    }
418}
419
420pub const PEDIGREE_SYNONYM_GROUPS: &[(&str, &str, &[&str])] = &[
421    ("id", "id", &["id", "individual", "codigo", "individuo"]),
422    (
423        "pai",
424        "fid",
425        &["pai", "fid", "father", "dadid", "progenitor", "pat"],
426    ),
427    (
428        "mae",
429        "mid",
430        &["mae", "mid", "mother", "momid", "progenitora", "mat"],
431    ),
432    ("sexo", "sex", &["sexo", "sex", "gender"]),
433    (
434        "fenotipo",
435        "aff",
436        &[
437            "fenotipo",
438            "aff",
439            "affected",
440            "afetado",
441            "status_clinico",
442            "phenotype",
443        ],
444    ),
445    (
446        "probando",
447        "proband",
448        &["probando", "probanda", "proband", "propositus", "proposito"],
449    ),
450    (
451        "falecido",
452        "status",
453        &["falecido", "deceased", "status", "obito"],
454    ),
455    (
456        "rotulo",
457        "label",
458        &["rotulo", "label", "rotulo_ped", "text", "nome"],
459    ),
460];
461
462pub fn find_pedigree_synonym_column(target: &str, headers: &[String]) -> Option<usize> {
463    if let Some(pos) = headers.iter().position(|h| h.eq_ignore_ascii_case(target)) {
464        return Some(pos);
465    }
466
467    let lower_target = target.to_lowercase();
468    for (_, _, synonyms) in PEDIGREE_SYNONYM_GROUPS {
469        if synonyms.iter().any(|&s| s == lower_target) {
470            for (idx, h) in headers.iter().enumerate() {
471                let lower_h = h.to_lowercase();
472                if synonyms.iter().any(|&s| s == lower_h) {
473                    return Some(idx);
474                }
475            }
476        }
477    }
478
479    None
480}