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