Skip to content

Chat Format Converter — Rust source

Convert chat transcripts between OpenAI messages[], Anthropic system+messages, Gemini contents[], and plain Markdown. Roles map faithfully, tool calls are preserved where possible, and anything unmappable is flagged — never dropped silently. Runs entirely in your browser.

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

// chat-format-converter — Rust polyglot showcase port.
//
// Converts one chat transcript between provider shapes through a shared internal
// message list:
//
// - openai    — {"messages": [{"role": "system|user|assistant|tool", ...}]}
// - anthropic — {"system": "...", "messages": [{"role": "user|assistant", ...}]}
// - gemini    — {"contents": [{"role": "user|model", "parts": [...]}]}
// - markdown  — a plain `**role**: text` transcript
//
// Roles map faithfully (Gemini has no assistant — it is `model`; Anthropic
// system lives top-level). Fields a target format cannot represent (e.g. tool
// calls in Markdown) are flagged as warnings, never dropped silently.
//
// This is the Rust sibling of src/lib/chatFormatConverter.ts (the canonical
// TypeScript that powers the live tool). The Rust standard library ships no
// JSON engine, so this file includes a small hand-written recursive-descent
// JSON value parser + writer (std only, no serde and no external crates).
// Object keys are kept in a Vec<(String, Json)> so insertion order survives a
// round-trip, exactly like the TypeScript original.
//
// Display source — part of CosmoDev's polyglot tool pages (dev.cosmolabs.org).

#![forbid(unsafe_code)]

/* ------------------------------ JSON engine ------------------------------- */

/// A JSON value. Objects preserve insertion order via a pair vector, matching
/// JavaScript object semantics (JSON.stringify key order).
#[derive(Clone, Debug, PartialEq)]
pub enum Json {
    Null,
    Bool(bool),
    Num(f64),
    Str(String),
    Arr(Vec<Json>),
    Obj(Vec<(String, Json)>),
}

impl Json {
    pub fn get(&self, key: &str) -> Option<&Json> {
        match self {
            Json::Obj(pairs) => pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v),
            _ => None,
        }
    }

    pub fn as_str(&self) -> Option<&str> {
        match self {
            Json::Str(s) => Some(s),
            _ => None,
        }
    }

    pub fn as_arr(&self) -> Option<&[Json]> {
        match self {
            Json::Arr(items) => Some(items),
            _ => None,
        }
    }

    /// Compact encoding, the moral equivalent of TS `JSON.stringify`.
    pub fn to_compact(&self) -> String {
        let mut out = String::new();
        write_json(&mut out, self, None, 0);
        out
    }

    /// Pretty encoding with a 2-space indent, like `JSON.stringify(v, null, 2)`.
    pub fn to_pretty(&self) -> String {
        let mut out = String::new();
        write_json(&mut out, self, Some(2), 0);
        out
    }
}

struct JsonParser<'a> {
    bytes: &'a [u8],
    pos: usize,
}

impl<'a> JsonParser<'a> {
    /// Parse a complete JSON document; trailing garbage is an error.
    pub fn parse(input: &'a str) -> Result<Json, String> {
        let mut p = JsonParser { bytes: input.as_bytes(), pos: 0 };
        p.skip_ws();
        let v = p.value()?;
        p.skip_ws();
        if p.pos != p.bytes.len() {
            return Err(format!("unexpected trailing characters at byte {}", p.pos));
        }
        Ok(v)
    }

    fn skip_ws(&mut self) {
        while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
            self.pos += 1;
        }
    }

    fn peek(&self) -> Option<u8> {
        self.bytes.get(self.pos).copied()
    }

    fn value(&mut self) -> Result<Json, String> {
        match self.peek() {
            Some(b'{') => self.object(),
            Some(b'[') => self.array(),
            Some(b'"') => Ok(Json::Str(self.string()?)),
            Some(b't') => self.literal("true", Json::Bool(true)),
            Some(b'f') => self.literal("false", Json::Bool(false)),
            Some(b'n') => self.literal("null", Json::Null),
            Some(c) if c == b'-' || c.is_ascii_digit() => self.number(),
            other => Err(format!("unexpected {:?} at byte {}", other.map(|c| c as char), self.pos)),
        }
    }

    fn literal(&mut self, word: &str, val: Json) -> Result<Json, String> {
        if self.bytes[self.pos..].starts_with(word.as_bytes()) {
            self.pos += word.len();
            Ok(val)
        } else {
            Err(format!("invalid literal at byte {}", self.pos))
        }
    }

    fn number(&mut self) -> Result<Json, String> {
        let start = self.pos;
        while let Some(c) = self.peek() {
            if c == b'-' || c == b'+' || c == b'.' || c == b'e' || c == b'E' || c.is_ascii_digit() {
                self.pos += 1;
            } else {
                break;
            }
        }
        let text = std::str::from_utf8(&self.bytes[start..self.pos]).unwrap_or("");
        text.parse::<f64>()
            .map(Json::Num)
            .map_err(|_| format!("invalid number {:?} at byte {}", text, start))
    }

    fn string(&mut self) -> Result<String, String> {
        self.pos += 1; // opening quote
        let mut out = String::new();
        loop {
            let c = self.peek().ok_or_else(|| format!("unterminated string at byte {}", self.pos))?;
            self.pos += 1;
            match c {
                b'"' => return Ok(out),
                b'\\' => {
                    let esc = self.peek().ok_or_else(|| "unterminated escape".to_string())?;
                    self.pos += 1;
                    match esc {
                        b'"' => out.push('"'),
                        b'\\' => out.push('\\'),
                        b'/' => out.push('/'),
                        b'b' => out.push('\u{0008}'),
                        b'f' => out.push('\u{000C}'),
                        b'n' => out.push('\n'),
                        b'r' => out.push('\r'),
                        b't' => out.push('\t'),
                        b'u' => {
                            let hi = self.hex4()?;
                            let ch = if (0xD800..0xDC00).contains(&hi) {
                                // High surrogate: combine with a following \uXXXX low surrogate.
                                let paired = self.bytes.get(self.pos).copied() == Some(b'\\')
                                    && self.bytes.get(self.pos + 1).copied() == Some(b'u');
                                if paired {
                                    self.pos += 2;
                                    let lo = self.hex4()?;
                                    let cp = 0x10000 + ((hi - 0xD800) << 10) + lo.wrapping_sub(0xDC00);
                                    char::from_u32(cp).unwrap_or('\u{FFFD}')
                                } else {
                                    '\u{FFFD}'
                                }
                            } else {
                                char::from_u32(hi).unwrap_or('\u{FFFD}')
                            };
                            out.push(ch);
                        }
                        other => return Err(format!("invalid escape \\{} at byte {}", other as char, self.pos - 1)),
                    }
                }
                _ => {
                    // Re-decode the full UTF-8 sequence starting at the previous byte.
                    let start = self.pos - 1;
                    let width = utf8_width(c);
                    let end = start + width;
                    if end > self.bytes.len() {
                        return Err(format!("truncated UTF-8 sequence at byte {}", start));
                    }
                    let s = std::str::from_utf8(&self.bytes[start..end])
                        .map_err(|_| format!("invalid UTF-8 at byte {}", start))?;
                    out.push_str(s);
                    self.pos = end;
                }
            }
        }
    }

    fn hex4(&mut self) -> Result<u32, String> {
        let mut v = 0u32;
        for _ in 0..4 {
            let c = self.peek().ok_or_else(|| "unterminated \\u escape".to_string())?;
            let d = (c as char)
                .to_digit(16)
                .ok_or_else(|| format!("invalid hex digit at byte {}", self.pos))?;
            self.pos += 1;
            v = v * 16 + d;
        }
        Ok(v)
    }

    fn object(&mut self) -> Result<Json, String> {
        self.pos += 1; // '{'
        let mut pairs = Vec::new();
        self.skip_ws();
        if self.peek() == Some(b'}') {
            self.pos += 1;
            return Ok(Json::Obj(pairs));
        }
        loop {
            self.skip_ws();
            if self.peek() != Some(b'"') {
                return Err(format!("expected object key at byte {}", self.pos));
            }
            let key = self.string()?;
            self.skip_ws();
            if self.peek() != Some(b':') {
                return Err(format!("expected ':' at byte {}", self.pos));
            }
            self.pos += 1;
            self.skip_ws();
            let val = self.value()?;
            pairs.push((key, val));
            self.skip_ws();
            match self.peek() {
                Some(b',') => self.pos += 1,
                Some(b'}') => {
                    self.pos += 1;
                    return Ok(Json::Obj(pairs));
                }
                _ => return Err(format!("expected ',' or '}}' at byte {}", self.pos)),
            }
        }
    }

    fn array(&mut self) -> Result<Json, String> {
        self.pos += 1; // '['
        let mut items = Vec::new();
        self.skip_ws();
        if self.peek() == Some(b']') {
            self.pos += 1;
            return Ok(Json::Arr(items));
        }
        loop {
            self.skip_ws();
            items.push(self.value()?);
            self.skip_ws();
            match self.peek() {
                Some(b',') => self.pos += 1,
                Some(b']') => {
                    self.pos += 1;
                    return Ok(Json::Arr(items));
                }
                _ => return Err(format!("expected ',' or ']' at byte {}", self.pos)),
            }
        }
    }
}

fn utf8_width(first: u8) -> usize {
    match first {
        0x00..=0x7F => 1,
        0xC0..=0xDF => 2,
        0xE0..=0xEF => 3,
        _ => 4,
    }
}

fn push_indent(out: &mut String, depth: usize) {
    for _ in 0..depth {
        out.push_str("  ");
    }
}

fn write_json(out: &mut String, v: &Json, indent: Option<usize>, depth: usize) {
    match v {
        Json::Null => out.push_str("null"),
        Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
        Json::Num(n) => out.push_str(&format_f64(*n)),
        Json::Str(s) => write_escaped(out, s),
        Json::Arr(items) => {
            if items.is_empty() {
                out.push_str("[]");
                return;
            }
            out.push('[');
            for (i, item) in items.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                if indent.is_some() {
                    out.push('\n');
                    push_indent(out, depth + 1);
                }
                write_json(out, item, indent, depth + 1);
            }
            if indent.is_some() {
                out.push('\n');
                push_indent(out, depth);
            }
            out.push(']');
        }
        Json::Obj(pairs) => {
            if pairs.is_empty() {
                out.push_str("{}");
                return;
            }
            out.push('{');
            for (i, (k, val)) in pairs.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                if indent.is_some() {
                    out.push('\n');
                    push_indent(out, depth + 1);
                }
                write_escaped(out, k);
                out.push(':');
                if indent.is_some() {
                    out.push(' ');
                }
                write_json(out, val, indent, depth + 1);
            }
            if indent.is_some() {
                out.push('\n');
                push_indent(out, depth);
            }
            out.push('}');
        }
    }
}

/// Escape a string the way JSON.stringify does: the two mandatory escapes,
/// the single-char shorthands, and \u00XX for remaining control characters.
fn write_escaped(out: &mut String, s: &str) {
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            '\u{0008}' => out.push_str("\\b"),
            '\u{000C}' => out.push_str("\\f"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
}

/// Render an f64 the way JavaScript's number-to-string coercion does: whole
/// values carry no trailing ".0".
fn format_f64(n: f64) -> String {
    if n == n.trunc() && n.abs() < 1e15 {
        format!("{}", n as i64)
    } else {
        format!("{}", n)
    }
}

/* ------------------------------ domain types ------------------------------ */

/// One assistant-emitted tool call, provider-agnostic. `args` is a JSON string.
#[derive(Clone, Debug, PartialEq)]
pub struct ToolCall {
    pub id: String,
    pub name: String,
    pub args: String,
}

/// The shared internal shape every format parses into and serializes from.
#[derive(Clone, Debug, PartialEq)]
pub struct InternalMessage {
    pub role: String,       // "system" | "user" | "assistant" | "tool"
    pub content: String,    // '' when the message only carries tool calls/results
    pub name: Option<String>,
    pub tool_calls: Option<Vec<ToolCall>>,
    pub tool_call_id: Option<String>,
}

impl InternalMessage {
    fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
        InternalMessage { role: role.into(), content: content.into(), name: None, tool_calls: None, tool_call_id: None }
    }
}

/// The conversion output plus human-readable notes about unmapped fields.
#[derive(Clone, Debug, PartialEq)]
pub struct ConversionResult {
    pub output: String,
    pub warnings: Vec<String>,
}

/* -------------------------------- helpers --------------------------------- */

fn parse_transcript_json(text: &str, label: &str) -> Result<Json, String> {
    JsonParser::parse(text).map_err(|e| format!("{}: invalid JSON — {}", label, e))
}

fn is_object(v: &Json) -> bool {
    matches!(v, Json::Obj(_))
}

/// The TS `?? {}` capture: null/missing becomes {}, any other value
/// (string, number, ...) passes through and stringifies as-is.
fn args_of(v: Option<&Json>) -> String {
    match v {
        None | Some(Json::Null) => "{}".to_string(),
        Some(v) => v.to_compact(),
    }
}

/// Parse a JSON-string argument back to a value, an empty object when it is
/// not a JSON object (mirrors parseArgsOrEmpty).
fn parse_args_or_empty(args: &str) -> Json {
    match JsonParser::parse(args) {
        Ok(v @ Json::Obj(_)) => v,
        _ => Json::Obj(Vec::new()),
    }
}

fn block_type_name(raw: &Json) -> String {
    raw.get("type").and_then(Json::as_str).unwrap_or("content").to_string()
}

/// One normalized Anthropic content block.
enum Block {
    Text(String),
    ToolUse(Json),
    ToolResult(Json),
    Other(Json),
}

fn content_blocks(content: &Json, where_: &str) -> Result<Vec<Block>, String> {
    if let Json::Str(s) = content {
        return Ok(vec![Block::Text(s.clone())]);
    }
    let items = content
        .as_arr()
        .ok_or_else(|| format!("{}: content must be a string or an array of blocks", where_))?;
    let mut blocks = Vec::with_capacity(items.len());
    for block in items {
        if !is_object(block) {
            blocks.push(Block::Other(block.clone()));
            continue;
        }
        let is_text = block.get("type").and_then(Json::as_str) == Some("text")
            && block.get("text").map(|t| t.as_str().is_some()).unwrap_or(false);
        let kind = block.get("type").and_then(Json::as_str);
        if is_text {
            blocks.push(Block::Text(block.get("text").unwrap().as_str().unwrap().to_string()));
        } else if kind == Some("tool_use") {
            blocks.push(Block::ToolUse(block.clone()));
        } else if kind == Some("tool_result") {
            blocks.push(Block::ToolResult(block.clone()));
        } else {
            blocks.push(Block::Other(block.clone()));
        }
    }
    Ok(blocks)
}

/// Anthropic string-or-block[] content → plain text.
fn blocks_to_text(content: &Json, where_: &str, warnings: &mut Vec<String>) -> Result<String, String> {
    if let Json::Str(s) = content {
        return Ok(s.clone());
    }
    let mut texts: Vec<String> = Vec::new();
    for block in content_blocks(content, where_)? {
        match block {
            Block::Text(t) => texts.push(t),
            Block::Other(raw) => warnings.push(format!(
                "{}: dropped unsupported {} block",
                where_,
                block_type_name(&raw)
            )),
            Block::ToolUse(_) | Block::ToolResult(_) => warnings.push(format!(
                "{}: dropped {} block from text-only content",
                where_,
                if matches!(block, Block::ToolUse(_)) { "tool_use" } else { "tool_result" }
            )),
        }
    }
    Ok(texts.join("\n"))
}

fn tool_call_from_block(raw: &Json) -> ToolCall {
    ToolCall {
        id: raw.get("id").and_then(Json::as_str).unwrap_or("").to_string(),
        name: raw.get("name").and_then(Json::as_str).unwrap_or("").to_string(),
        args: args_of(raw.get("input")),
    }
}

/* ---------------------------------- parse --------------------------------- */

/// OpenAI messages[] (bare array, or wrapped in {"messages": [...]}).
fn parse_openai(text: &str, warnings: &mut Vec<String>) -> Result<Vec<InternalMessage>, String> {
    let parsed = parse_transcript_json(text, "OpenAI transcript")?;
    let items: &[Json] = match parsed.get("messages") {
        Some(Json::Arr(items)) if is_object(&parsed) => items,
        _ => parsed
            .as_arr()
            .ok_or_else(|| r#"OpenAI transcript must be a messages[] array or an object with a "messages" array"#.to_string())?,
    };
    let mut out = Vec::new();
    for (i, entry) in items.iter().enumerate() {
        if !is_object(entry) {
            return Err(format!("messages[{}] is not an object", i));
        }
        let where_ = format!("messages[{}]", i);
        let role = entry.get("role").and_then(Json::as_str).unwrap_or("");
        match role {
            "system" | "developer" => {
                let content = content_text(entry.get("content"), &where_, warnings)?;
                out.push(InternalMessage::new("system", content));
            }
            "user" | "assistant" | "tool" => {
                let content = content_text(entry.get("content"), &where_, warnings)?;
                let mut msg = InternalMessage::new(role, content);
                msg.name = entry.get("name").and_then(Json::as_str).map(str::to_string);
                msg.tool_call_id = entry.get("tool_call_id").and_then(Json::as_str).map(str::to_string);
                msg.tool_calls = parse_openai_tool_calls(entry.get("tool_calls"))?;
                out.push(msg);
            }
            _ => {
                return Err(format!(
                    "messages[{}] has unsupported role: {}",
                    i,
                    entry.get("role").map(Json::to_compact).unwrap_or_else(|| "undefined".into())
                ))
            }
        }
    }
    Ok(out)
}

/// OpenAI content → plain text (string, or text parts joined; other parts flagged).
fn content_text(content: Option<&Json>, where_: &str, warnings: &mut Vec<String>) -> Result<String, String> {
    match content {
        None | Some(Json::Null) => Ok(String::new()),
        Some(Json::Str(s)) => Ok(s.clone()),
        Some(Json::Arr(parts)) => {
            let mut texts: Vec<String> = Vec::new();
            let mut dropped = 0usize;
            for part in parts {
                let is_text = part.get("type").and_then(Json::as_str) == Some("text")
                    && part.get("text").map(|t| t.as_str().is_some()).unwrap_or(false);
                if is_text {
                    texts.push(part.get("text").unwrap().as_str().unwrap().to_string());
                } else {
                    dropped += 1;
                }
            }
            if dropped > 0 {
                warnings.push(format!("{}: dropped {} non-text content part(s)", where_, dropped));
            }
            Ok(texts.join(""))
        }
        Some(_) => Err(format!("{}: content must be a string, an array of parts, or null", where_)),
    }
}

fn parse_openai_tool_calls(raw: Option<&Json>) -> Result<Option<Vec<ToolCall>>, String> {
    let raw = match raw {
        None | Some(Json::Null) => return Ok(None),
        Some(v) => v,
    };
    let items = raw.as_arr().ok_or_else(|| "tool_calls must be an array".to_string())?;
    let mut calls = Vec::with_capacity(items.len());
    for entry in items {
        let fn_ = entry.get("function");
        calls.push(ToolCall {
            id: entry.get("id").and_then(Json::as_str).unwrap_or("").to_string(),
            name: fn_.and_then(|f| f.get("name")).and_then(Json::as_str).unwrap_or("").to_string(),
            args: fn_.and_then(|f| f.get("arguments")).and_then(Json::as_str).unwrap_or("").to_string(),
        });
    }
    Ok(Some(calls))
}

/// Anthropic messages[] plus the top-level system field.
fn parse_anthropic(text: &str, warnings: &mut Vec<String>) -> Result<Vec<InternalMessage>, String> {
    let root = parse_transcript_json(text, "Anthropic transcript")?;
    if !is_object(&root) {
        return Err(r#"Anthropic transcript must be an object with a "messages" array"#.into());
    }
    let messages = root
        .get("messages")
        .and_then(Json::as_arr)
        .ok_or_else(|| r#"Anthropic transcript must be an object with a "messages" array"#.to_string())?;
    let mut out = Vec::new();
    if let Some(sys) = root.get("system") {
        out.push(InternalMessage::new("system", blocks_to_text(sys, "system", warnings)?));
    }

    for (i, entry) in messages.iter().enumerate() {
        if !is_object(entry) {
            return Err(format!("messages[{}] is not an object", i));
        }
        let where_ = format!("messages[{}]", i);
        match entry.get("role").and_then(Json::as_str).unwrap_or("") {
            "assistant" => {
                let mut texts: Vec<String> = Vec::new();
                let mut calls: Vec<ToolCall> = Vec::new();
                for block in content_blocks(entry.get("content").unwrap_or(&Json::Null), &where_)? {
                    match block {
                        Block::Text(t) => texts.push(t),
                        Block::ToolUse(raw) => calls.push(tool_call_from_block(&raw)),
                        Block::Other(raw) => warnings.push(format!(
                            "{}: dropped unsupported {} block",
                            where_,
                            block_type_name(&raw)
                        )),
                        Block::ToolResult(_) => {}
                    }
                }
                let mut msg = InternalMessage::new("assistant", texts.join("\n"));
                if !calls.is_empty() {
                    msg.tool_calls = Some(calls);
                }
                out.push(msg);
            }
            "user" => {
                let mut texts: Vec<String> = Vec::new();
                for block in content_blocks(entry.get("content").unwrap_or(&Json::Null), &where_)? {
                    match block {
                        Block::Text(t) => texts.push(t),
                        Block::ToolUse(raw) => {
                            warnings.push(format!(
                                "{}: tool_use block inside a user message moved to an assistant tool call",
                                where_
                            ));
                            if !texts.is_empty() {
                                out.push(InternalMessage::new("user", texts.join("\n")));
                                texts.clear();
                            }
                            out.push(InternalMessage {
                                role: "assistant".to_string(),
                                content: String::new(),
                                name: None,
                                tool_calls: Some(vec![tool_call_from_block(&raw)]),
                                tool_call_id: None,
                            });
                        }
                        Block::ToolResult(raw) => {
                            if !texts.is_empty() {
                                out.push(InternalMessage::new("user", texts.join("\n")));
                                texts.clear();
                            }
                            let mut msg = InternalMessage::new(
                                "tool",
                                blocks_to_text(raw.get("content").unwrap_or(&Json::Str(String::new())), &where_, warnings)?,
                            );
                            msg.tool_call_id =
                                Some(raw.get("tool_use_id").and_then(Json::as_str).unwrap_or("").to_string());
                            out.push(msg);
                        }
                        Block::Other(raw) => warnings.push(format!(
                            "{}: dropped unsupported {} block",
                            where_,
                            block_type_name(&raw)
                        )),
                    }
                }
                if !texts.is_empty() {
                    out.push(InternalMessage::new("user", texts.join("\n")));
                }
            }
            other => {
                return Err(format!(
                    "{} has unsupported role: {}",
                    where_,
                    entry.get("role").map(Json::to_compact).unwrap_or_else(|| format!("{:?}", other))
                ))
            }
        }
    }
    Ok(out)
}

/// Gemini systemInstruction (string or {parts}) → plain text.
fn gemini_text(v: &Json, where_: &str, warnings: &mut Vec<String>) -> String {
    if let Json::Str(s) = v {
        return s.clone();
    }
    if is_object(v) {
        if let Some(t) = v.get("text").and_then(Json::as_str) {
            return t.to_string();
        }
        if let Some(parts) = v.get("parts").and_then(Json::as_arr) {
            return parts
                .iter()
                .map(|p| p.get("text").and_then(Json::as_str).unwrap_or(""))
                .collect::<Vec<_>>()
                .join("\n");
        }
    }
    warnings.push(format!("{}: unsupported systemInstruction shape, treated as empty", where_));
    String::new()
}

/// Gemini contents[] plus the optional systemInstruction.
fn parse_gemini(text: &str, warnings: &mut Vec<String>) -> Result<Vec<InternalMessage>, String> {
    let root = parse_transcript_json(text, "Gemini transcript")?;
    if !is_object(&root) {
        return Err(r#"Gemini transcript must be an object with a "contents" array"#.into());
    }
    let contents = root
        .get("contents")
        .and_then(Json::as_arr)
        .ok_or_else(|| r#"Gemini transcript must be an object with a "contents" array"#.to_string())?;
    let mut out = Vec::new();
    if let Some(si) = root.get("systemInstruction") {
        out.push(InternalMessage::new("system", gemini_text(si, "systemInstruction", warnings)));
    }

    for (i, entry) in contents.iter().enumerate() {
        if !is_object(entry) {
            return Err(format!("contents[{}] is not an object", i));
        }
        let where_ = format!("contents[{}]", i);
        let role = entry.get("role").and_then(Json::as_str).unwrap_or("");
        if role != "user" && role != "model" {
            return Err(format!(
                "{} has unsupported role: {} (Gemini uses \"user\" or \"model\")",
                where_,
                entry.get("role").map(Json::to_compact).unwrap_or_else(|| "null".into())
            ));
        }
        let parts = entry
            .get("parts")
            .and_then(Json::as_arr)
            .ok_or_else(|| format!("{}: parts must be an array", where_))?;

        if role == "model" {
            // A model turn keeps its text and function calls in ONE message,
            // mirroring an OpenAI assistant message with tool_calls.
            let mut texts: Vec<String> = Vec::new();
            let mut calls: Vec<ToolCall> = Vec::new();
            for part in parts {
                if let Some(t) = part.get("text").and_then(Json::as_str) {
                    texts.push(t.to_string());
                    continue;
                }
                if let Some(fc) = part.get("functionCall").filter(|v| is_object(v)) {
                    calls.push(ToolCall {
                        id: String::new(),
                        name: fc.get("name").and_then(Json::as_str).unwrap_or("").to_string(),
                        args: args_of(fc.get("args")),
                    });
                    continue;
                }
                warnings.push(format!("{}: dropped unsupported part (inlineData or similar)", where_));
            }
            let mut msg = InternalMessage::new("assistant", texts.join("\n"));
            if !calls.is_empty() {
                msg.tool_calls = Some(calls);
            }
            out.push(msg);
            continue;
        }

        let mut texts: Vec<String> = Vec::new();
        for part in parts {
            if let Some(t) = part.get("text").and_then(Json::as_str) {
                texts.push(t.to_string());
                continue;
            }
            if let Some(fc) = part.get("functionCall").filter(|v| is_object(v)) {
                if !texts.is_empty() {
                    out.push(InternalMessage::new("user", texts.join("\n")));
                    texts.clear();
                }
                out.push(InternalMessage {
                    role: "assistant".to_string(),
                    content: String::new(),
                    name: None,
                    tool_calls: Some(vec![ToolCall {
                        id: String::new(),
                        name: fc.get("name").and_then(Json::as_str).unwrap_or("").to_string(),
                        args: args_of(fc.get("args")),
                    }]),
                    tool_call_id: None,
                });
                continue;
            }
            if let Some(fr) = part.get("functionResponse").filter(|v| is_object(v)) {
                if !texts.is_empty() {
                    out.push(InternalMessage::new("user", texts.join("\n")));
                    texts.clear();
                }
                let name = fr.get("name").and_then(Json::as_str).unwrap_or("").to_string();
                out.push(InternalMessage {
                    role: "tool".to_string(),
                    content: args_of(fr.get("response")),
                    name: Some(name.clone()),
                    tool_calls: None,
                    tool_call_id: Some(name),
                });
                continue;
            }
            warnings.push(format!("{}: dropped unsupported part (inlineData or similar)", where_));
        }
        if !texts.is_empty() {
            out.push(InternalMessage::new("user", texts.join("\n")));
        }
    }
    Ok(out)
}

/// A parsed Markdown header line: `**role**: text`.
fn markdown_header(line: &str) -> Option<(&'static str, &str)> {
    let rest = line.strip_prefix("**")?;
    let end = rest.find("**:")?;
    let role = &rest[..end];
    let mapped = match role {
        "system" => "system",
        "user" => "user",
        "assistant" => "assistant",
        "model" => "assistant",
        "tool" => "tool",
        _ => return None,
    };
    Some((mapped, rest[end + 3..].trim_start()))
}

fn finish_markdown_message(role: &'static str, lines: &mut Vec<String>) -> InternalMessage {
    // Trim leading/trailing blank continuation lines but keep inner blank lines.
    while lines.len() > 1 && lines[0].trim().is_empty() {
        lines.remove(0);
    }
    while lines.len() > 1 && lines[lines.len() - 1].trim().is_empty() {
        lines.pop();
    }
    InternalMessage::new(role, lines.join("\n"))
}

/// Markdown transcript: `**role**: text` header lines with continuation lines
/// belonging to the same message. `model` maps to assistant.
fn parse_markdown(text: &str, _warnings: &mut Vec<String>) -> Result<Vec<InternalMessage>, String> {
    let mut out = Vec::new();
    let mut current: Option<(&'static str, Vec<String>)> = None;
    for line in text.split('\n') {
        if let Some((role, first)) = markdown_header(line) {
            if let Some((r, mut lines)) = current.take() {
                out.push(finish_markdown_message(r, &mut lines));
            }
            current = Some((role, vec![first.to_string()]));
            continue;
        }
        match &mut current {
            Some((_, lines)) => lines.push(line.to_string()),
            None if !line.trim().is_empty() => {
                return Err("Markdown transcript must start with a `**role**:` header line".into())
            }
            None => {}
        }
    }
    if let Some((r, mut lines)) = current.take() {
        out.push(finish_markdown_message(r, &mut lines));
    }
    Ok(out)
}

/* -------------------------------- serialize ------------------------------- */

fn kv(pairs: Vec<(&str, Json)>) -> Json {
    Json::Obj(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
}

fn s(v: &str) -> Json {
    Json::Str(v.to_string())
}

fn serialize_openai(messages: &[InternalMessage], _warnings: &mut Vec<String>) -> String {
    let arr: Vec<Json> = messages
        .iter()
        .map(|m| {
            let mut pairs: Vec<(&str, Json)> = Vec::new();
            if m.role == "tool" {
                pairs.push(("role", s("tool")));
                pairs.push(("content", s(&m.content)));
                pairs.push(("tool_call_id", s(m.tool_call_id.as_deref().unwrap_or(""))));
                if let Some(name) = &m.name {
                    pairs.push(("name", s(name)));
                }
            } else if m.role == "assistant" && m.tool_calls.is_some() {
                pairs.push(("role", s("assistant")));
                pairs.push((
                    "content",
                    if m.content.is_empty() { Json::Null } else { s(&m.content) },
                ));
                pairs.push((
                    "tool_calls",
                    Json::Arr(
                        m.tool_calls
                            .as_deref()
                            .unwrap_or(&[])
                            .iter()
                            .map(|tc| {
                                kv(vec![
                                    ("id", s(&tc.id)),
                                    ("type", s("function")),
                                    ("function", kv(vec![("name", s(&tc.name)), ("arguments", s(&tc.args))])),
                                ])
                            })
                            .collect(),
                    ),
                ));
                if let Some(name) = &m.name {
                    pairs.push(("name", s(name)));
                }
            } else {
                pairs.push(("role", s(&m.role)));
                pairs.push(("content", s(&m.content)));
                if let Some(name) = &m.name {
                    pairs.push(("name", s(name)));
                }
            }
            kv(pairs)
        })
        .collect();
    format!("{}\n", kv(vec![("messages", Json::Arr(arr))]).to_pretty())
}

fn serialize_anthropic(messages: &[InternalMessage], warnings: &mut Vec<String>) -> String {
    let system: Vec<&str> = messages
        .iter()
        .filter(|m| m.role == "system")
        .map(|m| m.content.as_str())
        .collect();
    let mut out: Vec<Json> = Vec::new();
    for (i, m) in messages.iter().enumerate() {
        if m.role == "system" {
            continue;
        }
        if m.name.is_some() && m.role != "tool" {
            warnings.push(format!(
                "message {}: \"name\" has no Anthropic equivalent and was dropped",
                i
            ));
        }
        let entry = match m.role.as_str() {
            "user" => kv(vec![("role", s("user")), ("content", s(&m.content))]),
            "assistant" => {
                if let Some(calls) = &m.tool_calls {
                    let mut blocks: Vec<Json> = Vec::new();
                    if !m.content.is_empty() {
                        blocks.push(kv(vec![("type", s("text")), ("text", s(&m.content))]));
                    }
                    for tc in calls {
                        blocks.push(kv(vec![
                            ("type", s("tool_use")),
                            ("id", s(&tc.id)),
                            ("name", s(&tc.name)),
                            ("input", parse_args_or_empty(&tc.args)),
                        ]));
                    }
                    kv(vec![("role", s("assistant")), ("content", Json::Arr(blocks))])
                } else {
                    kv(vec![("role", s("assistant")), ("content", s(&m.content))])
                }
            }
            _ => kv(vec![
                ("role", s("user")),
                (
                    "content",
                    Json::Arr(vec![kv(vec![
                        ("type", s("tool_result")),
                        ("tool_use_id", s(m.tool_call_id.as_deref().unwrap_or(""))),
                        ("content", s(&m.content)),
                    ])]),
                ),
            ]),
        };
        out.push(entry);
    }
    let mut pairs: Vec<(String, Json)> = Vec::new();
    if !out.is_empty() {
        pairs.push(("messages".to_string(), Json::Arr(out)));
    }
    if !system.is_empty() {
        pairs.push(("system".to_string(), s(&system.join("\n\n"))));
    }
    format!("{}\n", Json::Obj(pairs).to_pretty())
}

fn serialize_gemini(messages: &[InternalMessage], warnings: &mut Vec<String>) -> String {
    let system: Vec<&str> = messages
        .iter()
        .filter(|m| m.role == "system")
        .map(|m| m.content.as_str())
        .collect();
    // (role, parts) turns; consecutive same-role parts merge into one turn.
    let mut turns: Vec<(String, Vec<Json>)> = Vec::new();
    for (i, m) in messages.iter().enumerate() {
        if m.role == "system" {
            continue;
        }
        if m.name.is_some() && m.role != "tool" {
            warnings.push(format!(
                "message {}: \"name\" has no Gemini equivalent and was dropped",
                i
            ));
        }
        let mut push = |role: &str, part: Json| {
            if let Some(last) = turns.last_mut() {
                if last.0 == role {
                    last.1.push(part);
                    return;
                }
            }
            turns.push((role.to_string(), vec![part]));
        };
        match m.role.as_str() {
            "user" => push("user", kv(vec![("text", s(&m.content))])),
            "assistant" => {
                if !m.content.is_empty() {
                    push("model", kv(vec![("text", s(&m.content))]));
                }
                for tc in m.tool_calls.as_deref().unwrap_or(&[]) {
                    push(
                        "model",
                        kv(vec![(
                            "functionCall",
                            kv(vec![
                                ("name", s(&tc.name)),
                                ("args", parse_args_or_empty(&tc.args)),
                            ]),
                        )]),
                    );
                }
            }
            _ => {
                let name = m.name.as_deref().or(m.tool_call_id.as_deref()).unwrap_or("");
                push(
                    "user",
                    kv(vec![(
                        "functionResponse",
                        kv(vec![
                            ("name", s(name)),
                            ("response", parse_args_or_empty(&m.content)),
                        ]),
                    )]),
                );
            }
        }
    }
    let mut pairs: Vec<(String, Json)> = Vec::new();
    if !turns.is_empty() {
        let contents: Vec<Json> = turns
            .iter()
            .map(|(role, parts)| kv(vec![("role", s(role)), ("parts", Json::Arr(parts.clone()))]))
            .collect();
        pairs.push(("contents".to_string(), Json::Arr(contents)));
    }
    if !system.is_empty() {
        pairs.push((
            "systemInstruction".to_string(),
            kv(vec![("parts", Json::Arr(vec![kv(vec![("text", s(&system.join("\n\n")))])]))]),
        ));
    }
    format!("{}\n", Json::Obj(pairs).to_pretty())
}

fn serialize_markdown(messages: &[InternalMessage], warnings: &mut Vec<String>) -> String {
    let mut lines = Vec::with_capacity(messages.len());
    for (i, m) in messages.iter().enumerate() {
        if let Some(calls) = &m.tool_calls {
            if !calls.is_empty() {
                warnings.push(format!(
                    "message {}: tool calls are not representable in Markdown and were dropped",
                    i
                ));
            }
        }
        if m.role == "tool" && m.tool_call_id.is_some() {
            warnings.push(format!(
                "message {}: tool result id is not representable in Markdown and was dropped",
                i
            ));
        }
        lines.push(format!("**{}**: {}", m.role, m.content));
    }
    format!("{}\n", lines.join("\n\n"))
}

fn parser_for(from: &str) -> Option<fn(&str, &mut Vec<String>) -> Result<Vec<InternalMessage>, String>> {
    match from {
        "openai" => Some(parse_openai),
        "anthropic" => Some(parse_anthropic),
        "gemini" => Some(parse_gemini),
        "markdown" => Some(parse_markdown),
        _ => None,
    }
}

fn serializer_for(to: &str) -> Option<fn(&[InternalMessage], &mut Vec<String>) -> String> {
    match to {
        "openai" => Some(serialize_openai),
        "anthropic" => Some(serialize_anthropic),
        "gemini" => Some(serialize_gemini),
        "markdown" => Some(serialize_markdown),
        _ => None,
    }
}

/// Convert a transcript between chat formats through the shared internal shape.
///
/// Errors when the transcript is empty, the source format fails to parse, or
/// `from`/`to` is not a known format.
pub fn convert(transcript: &str, from: &str, to: &str) -> Result<ConversionResult, String> {
    if transcript.trim().is_empty() {
        return Err("Transcript is empty — paste a transcript first".to_string());
    }
    let parser = parser_for(from).ok_or_else(|| format!("Unknown source format: {}", from))?;
    let serializer = serializer_for(to).ok_or_else(|| format!("Unknown target format: {}", to))?;
    let mut warnings: Vec<String> = Vec::new();
    let messages = parser(transcript, &mut warnings)?;
    let output = serializer(&messages, &mut warnings);
    Ok(ConversionResult { output, warnings })
}

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 →