use std::collections::BTreeMap;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::{ChatMessage, InterchangeError as Error, Result, Role, Session, ToolCall};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NativeTurn {
pub supercode_turn: u8,
pub ts: String,
pub role: Role,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_parts: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, String>,
}
impl From<&ChatMessage> for NativeTurn {
fn from(msg: &ChatMessage) -> Self {
Self::from_with_timestamp_and_index(msg, now_rfc3339(), 0)
}
}
impl NativeTurn {
pub(crate) fn from_with_timestamp_and_index(
msg: &ChatMessage,
ts: String,
turn_index: u64,
) -> Self {
let mut metadata = msg.metadata.clone();
metadata
.entry("timestamp".to_string())
.or_insert_with(|| ts.clone());
metadata
.entry("supercode_native_uuid".to_string())
.or_insert_with(|| native_turn_uuid(msg, &ts, turn_index));
NativeTurn {
supercode_turn: 1,
ts,
role: msg.role,
content: msg.content.clone(),
content_parts: msg.content_parts.clone(),
tool_calls: msg.tool_calls.clone(),
tool_call_id: msg.tool_call_id.clone(),
name: msg.name.clone(),
metadata,
}
}
pub fn into_message(self) -> ChatMessage {
ChatMessage {
role: self.role,
content: self.content,
content_parts: self.content_parts,
tool_calls: self.tool_calls,
tool_call_id: self.tool_call_id,
name: self.name,
metadata: self.metadata,
}
}
}
fn native_turn_uuid(msg: &ChatMessage, timestamp: &str, turn_index: u64) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(b"supercode-native-turn-uuid-v1\0");
hasher.update(timestamp.as_bytes());
hasher.update(&turn_index.to_le_bytes());
if let Ok(identity) = serde_json::to_vec(&NativeTurnIdentity::from(msg)) {
hasher.update(&identity);
}
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
)
}
#[derive(Serialize)]
struct NativeTurnIdentity<'a> {
role: Role,
content: &'a Option<String>,
content_parts: &'a Option<Vec<serde_json::Value>>,
tool_calls: &'a Option<Vec<ToolCall>>,
tool_call_id: &'a Option<String>,
name: &'a Option<String>,
}
impl<'a> From<&'a ChatMessage> for NativeTurnIdentity<'a> {
fn from(msg: &'a ChatMessage) -> Self {
Self {
role: msg.role,
content: &msg.content,
content_parts: &msg.content_parts,
tool_calls: &msg.tool_calls,
tool_call_id: &msg.tool_call_id,
name: &msg.name,
}
}
}
pub struct SidecarWriter {
file: File,
path: std::path::PathBuf,
fixed_timestamp: Option<String>,
next_turn_index: u64,
}
impl SidecarWriter {
pub fn create(path: &Path, session: &Session) -> Result<Self> {
Self::create_inner(path, session, None)
}
pub fn create_with_timestamp(
path: &Path,
session: &Session,
timestamp: impl Into<String>,
) -> Result<Self> {
let timestamp = timestamp.into();
if !is_canonical_rfc3339_millis(×tamp) {
return Err(Error::Other(format!(
"invalid fixed sidecar timestamp: {timestamp:?}"
)));
}
Self::create_inner(path, session, Some(timestamp))
}
fn create_inner(
path: &Path,
session: &Session,
fixed_timestamp: Option<String>,
) -> Result<Self> {
std::fs::write(
path,
session.to_native_jsonl_v2_with_timestamp(&[], fixed_timestamp.as_deref()),
)?;
let file = OpenOptions::new().append(true).open(path)?;
Ok(SidecarWriter {
file,
path: path.to_path_buf(),
fixed_timestamp,
next_turn_index: 0,
})
}
pub fn open_append(path: &Path) -> Result<Self> {
let next_turn_index = native_turn_count(path)?;
let file = OpenOptions::new().append(true).open(path)?;
Ok(SidecarWriter {
file,
path: path.to_path_buf(),
fixed_timestamp: None,
next_turn_index,
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn append(&mut self, msg: &ChatMessage) -> Result<()> {
let timestamp = self.fixed_timestamp.clone().unwrap_or_else(now_rfc3339);
let turn = NativeTurn::from_with_timestamp_and_index(msg, timestamp, self.next_turn_index);
let mut line = serde_json::to_string(&turn).map_err(Error::Decode)?;
line.push('\n');
self.file.write_all(line.as_bytes())?;
self.file.flush()?;
self.next_turn_index = self.next_turn_index.saturating_add(1);
Ok(())
}
}
fn native_turn_count(path: &Path) -> Result<u64> {
let body = std::fs::read_to_string(path)?;
Ok(body
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.filter(|record| {
record
.get("supercode_turn")
.and_then(|value| value.as_u64())
== Some(1)
})
.count() as u64)
}
fn is_canonical_rfc3339_millis(timestamp: &str) -> bool {
timestamp.len() == 24
&& timestamp.as_bytes().get(4) == Some(&b'-')
&& timestamp.as_bytes().get(7) == Some(&b'-')
&& timestamp.as_bytes().get(10) == Some(&b'T')
&& timestamp.as_bytes().get(13) == Some(&b':')
&& timestamp.as_bytes().get(16) == Some(&b':')
&& timestamp.as_bytes().get(19) == Some(&b'.')
&& timestamp.as_bytes().get(23) == Some(&b'Z')
&& timestamp.bytes().enumerate().all(|(index, byte)| {
matches!(index, 4 | 7 | 10 | 13 | 16 | 19 | 23) || byte.is_ascii_digit()
})
&& rfc3339_to_ms(timestamp).is_some_and(|millis| ms_to_rfc3339(millis) == timestamp)
}
#[doc(hidden)]
pub fn now_rfc3339() -> String {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
civil_rfc3339(dur.as_secs(), dur.subsec_millis())
}
fn civil_rfc3339(unix_secs: u64, millis: u32) -> String {
let secs = unix_secs as i64;
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let year = if m <= 2 { y + 1 } else { y };
format!("{year:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z")
}
#[doc(hidden)]
pub fn ms_to_rfc3339(ms: i64) -> String {
let secs = ms.div_euclid(1000);
let millis = ms.rem_euclid(1000) as u32;
civil_rfc3339(secs.max(0) as u64, millis)
}
#[doc(hidden)]
pub fn rfc3339_to_ms(s: &str) -> Option<i64> {
let s = s.trim();
let s = s.strip_suffix('Z').unwrap_or(s);
let (date, time) = s.split_once('T')?;
let mut date_parts = date.splitn(3, '-');
let y: i64 = date_parts.next()?.parse().ok()?;
let mo: i64 = date_parts.next()?.parse().ok()?;
let d: i64 = date_parts.next()?.parse().ok()?;
let (time_main, frac) = match time.split_once('.') {
Some((t, f)) => (t, Some(f)),
None => (time, None),
};
let mut time_parts = time_main.splitn(3, ':');
let h: i64 = time_parts.next()?.parse().ok()?;
let mi: i64 = time_parts.next()?.parse().ok()?;
let sec: i64 = time_parts.next()?.parse().ok()?;
let millis: i64 = match frac {
Some(f) => {
let digits: String = f.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return None;
}
let mut padded = digits;
padded.truncate(3);
while padded.len() < 3 {
padded.push('0');
}
padded.parse().ok()?
}
None => 0,
};
let days = days_from_civil(y, mo, d)?;
let secs = days
.checked_mul(86_400)?
.checked_add(h * 3600 + mi * 60 + sec)?;
secs.checked_mul(1000)?.checked_add(millis)
}
fn days_from_civil(y: i64, m: i64, d: i64) -> Option<i64> {
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 }.div_euclid(400);
let yoe = y - era * 400; let mp = if m > 2 { m - 3 } else { m + 9 }; let doy = (153 * mp + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; Some(era * 146_097 + doe - 719_468)
}
#[cfg(test)]
mod tests {
use super::{civil_rfc3339, ms_to_rfc3339, rfc3339_to_ms, SidecarWriter};
use crate::session::Session;
#[test]
fn civil_rfc3339_known_epochs() {
assert_eq!(civil_rfc3339(0, 0), "1970-01-01T00:00:00.000Z");
assert_eq!(civil_rfc3339(1_700_000_000, 0), "2023-11-14T22:13:20.000Z");
assert_eq!(civil_rfc3339(1_893_456_000, 0), "2030-01-01T00:00:00.000Z");
assert_eq!(civil_rfc3339(1_582_934_400, 0), "2020-02-29T00:00:00.000Z");
assert_eq!(civil_rfc3339(0, 7), "1970-01-01T00:00:00.007Z");
}
#[test]
fn ms_iso_round_trip() {
for ms in [
0i64,
7,
1_700_000_000_123,
1_751_900_002_100,
1_893_456_000_000,
1_582_934_400_999,
] {
let iso = ms_to_rfc3339(ms);
assert_eq!(
rfc3339_to_ms(&iso),
Some(ms),
"ms->iso->ms must be lossless for {ms} (iso={iso})"
);
}
}
#[test]
fn rfc3339_to_ms_known_values() {
assert_eq!(rfc3339_to_ms("1970-01-01T00:00:00.000Z"), Some(0));
assert_eq!(
rfc3339_to_ms("2023-11-14T22:13:20.000Z"),
Some(1_700_000_000_000)
);
assert_eq!(rfc3339_to_ms("not-a-timestamp"), None);
assert_eq!(rfc3339_to_ms(""), None);
assert_eq!(rfc3339_to_ms("1970-01-01T00:00:00Z"), Some(0));
}
#[test]
fn fixed_writer_timestamp_requires_canonical_rfc3339_milliseconds() {
let dir = std::env::temp_dir().join(format!(
"supercode-sidecar-fixed-timestamp-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let session = Session::from_claude_code_str("").unwrap();
for (index, malformed) in [
"2026-07-19T12:00:00.000",
"2026-07-19T12:00:00.000Zjunk",
"2026-07-19T25:00:00.000Z",
"2026-07-19T12:60:00.000Z",
"2026-07-19T12:00:60.000Z",
"2026-02-31T12:00:00.000Z",
"2026-07-19T12:00:00Z",
]
.into_iter()
.enumerate()
{
assert!(
SidecarWriter::create_with_timestamp(
&dir.join(format!("invalid-{index}.jsonl")),
&session,
malformed,
)
.is_err(),
"malformed timestamp was accepted: {malformed}"
);
}
let valid_path = dir.join("valid.jsonl");
SidecarWriter::create_with_timestamp(&valid_path, &session, "2026-07-19T12:00:00.000Z")
.unwrap();
assert!(std::fs::read_to_string(valid_path)
.unwrap()
.contains(r#""created":"2026-07-19T12:00:00.000Z""#));
std::fs::remove_dir_all(dir).ok();
}
}