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 →