Skip to main content

use_csv/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4/// Basic dialect metadata for simple CSV handling.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub struct CsvDialect {
7    pub delimiter: char,
8    pub quote: char,
9    pub has_header: bool,
10}
11
12/// A parsed CSV row.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct CsvRow {
15    pub fields: Vec<String>,
16    pub line: usize,
17}
18
19/// Returns `true` when the input looks like delimiter-separated data.
20pub fn looks_like_csv(input: &str) -> bool {
21    detect_delimiter(input).is_some()
22}
23
24/// Detects the most likely delimiter from common candidates.
25pub fn detect_delimiter(input: &str) -> Option<char> {
26    let lines: Vec<&str> = input
27        .lines()
28        .filter(|line| !line.trim().is_empty())
29        .take(5)
30        .collect();
31
32    if lines.is_empty() {
33        return None;
34    }
35
36    let mut best: Option<(char, usize, usize, usize)> = None;
37
38    for delimiter in [',', '\t', ';', '|'] {
39        let mut matching_lines = 0;
40        let mut total_columns = 0;
41        let mut expected_columns = None;
42        let mut consistent = true;
43
44        for line in &lines {
45            let columns = split_csv_line_with_delimiter(line, delimiter).len();
46            if columns > 1 {
47                matching_lines += 1;
48                total_columns += columns;
49                if let Some(expected) = expected_columns {
50                    if expected != columns {
51                        consistent = false;
52                    }
53                } else {
54                    expected_columns = Some(columns);
55                }
56            }
57        }
58
59        if matching_lines == 0 {
60            continue;
61        }
62
63        let score = (
64            delimiter,
65            usize::from(consistent),
66            matching_lines,
67            total_columns,
68        );
69
70        match best {
71            Some((_, best_consistent, best_matches, best_columns))
72                if (score.1, score.2, score.3) <= (best_consistent, best_matches, best_columns) => {
73            }
74            _ => best = Some(score),
75        }
76    }
77
78    best.map(|(delimiter, _, _, _)| delimiter)
79}
80
81/// Splits a single CSV line using a detected delimiter or a comma fallback.
82pub fn split_csv_line_basic(line: &str) -> Vec<String> {
83    if line.is_empty() {
84        return Vec::new();
85    }
86
87    let delimiter = detect_delimiter(line).unwrap_or(',');
88    split_csv_line_with_delimiter(line, delimiter)
89}
90
91/// Parses CSV-like input into rows without streaming or encoding support.
92pub fn parse_csv_basic(input: &str) -> Vec<CsvRow> {
93    if input.trim().is_empty() {
94        return Vec::new();
95    }
96
97    let delimiter = detect_delimiter(input).unwrap_or(',');
98    let mut rows = Vec::new();
99
100    for (line_index, line) in input.lines().enumerate() {
101        if line.trim().is_empty() {
102            continue;
103        }
104
105        rows.push(CsvRow {
106            fields: split_csv_line_with_delimiter(line, delimiter),
107            line: line_index + 1,
108        });
109    }
110
111    rows
112}
113
114/// Escapes a field for comma-separated output.
115pub fn csv_escape_field(input: &str) -> String {
116    if input.contains([',', '"', '\n', '\r']) {
117        format!("\"{}\"", input.replace('"', "\"\""))
118    } else {
119        input.to_string()
120    }
121}
122
123/// Joins fields into a comma-separated row.
124pub fn csv_join_row(fields: &[&str]) -> String {
125    fields
126        .iter()
127        .map(|field| csv_escape_field(field))
128        .collect::<Vec<_>>()
129        .join(",")
130}
131
132/// Counts columns in a CSV-like line using the basic splitter.
133pub fn count_csv_columns(line: &str) -> usize {
134    split_csv_line_basic(line).len()
135}
136
137fn split_csv_line_with_delimiter(line: &str, delimiter: char) -> Vec<String> {
138    let mut fields = Vec::new();
139    let mut current = String::new();
140    let mut chars = line.chars().peekable();
141    let mut in_quotes = false;
142
143    while let Some(ch) = chars.next() {
144        if ch == '"' {
145            if in_quotes && chars.peek() == Some(&'"') {
146                current.push('"');
147                chars.next();
148            } else {
149                in_quotes = !in_quotes;
150            }
151            continue;
152        }
153
154        if ch == delimiter && !in_quotes {
155            fields.push(current);
156            current = String::new();
157            continue;
158        }
159
160        current.push(ch);
161    }
162
163    fields.push(current);
164    fields
165}