use-csv 0.1.0

Lightweight CSV delimiter, row, and field helpers for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

/// Basic dialect metadata for simple CSV handling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CsvDialect {
    pub delimiter: char,
    pub quote: char,
    pub has_header: bool,
}

/// A parsed CSV row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CsvRow {
    pub fields: Vec<String>,
    pub line: usize,
}

/// Returns `true` when the input looks like delimiter-separated data.
pub fn looks_like_csv(input: &str) -> bool {
    detect_delimiter(input).is_some()
}

/// Detects the most likely delimiter from common candidates.
pub fn detect_delimiter(input: &str) -> Option<char> {
    let lines: Vec<&str> = input
        .lines()
        .filter(|line| !line.trim().is_empty())
        .take(5)
        .collect();

    if lines.is_empty() {
        return None;
    }

    let mut best: Option<(char, usize, usize, usize)> = None;

    for delimiter in [',', '\t', ';', '|'] {
        let mut matching_lines = 0;
        let mut total_columns = 0;
        let mut expected_columns = None;
        let mut consistent = true;

        for line in &lines {
            let columns = split_csv_line_with_delimiter(line, delimiter).len();
            if columns > 1 {
                matching_lines += 1;
                total_columns += columns;
                if let Some(expected) = expected_columns {
                    if expected != columns {
                        consistent = false;
                    }
                } else {
                    expected_columns = Some(columns);
                }
            }
        }

        if matching_lines == 0 {
            continue;
        }

        let score = (
            delimiter,
            usize::from(consistent),
            matching_lines,
            total_columns,
        );

        match best {
            Some((_, best_consistent, best_matches, best_columns))
                if (score.1, score.2, score.3) <= (best_consistent, best_matches, best_columns) => {
            }
            _ => best = Some(score),
        }
    }

    best.map(|(delimiter, _, _, _)| delimiter)
}

/// Splits a single CSV line using a detected delimiter or a comma fallback.
pub fn split_csv_line_basic(line: &str) -> Vec<String> {
    if line.is_empty() {
        return Vec::new();
    }

    let delimiter = detect_delimiter(line).unwrap_or(',');
    split_csv_line_with_delimiter(line, delimiter)
}

/// Parses CSV-like input into rows without streaming or encoding support.
pub fn parse_csv_basic(input: &str) -> Vec<CsvRow> {
    if input.trim().is_empty() {
        return Vec::new();
    }

    let delimiter = detect_delimiter(input).unwrap_or(',');
    let mut rows = Vec::new();

    for (line_index, line) in input.lines().enumerate() {
        if line.trim().is_empty() {
            continue;
        }

        rows.push(CsvRow {
            fields: split_csv_line_with_delimiter(line, delimiter),
            line: line_index + 1,
        });
    }

    rows
}

/// Escapes a field for comma-separated output.
pub fn csv_escape_field(input: &str) -> String {
    if input.contains([',', '"', '\n', '\r']) {
        format!("\"{}\"", input.replace('"', "\"\""))
    } else {
        input.to_string()
    }
}

/// Joins fields into a comma-separated row.
pub fn csv_join_row(fields: &[&str]) -> String {
    fields
        .iter()
        .map(|field| csv_escape_field(field))
        .collect::<Vec<_>>()
        .join(",")
}

/// Counts columns in a CSV-like line using the basic splitter.
pub fn count_csv_columns(line: &str) -> usize {
    split_csv_line_basic(line).len()
}

fn split_csv_line_with_delimiter(line: &str, delimiter: char) -> Vec<String> {
    let mut fields = Vec::new();
    let mut current = String::new();
    let mut chars = line.chars().peekable();
    let mut in_quotes = false;

    while let Some(ch) = chars.next() {
        if ch == '"' {
            if in_quotes && chars.peek() == Some(&'"') {
                current.push('"');
                chars.next();
            } else {
                in_quotes = !in_quotes;
            }
            continue;
        }

        if ch == delimiter && !in_quotes {
            fields.push(current);
            current = String::new();
            continue;
        }

        current.push(ch);
    }

    fields.push(current);
    fields
}