1use std::collections::BTreeMap;
17use std::fs::{File, OpenOptions};
18use std::io::Write;
19use std::path::Path;
20
21use serde::{Deserialize, Serialize};
22
23use crate::{ChatMessage, InterchangeError as Error, Result, Role, Session, ToolCall};
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct NativeTurn {
34 pub supercode_turn: u8,
36 pub ts: String,
38 pub role: Role,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub content: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub content_parts: Option<Vec<serde_json::Value>>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub tool_calls: Option<Vec<ToolCall>>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub tool_call_id: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub name: Option<String>,
55 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
61 pub metadata: BTreeMap<String, String>,
62}
63
64impl From<&ChatMessage> for NativeTurn {
65 fn from(msg: &ChatMessage) -> Self {
66 Self::from_with_timestamp_and_index(msg, now_rfc3339(), 0)
67 }
68}
69
70impl NativeTurn {
71 pub(crate) fn from_with_timestamp_and_index(
72 msg: &ChatMessage,
73 ts: String,
74 turn_index: u64,
75 ) -> Self {
76 let mut metadata = msg.metadata.clone();
77 metadata
78 .entry("timestamp".to_string())
79 .or_insert_with(|| ts.clone());
80 metadata
81 .entry("supercode_native_uuid".to_string())
82 .or_insert_with(|| native_turn_uuid(msg, &ts, turn_index));
83 NativeTurn {
84 supercode_turn: 1,
85 ts,
86 role: msg.role,
87 content: msg.content.clone(),
88 content_parts: msg.content_parts.clone(),
89 tool_calls: msg.tool_calls.clone(),
90 tool_call_id: msg.tool_call_id.clone(),
91 name: msg.name.clone(),
92 metadata,
93 }
94 }
95
96 pub fn into_message(self) -> ChatMessage {
100 ChatMessage {
101 role: self.role,
102 content: self.content,
103 content_parts: self.content_parts,
104 tool_calls: self.tool_calls,
105 tool_call_id: self.tool_call_id,
106 name: self.name,
107 metadata: self.metadata,
108 }
109 }
110}
111
112fn native_turn_uuid(msg: &ChatMessage, timestamp: &str, turn_index: u64) -> String {
120 let mut hasher = blake3::Hasher::new();
121 hasher.update(b"supercode-native-turn-uuid-v1\0");
122 hasher.update(timestamp.as_bytes());
123 hasher.update(&turn_index.to_le_bytes());
124 if let Ok(identity) = serde_json::to_vec(&NativeTurnIdentity::from(msg)) {
125 hasher.update(&identity);
126 }
127 let mut bytes = [0u8; 16];
128 bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
129 bytes[6] = (bytes[6] & 0x0f) | 0x40;
130 bytes[8] = (bytes[8] & 0x3f) | 0x80;
131 format!(
132 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
133 bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
134 bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
135 )
136}
137
138#[derive(Serialize)]
139struct NativeTurnIdentity<'a> {
140 role: Role,
141 content: &'a Option<String>,
142 content_parts: &'a Option<Vec<serde_json::Value>>,
143 tool_calls: &'a Option<Vec<ToolCall>>,
144 tool_call_id: &'a Option<String>,
145 name: &'a Option<String>,
146}
147
148impl<'a> From<&'a ChatMessage> for NativeTurnIdentity<'a> {
149 fn from(msg: &'a ChatMessage) -> Self {
150 Self {
151 role: msg.role,
152 content: &msg.content,
153 content_parts: &msg.content_parts,
154 tool_calls: &msg.tool_calls,
155 tool_call_id: &msg.tool_call_id,
156 name: &msg.name,
157 }
158 }
159}
160
161pub struct SidecarWriter {
169 file: File,
170 path: std::path::PathBuf,
171 fixed_timestamp: Option<String>,
172 next_turn_index: u64,
173}
174
175impl SidecarWriter {
176 pub fn create(path: &Path, session: &Session) -> Result<Self> {
184 Self::create_inner(path, session, None)
185 }
186
187 pub fn create_with_timestamp(
193 path: &Path,
194 session: &Session,
195 timestamp: impl Into<String>,
196 ) -> Result<Self> {
197 let timestamp = timestamp.into();
198 if !is_canonical_rfc3339_millis(×tamp) {
199 return Err(Error::Other(format!(
200 "invalid fixed sidecar timestamp: {timestamp:?}"
201 )));
202 }
203 Self::create_inner(path, session, Some(timestamp))
204 }
205
206 fn create_inner(
207 path: &Path,
208 session: &Session,
209 fixed_timestamp: Option<String>,
210 ) -> Result<Self> {
211 std::fs::write(
212 path,
213 session.to_native_jsonl_v2_with_timestamp(&[], fixed_timestamp.as_deref()),
214 )?;
215 let file = OpenOptions::new().append(true).open(path)?;
216 Ok(SidecarWriter {
217 file,
218 path: path.to_path_buf(),
219 fixed_timestamp,
220 next_turn_index: 0,
221 })
222 }
223
224 pub fn open_append(path: &Path) -> Result<Self> {
227 let next_turn_index = native_turn_count(path)?;
228 let file = OpenOptions::new().append(true).open(path)?;
229 Ok(SidecarWriter {
230 file,
231 path: path.to_path_buf(),
232 fixed_timestamp: None,
233 next_turn_index,
234 })
235 }
236
237 pub fn path(&self) -> &Path {
243 &self.path
244 }
245
246 pub fn append(&mut self, msg: &ChatMessage) -> Result<()> {
249 let timestamp = self.fixed_timestamp.clone().unwrap_or_else(now_rfc3339);
250 let turn = NativeTurn::from_with_timestamp_and_index(msg, timestamp, self.next_turn_index);
251 let mut line = serde_json::to_string(&turn).map_err(Error::Decode)?;
252 line.push('\n');
253 self.file.write_all(line.as_bytes())?;
254 self.file.flush()?;
255 self.next_turn_index = self.next_turn_index.saturating_add(1);
256 Ok(())
257 }
258}
259
260fn native_turn_count(path: &Path) -> Result<u64> {
261 let body = std::fs::read_to_string(path)?;
262 Ok(body
263 .lines()
264 .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
265 .filter(|record| {
266 record
267 .get("supercode_turn")
268 .and_then(|value| value.as_u64())
269 == Some(1)
270 })
271 .count() as u64)
272}
273
274fn is_canonical_rfc3339_millis(timestamp: &str) -> bool {
275 timestamp.len() == 24
276 && timestamp.as_bytes().get(4) == Some(&b'-')
277 && timestamp.as_bytes().get(7) == Some(&b'-')
278 && timestamp.as_bytes().get(10) == Some(&b'T')
279 && timestamp.as_bytes().get(13) == Some(&b':')
280 && timestamp.as_bytes().get(16) == Some(&b':')
281 && timestamp.as_bytes().get(19) == Some(&b'.')
282 && timestamp.as_bytes().get(23) == Some(&b'Z')
283 && timestamp.bytes().enumerate().all(|(index, byte)| {
284 matches!(index, 4 | 7 | 10 | 13 | 16 | 19 | 23) || byte.is_ascii_digit()
285 })
286 && rfc3339_to_ms(timestamp).is_some_and(|millis| ms_to_rfc3339(millis) == timestamp)
287}
288
289#[doc(hidden)]
299pub fn now_rfc3339() -> String {
300 let dur = std::time::SystemTime::now()
301 .duration_since(std::time::UNIX_EPOCH)
302 .unwrap_or_default();
303 civil_rfc3339(dur.as_secs(), dur.subsec_millis())
304}
305
306fn civil_rfc3339(unix_secs: u64, millis: u32) -> String {
307 let secs = unix_secs as i64;
308 let days = secs.div_euclid(86_400);
309 let rem = secs.rem_euclid(86_400);
310 let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
311
312 let z = days + 719_468;
314 let era = z.div_euclid(146_097);
315 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y = yoe + era * 400;
318 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 };
323
324 format!("{year:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z")
325}
326
327#[doc(hidden)]
335pub fn ms_to_rfc3339(ms: i64) -> String {
336 let secs = ms.div_euclid(1000);
337 let millis = ms.rem_euclid(1000) as u32;
338 civil_rfc3339(secs.max(0) as u64, millis)
344}
345
346#[doc(hidden)]
358pub fn rfc3339_to_ms(s: &str) -> Option<i64> {
359 let s = s.trim();
360 let s = s.strip_suffix('Z').unwrap_or(s);
361 let (date, time) = s.split_once('T')?;
362 let mut date_parts = date.splitn(3, '-');
363 let y: i64 = date_parts.next()?.parse().ok()?;
364 let mo: i64 = date_parts.next()?.parse().ok()?;
365 let d: i64 = date_parts.next()?.parse().ok()?;
366
367 let (time_main, frac) = match time.split_once('.') {
368 Some((t, f)) => (t, Some(f)),
369 None => (time, None),
370 };
371 let mut time_parts = time_main.splitn(3, ':');
372 let h: i64 = time_parts.next()?.parse().ok()?;
373 let mi: i64 = time_parts.next()?.parse().ok()?;
374 let sec: i64 = time_parts.next()?.parse().ok()?;
375 let millis: i64 = match frac {
376 Some(f) => {
377 let digits: String = f.chars().take_while(|c| c.is_ascii_digit()).collect();
378 if digits.is_empty() {
379 return None;
380 }
381 let mut padded = digits;
382 padded.truncate(3);
383 while padded.len() < 3 {
384 padded.push('0');
385 }
386 padded.parse().ok()?
387 }
388 None => 0,
389 };
390
391 let days = days_from_civil(y, mo, d)?;
392 let secs = days
393 .checked_mul(86_400)?
394 .checked_add(h * 3600 + mi * 60 + sec)?;
395 secs.checked_mul(1000)?.checked_add(millis)
396}
397
398fn days_from_civil(y: i64, m: i64, d: i64) -> Option<i64> {
404 if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
405 return None;
406 }
407 let y = if m <= 2 { y - 1 } else { y };
408 let era = if y >= 0 { y } else { y - 399 }.div_euclid(400);
409 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)
414}
415
416#[cfg(test)]
417mod tests {
418 use super::{civil_rfc3339, ms_to_rfc3339, rfc3339_to_ms, SidecarWriter};
419 use crate::session::Session;
420
421 #[test]
422 fn civil_rfc3339_known_epochs() {
423 assert_eq!(civil_rfc3339(0, 0), "1970-01-01T00:00:00.000Z");
425 assert_eq!(civil_rfc3339(1_700_000_000, 0), "2023-11-14T22:13:20.000Z");
426 assert_eq!(civil_rfc3339(1_893_456_000, 0), "2030-01-01T00:00:00.000Z");
427 assert_eq!(civil_rfc3339(1_582_934_400, 0), "2020-02-29T00:00:00.000Z");
429 assert_eq!(civil_rfc3339(0, 7), "1970-01-01T00:00:00.007Z");
430 }
431
432 #[test]
433 fn ms_iso_round_trip() {
434 for ms in [
435 0i64,
436 7,
437 1_700_000_000_123,
438 1_751_900_002_100,
439 1_893_456_000_000,
440 1_582_934_400_999,
441 ] {
442 let iso = ms_to_rfc3339(ms);
443 assert_eq!(
444 rfc3339_to_ms(&iso),
445 Some(ms),
446 "ms->iso->ms must be lossless for {ms} (iso={iso})"
447 );
448 }
449 }
450
451 #[test]
452 fn rfc3339_to_ms_known_values() {
453 assert_eq!(rfc3339_to_ms("1970-01-01T00:00:00.000Z"), Some(0));
454 assert_eq!(
455 rfc3339_to_ms("2023-11-14T22:13:20.000Z"),
456 Some(1_700_000_000_000)
457 );
458 assert_eq!(rfc3339_to_ms("not-a-timestamp"), None);
459 assert_eq!(rfc3339_to_ms(""), None);
460 assert_eq!(rfc3339_to_ms("1970-01-01T00:00:00Z"), Some(0));
462 }
463
464 #[test]
465 fn fixed_writer_timestamp_requires_canonical_rfc3339_milliseconds() {
466 let dir = std::env::temp_dir().join(format!(
467 "supercode-sidecar-fixed-timestamp-{}",
468 std::process::id()
469 ));
470 std::fs::create_dir_all(&dir).unwrap();
471 let session = Session::from_claude_code_str("").unwrap();
472 for (index, malformed) in [
473 "2026-07-19T12:00:00.000",
474 "2026-07-19T12:00:00.000Zjunk",
475 "2026-07-19T25:00:00.000Z",
476 "2026-07-19T12:60:00.000Z",
477 "2026-07-19T12:00:60.000Z",
478 "2026-02-31T12:00:00.000Z",
479 "2026-07-19T12:00:00Z",
480 ]
481 .into_iter()
482 .enumerate()
483 {
484 assert!(
485 SidecarWriter::create_with_timestamp(
486 &dir.join(format!("invalid-{index}.jsonl")),
487 &session,
488 malformed,
489 )
490 .is_err(),
491 "malformed timestamp was accepted: {malformed}"
492 );
493 }
494 let valid_path = dir.join("valid.jsonl");
495 SidecarWriter::create_with_timestamp(&valid_path, &session, "2026-07-19T12:00:00.000Z")
496 .unwrap();
497 assert!(std::fs::read_to_string(valid_path)
498 .unwrap()
499 .contains(r#""created":"2026-07-19T12:00:00.000Z""#));
500 std::fs::remove_dir_all(dir).ok();
501 }
502}