Skip to content

Chat Format Converter — Swift 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 Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.

// chat-format-converter — Swift 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
//
// Language: Swift (5.9, standard library only)
// Source:   CosmoDev polyglot showcase port of the Chat Format Converter tool,
//           ported from src/lib/chatFormatConverter.ts (the canonical
//           TypeScript that powers the live tool).
// License:  display source — part of CosmoDev's polyglot tool pages.
//
// 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.
// Foundation's JSONSerialization alphabetizes object keys, which would break
// the round-trip key order the TypeScript original guarantees — so this file
// includes a small hand-written recursive-descent JSON parser + writer
// (stdlib only). Object keys are kept in an [(String, Json)] array so
// insertion order survives a round-trip. Parse failures throw ChatFormatError.

// MARK: - JSON engine

/// A JSON value. Objects preserve insertion order via a pair array, matching
/// JavaScript object semantics (JSON.stringify key order).
enum Json {
    case null
    case bool(Bool)
    case num(Double)
    case str(String)
    case arr([Json])
    case obj([(String, Json)])

    subscript(key: String) -> Json? {
        guard case .obj(let pairs) = self else { return nil }
        return pairs.first(where: { $0.0 == key })?.1
    }

    var asStr: String? { if case .str(let s) = self { return s }; return nil }
    var asArr: [Json]? { if case .arr(let items) = self { return items }; return nil }
    var isObject: Bool { if case .obj = self { return true }; return false }

    /// Compact encoding, the moral equivalent of TS `JSON.stringify`.
    var toCompact: String {
        var out = ""
        write(into: &out, indent: nil, depth: 0)
        return out
    }

    /// Pretty encoding with a 2-space indent, like `JSON.stringify(v, null, 2)`.
    var toPretty: String {
        var out = ""
        write(into: &out, indent: 2, depth: 0)
        return out
    }

    private func write(into out: inout String, indent: Int?, depth: Int) {
        switch self {
        case .null: out += "null"
        case .bool(let b): out += b ? "true" : "false"
        case .num(let n): out += Json.formatF64(n)
        case .str(let s): Json.writeEscaped(into: &out, s)
        case .arr(let items):
            if items.isEmpty { out += "[]"; return }
            out += "["
            for (i, item) in items.enumerated() {
                if i > 0 { out += "," }
                if indent != nil { out += "\n"; Json.pushIndent(into: &out, depth + 1) }
                item.write(into: &out, indent: indent, depth: depth + 1)
            }
            if indent != nil { out += "\n"; Json.pushIndent(into: &out, depth) }
            out += "]"
        case .obj(let pairs):
            if pairs.isEmpty { out += "{}"; return }
            out += "{"
            for (i, (k, v)) in pairs.enumerated() {
                if i > 0 { out += "," }
                if indent != nil { out += "\n"; Json.pushIndent(into: &out, depth + 1) }
                Json.writeEscaped(into: &out, k)
                out += indent != nil ? ": " : ":"
                v.write(into: &out, indent: indent, depth: depth + 1)
            }
            if indent != nil { out += "\n"; Json.pushIndent(into: &out, depth) }
            out += "}"
        }
    }

    private static func pushIndent(into out: inout String, _ depth: Int) {
        for _ in 0..<depth { out += "  " }
    }

    /// Escape a string the way JSON.stringify does: the two mandatory escapes,
    /// the single-char shorthands, and \u00XX for remaining control characters.
    static func writeEscaped(into out: inout String, _ s: String) {
        out += "\""
        for c in s {
            switch c {
            case "\"": out += "\\\""
            case "\\": out += "\\\\"
            case "\n": out += "\\n"
            case "\r": out += "\\r"
            case "\t": out += "\\t"
            case "\u{0008}": out += "\\b"
            case "\u{000C}": out += "\\f"
            default:
                let b = c.utf8.first!
                if b < 0x20 { out += Json.hex4Escape(b) } else { out.append(c) }
            }
        }
        out += "\""
    }

    /// \u00XX for a control byte, lowercase hex, exactly four digits.
    private static func hex4Escape(_ b: UInt8) -> String {
        let digits = Array("0123456789abcdef")
        return "\\u" + String(digits[Int(b >> 4)]) + String(digits[Int(b & 0xF)])
    }

    /// Render a Double the way JavaScript's number-to-string coercion does:
    /// whole values carry no trailing ".0".
    static func formatF64(_ n: Double) -> String {
        if n == n.rounded() && abs(n) < 1e15 { return String(Int64(n)) }
        return String(n)
    }
}

/// One parse/convert failure, carrying the same human-readable message the
/// TypeScript original surfaces in the UI.
struct ChatFormatError: Error, CustomStringConvertible {
    let message: String
    var description: String { message }
}

/// Recursive-descent JSON parser; trailing garbage is an error.
struct JsonParser {
    private let bytes: [UInt8]
    private var pos = 0

    static func parse(_ input: String) throws -> Json {
        var p = JsonParser(bytes: Array(input.utf8))
        p.skipWs()
        let v = try p.value()
        p.skipWs()
        if p.pos != p.bytes.count {
            throw ChatFormatError(message: "unexpected trailing characters at byte \(p.pos)")
        }
        return v
    }

    private mutating func skipWs() {
        while let c = peek(), c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D { pos += 1 }
    }

    private func peek() -> UInt8? { pos < bytes.count ? bytes[pos] : nil }

    private mutating func value() throws -> Json {
        guard let c = peek() else { throw ChatFormatError(message: "unexpected end of input") }
        switch c {
        case UInt8(ascii: "{"): return try obj()
        case UInt8(ascii: "["): return try arr()
        case UInt8(ascii: "\""): return .str(try string())
        case UInt8(ascii: "t"): return try literal("true", .bool(true))
        case UInt8(ascii: "f"): return try literal("false", .bool(false))
        case UInt8(ascii: "n"): return try literal("null", .null)
        case UInt8(ascii: "-"), 0x30...0x39: return try number()
        default:
            throw ChatFormatError(message: "unexpected '\(Character(UnicodeScalar(c)))' at byte \(pos)")
        }
    }

    private mutating func literal(_ word: String, _ val: Json) throws -> Json {
        let target = Array(word.utf8)
        if bytes[pos..<min(pos + target.count, bytes.count)].elementsEqual(target) {
            pos += target.count
            return val
        }
        throw ChatFormatError(message: "invalid literal at byte \(pos)")
    }

    private mutating func number() throws -> Json {
        let start = pos
        loop: while let c = peek() {
            switch c {
            case UInt8(ascii: "-"), UInt8(ascii: "+"), UInt8(ascii: "."),
                 UInt8(ascii: "e"), UInt8(ascii: "E"), 0x30...0x39:
                pos += 1
            default: break loop
            }
        }
        let text = String(decoding: bytes[start..<pos], as: UTF8.self)
        guard let d = Double(text) else {
            throw ChatFormatError(message: "invalid number \"\(text)\" at byte \(start)")
        }
        return .num(d)
    }

    private mutating func string() throws -> String {
        pos += 1 // opening quote
        var scalars = [UInt8]()
        while true {
            guard let c = peek() else {
                throw ChatFormatError(message: "unterminated string at byte \(pos)")
            }
            pos += 1
            switch c {
            case UInt8(ascii: "\""):
                return String(decoding: scalars, as: UTF8.self)
            case UInt8(ascii: "\\"):
                guard let esc = peek() else { throw ChatFormatError(message: "unterminated escape") }
                pos += 1
                switch esc {
                case UInt8(ascii: "\""): scalars.append(0x22)
                case UInt8(ascii: "\\"): scalars.append(0x5C)
                case UInt8(ascii: "/"): scalars.append(0x2F)
                case UInt8(ascii: "b"): scalars.append(0x08)
                case UInt8(ascii: "f"): scalars.append(0x0C)
                case UInt8(ascii: "n"): scalars.append(0x0A)
                case UInt8(ascii: "r"): scalars.append(0x0D)
                case UInt8(ascii: "t"): scalars.append(0x09)
                case UInt8(ascii: "u"):
                    let hi = try hex4()
                    if hi >= 0xD800 && hi <= 0xDBFF {
                        // High surrogate: combine with a following \uXXXX low surrogate.
                        let paired = peek() == UInt8(ascii: "\\")
                            && bytes.count > pos + 1 && bytes[pos + 1] == UInt8(ascii: "u")
                        if paired {
                            pos += 2
                            let lo = try hex4()
                            let cp = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00)
                            if let s = String(scalar: cp) { scalars.append(contentsOf: Array(s.utf8)) }
                            else { scalars.append(contentsOf: [0xEF, 0xBF, 0xBD]) }
                        } else {
                            scalars.append(contentsOf: Array("\u{FFFD}".utf8))
                        }
                    } else if let s = String(scalar: hi) {
                        scalars.append(contentsOf: Array(s.utf8))
                    } else {
                        scalars.append(contentsOf: Array("\u{FFFD}".utf8))
                    }
                default:
                    throw ChatFormatError(message: "invalid escape \\\(Character(UnicodeScalar(esc))) at byte \(pos - 1)")
                }
            default:
                // Copy the full UTF-8 sequence as-is; `pos` already advanced one byte.
                scalars.append(c)
            }
        }
    }

    private mutating func hex4() throws -> Int {
        var v = 0
        for _ in 0..<4 {
            guard let c = peek() else { throw ChatFormatError(message: "unterminated \\u escape") }
            guard let d = Character(UnicodeScalar(c)).hexDigitValue else {
                throw ChatFormatError(message: "invalid hex digit at byte \(pos)")
            }
            pos += 1
            v = v * 16 + d
        }
        return v
    }

    private mutating func obj() throws -> Json {
        pos += 1 // '{'
        var pairs: [(String, Json)] = []
        skipWs()
        if peek() == UInt8(ascii: "}") { pos += 1; return .obj(pairs) }
        while true {
            skipWs()
            guard peek() == UInt8(ascii: "\"") else {
                throw ChatFormatError(message: "expected object key at byte \(pos)")
            }
            let key = try string()
            skipWs()
            guard peek() == UInt8(ascii: ":") else {
                throw ChatFormatError(message: "expected ':' at byte \(pos)")
            }
            pos += 1
            skipWs()
            pairs.append((key, try value()))
            skipWs()
            switch peek() {
            case UInt8(ascii: ","): pos += 1
            case UInt8(ascii: "}"): pos += 1; return .obj(pairs)
            default: throw ChatFormatError(message: "expected ',' or '}' at byte \(pos)")
            }
        }
    }

    private mutating func arr() throws -> Json {
        pos += 1 // '['
        var items: [Json] = []
        skipWs()
        if peek() == UInt8(ascii: "]") { pos += 1; return .arr(items) }
        while true {
            skipWs()
            items.append(try value())
            skipWs()
            switch peek() {
            case UInt8(ascii: ","): pos += 1
            case UInt8(ascii: "]"): pos += 1; return .arr(items)
            default: throw ChatFormatError(message: "expected ',' or ']' at byte \(pos)")
            }
        }
    }
}

extension String {
    /// Build a String from a Unicode scalar value, nil when it is a surrogate.
    init?(scalar v: Int) {
        guard let us = UnicodeScalar(v), !(0xD800...0xDFFF).contains(v) else { return nil }
        self = String(Character(us))
    }
}

// MARK: - Domain types

let CHAT_FORMATS = ["openai", "anthropic", "gemini", "markdown"]

/// One assistant-emitted tool call, provider-agnostic. `args` is a JSON string.
struct ToolCall: Equatable {
    var id = ""
    var name = ""
    var args = ""
}

/// The shared internal shape every format parses into and serializes from.
struct InternalMessage: Equatable {
    var role: String            // "system" | "user" | "assistant" | "tool"
    var content = ""            // "" when the message only carries tool calls/results
    var name: String? = nil
    var toolCalls: [ToolCall]? = nil
    var toolCallId: String? = nil

    init(_ role: String, _ content: String = "") {
        self.role = role
        self.content = content
    }
}

/// The conversion output plus human-readable notes about unmapped fields.
struct ConversionResult: Equatable {
    var output: String
    var warnings: [String]
}

// MARK: - Helpers

func parseTranscriptJson(_ text: String, _ label: String) throws -> Json {
    do { return try JsonParser.parse(text) }
    catch let e as ChatFormatError { throw ChatFormatError(message: "\(label): invalid JSON — \(e.message)") }
}

/// The TS `?? {}` capture: null/missing becomes {}, any other value passes
/// through and stringifies as-is.
func argsOf(_ v: Json?) -> String {
    switch v {
    case nil, .some(.null): return "{}"
    case .some(let v): return v.toCompact
    }
}

/// Parse a JSON-string argument back to a value, an empty object when it is
/// not a JSON object (mirrors parseArgsOrEmpty).
func parseArgsOrEmpty(_ args: String) -> Json {
    if let v = try? JsonParser.parse(args), v.isObject { return v }
    return .obj([])
}

func blockTypeName(_ raw: Json) -> String {
    raw["type"]?.asStr ?? "content"
}

/// One normalized Anthropic content block.
enum Block {
    case text(String)
    case toolUse(Json)
    case toolResult(Json)
    case other(Json)
}

func contentBlocks(_ content: Json, _ where_: String) throws -> [Block] {
    if case .str(let s) = content { return [.text(s)] }
    guard case .arr(let items) = content else {
        throw ChatFormatError(message: "\(where_): content must be a string or an array of blocks")
    }
    return items.map { block in
        let isText = block["type"]?.asStr == "text" && block["text"]?.asStr != nil
        switch block["type"]?.asStr {
        case "text" where isText: return .text(block["text"]!.asStr!)
        case "tool_use": return .toolUse(block)
        case "tool_result": return .toolResult(block)
        default: return .other(block)
        }
    }
}

/// Anthropic string-or-block[] content → plain text.
func blocksToText(_ content: Json, _ where_: String, _ warnings: inout [String]) throws -> String {
    if case .str(let s) = content { return s }
    var texts: [String] = []
    for block in try contentBlocks(content, where_) {
        switch block {
        case .text(let t): texts.append(t)
        case .other(let raw):
            warnings.append("\(where_): dropped unsupported \(blockTypeName(raw)) block")
        case .toolUse:
            warnings.append("\(where_): dropped tool_use block from text-only content")
        case .toolResult:
            warnings.append("\(where_): dropped tool_result block from text-only content")
        }
    }
    return texts.joined(separator: "\n")
}

func toolCallFromBlock(_ raw: Json) -> ToolCall {
    ToolCall(id: raw["id"]?.asStr ?? "", name: raw["name"]?.asStr ?? "", args: argsOf(raw["input"]))
}

// MARK: - Parse

/// OpenAI messages[] (bare array, or wrapped in {"messages": [...]}).
func parseOpenAi(_ text: String, _ warnings: inout [String]) throws -> [InternalMessage] {
    let parsed = try parseTranscriptJson(text, "OpenAI transcript")
    let items: [Json]
    if case .arr(let wrapped)? = parsed.isObject ? parsed["messages"] : nil {
        items = wrapped
    } else if case .arr(let bare) = parsed {
        items = bare
    } else {
        throw ChatFormatError(
            message: "OpenAI transcript must be a messages[] array or an object with a \"messages\" array")
    }
    var out: [InternalMessage] = []
    for (i, entry) in items.enumerated() {
        guard entry.isObject else { throw ChatFormatError(message: "messages[\(i)] is not an object") }
        let where_ = "messages[\(i)]"
        let role = entry["role"]?.asStr ?? ""
        switch role {
        case "system", "developer":
            out.append(InternalMessage("system", try contentText(entry["content"], where_, &warnings)))
        case "user", "assistant", "tool":
            var msg = InternalMessage(role, try contentText(entry["content"], where_, &warnings))
            msg.name = entry["name"]?.asStr
            msg.toolCallId = entry["tool_call_id"]?.asStr
            msg.toolCalls = try parseOpenAiToolCalls(entry["tool_calls"])
            out.append(msg)
        default:
            throw ChatFormatError(message: "messages[\(i)] has unsupported role: \(entry["role"]?.toCompact ?? "null")")
        }
    }
    return out
}

/// OpenAI content → plain text (string, or text parts joined; other parts flagged).
func contentText(_ content: Json?, _ where_: String, _ warnings: inout [String]) throws -> String {
    switch content {
    case nil, .some(.null): return ""
    case .some(.str(let s)): return s
    case .some(.arr(let parts)):
        var texts: [String] = []
        var dropped = 0
        for part in parts {
            let isText = part["type"]?.asStr == "text" && part["text"]?.asStr != nil
            if isText { texts.append(part["text"]!.asStr!) } else { dropped += 1 }
        }
        if dropped > 0 { warnings.append("\(where_): dropped \(dropped) non-text content part(s)") }
        return texts.joined()
    default:
        throw ChatFormatError(message: "\(where_): content must be a string, an array of parts, or null")
    }
}

func parseOpenAiToolCalls(_ raw: Json?) throws -> [ToolCall]? {
    switch raw {
    case nil, .some(.null):
        return nil
    case .some(.arr(let items)):
        return items.map { entry in
            let fn = entry["function"]
            return ToolCall(
                id: entry["id"]?.asStr ?? "",
                name: fn?["name"]?.asStr ?? "",
                args: fn?["arguments"]?.asStr ?? "")
        }
    default:
        throw ChatFormatError(message: "tool_calls must be an array")
    }
}

/// Anthropic messages[] plus the top-level system field.
func parseAnthropic(_ text: String, _ warnings: inout [String]) throws -> [InternalMessage] {
    let root = try parseTranscriptJson(text, "Anthropic transcript")
    guard root.isObject, case .arr(let messages)? = root["messages"] else {
        throw ChatFormatError(message: "Anthropic transcript must be an object with a \"messages\" array")
    }
    var out: [InternalMessage] = []
    if let sys = root["system"] {
        out.append(InternalMessage("system", try blocksToText(sys, "system", &warnings)))
    }

    for (i, entry) in messages.enumerated() {
        guard entry.isObject else { throw ChatFormatError(message: "messages[\(i)] is not an object") }
        let where_ = "messages[\(i)]"
        switch entry["role"]?.asStr ?? "" {
        case "assistant":
            var texts: [String] = []
            var calls: [ToolCall] = []
            for block in try contentBlocks(entry["content"] ?? .null, where_) {
                switch block {
                case .text(let t): texts.append(t)
                case .toolUse(let raw): calls.append(toolCallFromBlock(raw))
                case .other(let raw):
                    warnings.append("\(where_): dropped unsupported \(blockTypeName(raw)) block")
                case .toolResult: break
                }
            }
            var msg = InternalMessage("assistant", texts.joined(separator: "\n"))
            if !calls.isEmpty { msg.toolCalls = calls }
            out.append(msg)
        case "user":
            var texts: [String] = []
            func flush() {
                if !texts.isEmpty {
                    out.append(InternalMessage("user", texts.joined(separator: "\n")))
                    texts.removeAll()
                }
            }
            for block in try contentBlocks(entry["content"] ?? .null, where_) {
                switch block {
                case .text(let t):
                    texts.append(t)
                case .toolUse(let raw):
                    warnings.append("\(where_): tool_use block inside a user message moved to an assistant tool call")
                    flush()
                    var msg = InternalMessage("assistant")
                    msg.toolCalls = [toolCallFromBlock(raw)]
                    out.append(msg)
                case .toolResult(let raw):
                    flush()
                    var msg = InternalMessage("tool",
                        try blocksToText(raw["content"] ?? .str(""), where_, &warnings))
                    msg.toolCallId = raw["tool_use_id"]?.asStr ?? ""
                    out.append(msg)
                case .other(let raw):
                    warnings.append("\(where_): dropped unsupported \(blockTypeName(raw)) block")
                }
            }
            flush()
        default:
            throw ChatFormatError(message: "\(where_) has unsupported role: \(entry["role"]?.toCompact ?? "null")")
        }
    }
    return out
}

/// Gemini systemInstruction (string or {parts}) → plain text.
func geminiText(_ v: Json, _ where_: String, _ warnings: inout [String]) -> String {
    if case .str(let s) = v { return s }
    if v.isObject {
        if let t = v["text"]?.asStr { return t }
        if case .arr(let parts)? = v["parts"] {
            return parts.map { $0["text"]?.asStr ?? "" }.joined(separator: "\n")
        }
    }
    warnings.append("\(where_): unsupported systemInstruction shape, treated as empty")
    return ""
}

/// Gemini contents[] plus the optional systemInstruction.
func parseGemini(_ text: String, _ warnings: inout [String]) throws -> [InternalMessage] {
    let root = try parseTranscriptJson(text, "Gemini transcript")
    guard root.isObject, case .arr(let contents)? = root["contents"] else {
        throw ChatFormatError(message: "Gemini transcript must be an object with a \"contents\" array")
    }
    var out: [InternalMessage] = []
    if let si = root["systemInstruction"] {
        out.append(InternalMessage("system", geminiText(si, "systemInstruction", &warnings)))
    }

    for (i, entry) in contents.enumerated() {
        guard entry.isObject else { throw ChatFormatError(message: "contents[\(i)] is not an object") }
        let where_ = "contents[\(i)]"
        let role = entry["role"]?.asStr ?? ""
        guard role == "user" || role == "model" else {
            let roleJson = entry["role"]?.toCompact ?? "null"
            throw ChatFormatError(message: "\(where_) has unsupported role: \(roleJson) (Gemini uses \"user\" or \"model\")")
        }
        guard case .arr(let parts)? = entry["parts"] else {
            throw ChatFormatError(message: "\(where_): parts must be an array")
        }

        if role == "model" {
            // A model turn keeps its text and function calls in ONE message,
            // mirroring an OpenAI assistant message with tool_calls.
            var texts: [String] = []
            var calls: [ToolCall] = []
            for part in parts {
                if let t = part["text"]?.asStr { texts.append(t); continue }
                if case .obj(let fc)? = part["functionCall"] {
                    calls.append(ToolCall(name: fc["name"]?.asStr ?? "", args: argsOf(fc["args"])))
                    continue
                }
                warnings.append("\(where_): dropped unsupported part (inlineData or similar)")
            }
            var msg = InternalMessage("assistant", texts.joined(separator: "\n"))
            if !calls.isEmpty { msg.toolCalls = calls }
            out.append(msg)
            continue
        }

        var texts: [String] = []
        func flushText() {
            if !texts.isEmpty {
                out.append(InternalMessage("user", texts.joined(separator: "\n")))
                texts.removeAll()
            }
        }
        for part in parts {
            if let t = part["text"]?.asStr { texts.append(t); continue }
            if part["functionCall"]?.isObject == true {
                let fc = part["functionCall"]!
                flushText()
                var msg = InternalMessage("assistant")
                msg.toolCalls = [ToolCall(name: fc["name"]?.asStr ?? "", args: argsOf(fc["args"]))]
                out.append(msg)
                continue
            }
            if part["functionResponse"]?.isObject == true {
                let fr = part["functionResponse"]!
                flushText()
                let name = fr["name"]?.asStr ?? ""
                var msg = InternalMessage("tool", argsOf(fr["response"]))
                msg.name = name
                msg.toolCallId = name
                out.append(msg)
                continue
            }
            warnings.append("\(where_): dropped unsupported part (inlineData or similar)")
        }
        flushText()
    }
    return out
}

/// A parsed Markdown header line: `**role**: text`.
func markdownHeader(_ line: String) -> (role: String, first: String)? {
    guard line.hasPrefix("**") else { return nil }
    let afterOpening = line.index(line.startIndex, offsetBy: 2)
    // Substring indices are shared with the parent string, so the found range
    // maps straight back onto `line`.
    guard let afterRole = line[afterOpening...].firstRange(of: "**:") else { return nil }
    let role = String(line[afterOpening..<afterRole.lowerBound])
    let mapped: String?
    switch role {
    case "system", "user", "assistant", "tool": mapped = role
    case "model": mapped = "assistant"
    default: mapped = nil
    }
    guard let mapped else { return nil }
    let rest = line[afterRole.upperBound...]
    return (mapped, String(rest.drop(while: { $0.isWhitespace })))
}

func finishMarkdownMessage(_ role: String, _ lines: inout [String]) -> InternalMessage {
    // Trim leading/trailing blank continuation lines but keep inner blank lines.
    while lines.count > 1 && lines[0].allSatisfy(\.isWhitespace) { lines.removeFirst() }
    while lines.count > 1 && lines[lines.count - 1].allSatisfy(\.isWhitespace) {
        lines.removeLast()
    }
    return InternalMessage(role, lines.joined(separator: "\n"))
}

/// Markdown transcript: `**role**: text` header lines with continuation lines
/// belonging to the same message. `model` maps to assistant.
func parseMarkdown(_ text: String, _ warnings: inout [String]) throws -> [InternalMessage] {
    var out: [InternalMessage] = []
    var current: (role: String, lines: [String])? = nil
    for line in text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
        if let (role, first) = markdownHeader(line) {
            if var cur = current { out.append(finishMarkdownMessage(cur.role, &cur.lines)) }
            current = (role, [first])
            continue
        }
        if var cur = current {
            cur.lines.append(line)
            current = (cur.role, cur.lines)
        } else if !line.allSatisfy(\.isWhitespace) {
            throw ChatFormatError(message: "Markdown transcript must start with a `**role**:` header line")
        }
    }
    if var cur = current { out.append(finishMarkdownMessage(cur.role, &cur.lines)) }
    return out
}

// MARK: - Serialize

func kv(_ pairs: [(String, Json)]) -> Json { .obj(pairs) }
func s(_ v: String) -> Json { .str(v) }

func serializeOpenAi(_ messages: [InternalMessage], _ warnings: inout [String]) -> String {
    let arr = messages.map { m -> Json in
        var pairs: [(String, Json)] = []
        if m.role == "tool" {
            pairs.append(("role", s("tool")))
            pairs.append(("content", s(m.content)))
            pairs.append(("tool_call_id", s(m.toolCallId ?? "")))
            if let name = m.name { pairs.append(("name", s(name))) }
        } else if m.role == "assistant" && m.toolCalls != nil {
            pairs.append(("role", s("assistant")))
            pairs.append(("content", m.content.isEmpty ? .null : s(m.content)))
            pairs.append(("tool_calls", .arr(m.toolCalls!.map { tc in
                kv([("id", s(tc.id)), ("type", s("function")),
                    ("function", kv([("name", s(tc.name)), ("arguments", s(tc.args))]))])
            })))
            if let name = m.name { pairs.append(("name", s(name))) }
        } else {
            pairs.append(("role", s(m.role)))
            pairs.append(("content", s(m.content)))
            if let name = m.name { pairs.append(("name", s(name))) }
        }
        return kv(pairs)
    }
    return kv([("messages", .arr(arr))]).toPretty + "\n"
}

func serializeAnthropic(_ messages: [InternalMessage], _ warnings: inout [String]) -> String {
    let system = messages.filter { $0.role == "system" }.map(\.content)
    var out: [Json] = []
    for (i, m) in messages.enumerated() {
        if m.role == "system" { continue }
        if m.name != nil && m.role != "tool" {
            warnings.append("message \(i): \"name\" has no Anthropic equivalent and was dropped")
        }
        switch m.role {
        case "user":
            out.append(kv([("role", s("user")), ("content", s(m.content))]))
        case "assistant":
            if let calls = m.toolCalls {
                var blocks: [Json] = []
                if !m.content.isEmpty { blocks.append(kv([("type", s("text")), ("text", s(m.content))])) }
                for tc in calls {
                    blocks.append(kv([("type", s("tool_use")), ("id", s(tc.id)), ("name", s(tc.name)),
                                      ("input", parseArgsOrEmpty(tc.args))]))
                }
                out.append(kv([("role", s("assistant")), ("content", .arr(blocks))]))
            } else {
                out.append(kv([("role", s("assistant")), ("content", s(m.content))]))
            }
        default:
            out.append(kv([
                ("role", s("user")),
                ("content", .arr([kv([
                    ("type", s("tool_result")),
                    ("tool_use_id", s(m.toolCallId ?? "")),
                    ("content", s(m.content)),
                ])])),
            ]))
        }
    }
    var pairs: [(String, Json)] = []
    if !out.isEmpty { pairs.append(("messages", .arr(out))) }
    if !system.isEmpty { pairs.append(("system", s(system.joined(separator: "\n\n")))) }
    return Json.obj(pairs).toPretty + "\n"
}

func serializeGemini(_ messages: [InternalMessage], _ warnings: inout [String]) -> String {
    let system = messages.filter { $0.role == "system" }.map(\.content)
    // (role, parts) turns; consecutive same-role parts merge into one turn.
    var turns: [(String, [Json])] = []
    func push(_ role: String, _ part: Json) {
        if var last = turns.last, last.0 == role {
            last.1.append(part)
            turns[turns.count - 1] = last
        } else {
            turns.append((role, [part]))
        }
    }
    for (i, m) in messages.enumerated() {
        if m.role == "system" { continue }
        if m.name != nil && m.role != "tool" {
            warnings.append("message \(i): \"name\" has no Gemini equivalent and was dropped")
        }
        switch m.role {
        case "user":
            push("user", kv([("text", s(m.content))]))
        case "assistant":
            if !m.content.isEmpty { push("model", kv([("text", s(m.content))])) }
            for tc in m.toolCalls ?? [] {
                push("model", kv([("functionCall", kv([("name", s(tc.name)),
                                                       ("args", parseArgsOrEmpty(tc.args))]))]))
            }
        default:
            let name = m.name ?? m.toolCallId ?? ""
            push("user", kv([("functionResponse", kv([("name", s(name)),
                                                       ("response", parseArgsOrEmpty(m.content))]))]))
        }
    }
    var pairs: [(String, Json)] = []
    if !turns.isEmpty {
        pairs.append(("contents", .arr(turns.map { (role, parts) in
            kv([("role", s(role)), ("parts", .arr(parts))])
        })))
    }
    if !system.isEmpty {
        pairs.append(("systemInstruction",
            kv([("parts", .arr([kv([("text", s(system.joined(separator: "\n\n")))])]))])))
    }
    return Json.obj(pairs).toPretty + "\n"
}

func serializeMarkdown(_ messages: [InternalMessage], _ warnings: inout [String]) -> String {
    for (i, m) in messages.enumerated() {
        if let calls = m.toolCalls, !calls.isEmpty {
            warnings.append("message \(i): tool calls are not representable in Markdown and were dropped")
        }
        if m.role == "tool" && m.toolCallId != nil {
            warnings.append("message \(i): tool result id is not representable in Markdown and was dropped")
        }
    }
    return messages.map { "**\($0.role)**: \($0.content)" }.joined(separator: "\n\n") + "\n"
}

let PARSERS: [String: (String, inout [String]) throws -> [InternalMessage]] = [
    "openai": parseOpenAi,
    "anthropic": parseAnthropic,
    "gemini": parseGemini,
    "markdown": parseMarkdown,
]

let SERIALIZERS: [String: ([InternalMessage], inout [String]) -> String] = [
    "openai": serializeOpenAi,
    "anthropic": serializeAnthropic,
    "gemini": serializeGemini,
    "markdown": serializeMarkdown,
]

/// Convert a transcript between chat formats through the shared internal shape.
///
/// Throws ChatFormatError when the transcript is empty, the source format
/// fails to parse, or from/to is not a known format.
func convert(_ transcript: String, _ from: String, _ to: String) throws -> ConversionResult {
    if transcript.allSatisfy(\.isWhitespace) {
        throw ChatFormatError(message: "Transcript is empty — paste a transcript first")
    }
    guard let parser = PARSERS[from] else {
        throw ChatFormatError(message: "Unknown source format: \(from)")
    }
    guard let serializer = SERIALIZERS[to] else {
        throw ChatFormatError(message: "Unknown target format: \(to)")
    }
    var warnings: [String] = []
    let messages = try parser(transcript, &warnings)
    return ConversionResult(output: serializer(messages, &warnings), warnings: 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 →