1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[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#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct CsvRow {
15 pub fields: Vec<String>,
16 pub line: usize,
17}
18
19pub fn looks_like_csv(input: &str) -> bool {
21 detect_delimiter(input).is_some()
22}
23
24pub 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
81pub 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
91pub 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
114pub 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
123pub 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
132pub 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}