Skip to content

List Converter — Rust source

Transform a list between separators (newline, comma, space, pipe, semicolon, tab) with trim, dedupe, sort, and empty-removal options. Runs entirely in your browser, with a shareable link.

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! list-converter — convert lists between separators (newline/comma/space/pipe/...).
//!
//! Language: Rust (edition 2021, standard library only)
//! Source:   CosmoDev polyglot showcase port of the List Converter tool, ported
//!           from cli/list-converter/list-converter.go (the Go CLI twin), which
//!           is itself the lock-step mirror of src/lib/list-converter.ts (the
//!           canonical TypeScript implementation).
//! License:  display source — part of CosmoDev's polyglot tool pages.
//!
//! Design goals:
//!   - Pure + deterministic; never panics (the public API returns `String`).
//!   - Functionally equivalent to the Go twin: same inputs -> same outputs.
//!   - Self-contained: std only (no crates.io dependencies).
//!
//! Pipeline: split on the "from" separator -> (trim each item) -> (drop empties)
//! -> (dedup, optionally case-insensitive, first occurrence wins) ->
//! (stable sort, optionally case-insensitive) -> join with the "to" separator.
//! The `Separator` enum, `Options`, and `convert` mirror the Go types/`Convert`
//! exactly; defaults are From=Newline, To=Comma (the TS `resolveSep` fallbacks).

use std::cmp::Ordering;

/// A built-in list separator. Mirrors the `Separator` enum in the Go twin; the
/// default `Newline` matches the TS `from` default.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Separator {
    #[default]
    Newline,
    Comma,
    Space,
    Pipe,
    Semicolon,
    Tab,
}

impl Separator {
    /// The literal string this separator splits/joins on. Mirrors Go's
    /// `sepString`. Returns `'static str` so joining needs no allocation for
    /// the glue itself.
    pub fn as_str(self) -> &'static str {
        match self {
            Separator::Comma => ",",
            Separator::Space => " ",
            Separator::Pipe => "|",
            Separator::Semicolon => ";",
            Separator::Tab => "\t",
            Separator::Newline => "\n",
        }
    }
}

/// Options mirror the Go twin's `Options`. `Default` reproduces the TS
/// `resolveSep` fallbacks (from -> newline, to -> comma); every flag defaults
/// to off, so callers can override incrementally with `..Default::default()`.
///
/// `Default` is hand-rolled (not derived) precisely because the two separator
/// fields need DIFFERENT defaults: `from` -> [`Separator::Newline`], `to` ->
/// [`Separator::Comma`]. A derived impl would give both fields
/// [`Separator::Newline`] (the enum's `#[default]`), which would make the
/// common "newline list to comma list" case a no-op.
#[derive(Debug, Clone)]
pub struct Options {
    /// Source separator. Defaults to [`Separator::Newline`].
    pub from: Separator,
    /// Target separator. Defaults to [`Separator::Comma`] (the TS `to` default).
    pub to: Separator,
    /// `trim()` each split item (runs before `remove_empty`).
    pub trim: bool,
    /// Drop items equal to `""` (after `trim` if enabled).
    pub remove_empty: bool,
    /// Keep the first occurrence of each item, dropping later duplicates.
    pub unique: bool,
    /// Stable-sort the items ascending.
    pub sort: bool,
    /// When set, `unique`/`sort` compare lowercased keys but keep original
    /// casing in the output. Mirrors Go's `CaseInsensitive` flag.
    pub case_insensitive: bool,
}

impl Default for Options {
    fn default() -> Self {
        Options {
            from: Separator::Newline,
            to: Separator::Comma,
            trim: false,
            remove_empty: false,
            unique: false,
            sort: false,
            case_insensitive: false,
        }
    }
}

/// Lowercase a string with the same observable effect as Go's
/// `strings.ToLower` for the ASCII range this tool targets. Rust's
/// `to_lowercase` handles Unicode; Go does too via its case tables, so both
/// agree on the inputs the tool is designed for.
fn lower(s: &str) -> String {
    s.to_lowercase()
}

/// Compare two items, honoring `case_insensitive`. Wraps `str::cmp` so the
/// caller gets a total `Ordering`; used as the comparator for stable sort.
fn cmp_items(a: &str, b: &str, case_insensitive: bool) -> Ordering {
    if case_insensitive {
        lower(a).cmp(&lower(b))
    } else {
        a.cmp(b)
    }
}

/// Convert a list between separators. Never panics; an empty `input` simply
/// yields a one-item list joined back together (mirroring Go's
/// `strings.Split("", sep) == [""]`).
pub fn convert(input: &str, opts: &Options) -> String {
    let mut items: Vec<&str> = input.split(opts.from.as_str()).collect();

    if opts.trim {
        // `trim` needs owned strings to hold the trimmed views, so we widen
        // the vec to `String` at this point. Later stages stay owned.
        let mut trimmed: Vec<String> = items.iter().map(|s| s.trim().to_owned()).collect();
        if opts.remove_empty {
            trimmed.retain(|s| !s.is_empty());
        }
        if opts.unique {
            let mut seen = std::collections::HashSet::new();
            trimmed.retain(|s| {
                let key = if opts.case_insensitive { lower(s) } else { s.clone() };
                seen.insert(key)
            });
        }
        if opts.sort {
            trimmed.sort_by(|a, b| cmp_items(a, b, opts.case_insensitive));
        }
        return trimmed.join(opts.to.as_str());
    }

    // No-trim path: keep borrowing until a stage forces ownership.
    if opts.remove_empty {
        items.retain(|s| !s.is_empty());
    }
    if opts.unique {
        let mut seen = std::collections::HashSet::new();
        items.retain(|s| {
            let key = if opts.case_insensitive { lower(s) } else { (*s).to_owned() };
            seen.insert(key)
        });
    }
    if opts.sort {
        items.sort_by(|a, b| cmp_items(a, b, opts.case_insensitive));
    }
    items.join(opts.to.as_str())
}

/// Convenience wrapper using default options — the common "newline list to a
/// comma list" case (matches the tool's default From/To).
pub fn convert_default(input: &str) -> String {
    convert(input, &Options::default())
}

// ---------- tests (showcase-only; the canonical suite lives in src/lib + cli/) ----------
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn newline_to_comma_default() {
        // Default From=Newline, To=Comma — the primary use case.
        assert_eq!(convert_default("a\nb\nc"), "a,b,c");
    }

    #[test]
    fn roundtrip_comma_to_newline() {
        let opts = Options { from: Separator::Comma, to: Separator::Newline, ..Default::default() };
        assert_eq!(convert("a,b,c", &opts), "a\nb\nc");
    }

    #[test]
    fn trim_and_remove_empty() {
        // trim runs before remove_empty, so the blank between the two \n
        // collapses to "" and is then dropped.
        let opts = Options { to: Separator::Comma, trim: true, remove_empty: true, ..Default::default() };
        assert_eq!(convert(" a \n\n b \n c", &opts), "a,b,c");
    }

    #[test]
    fn unique_case_insensitive_keeps_first() {
        // Keyed by lowercased value; the first-seen casing survives.
        let opts = Options { to: Separator::Comma, unique: true, case_insensitive: true, ..Default::default() };
        assert_eq!(convert("A\na\nB", &opts), "A,B");
    }

    #[test]
    fn sort_case_insensitive() {
        let opts = Options { to: Separator::Comma, sort: true, case_insensitive: true, ..Default::default() };
        assert_eq!(convert("banana\nApple\ncherry", &opts), "Apple,banana,cherry");
    }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →