1use serde_json::Value;
7use std::collections::{BTreeMap, HashSet};
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use crate::types::{AgentSession, SessionCandidate, SessionDirStat, TokenUsage};
13use crate::{AGENT_CLAUDE, AGENT_CODEX, AGENT_GEMINI};
14
15pub fn discover_session_files() -> Vec<SessionCandidate> {
17 user_home_dir()
18 .as_deref()
19 .map(discover_session_files_in_home)
20 .unwrap_or_default()
21}
22
23pub fn discover_session_files_in_home(home: &Path) -> Vec<SessionCandidate> {
25 let roots = [
26 (AGENT_CLAUDE, home.join(".claude/projects")),
27 (AGENT_CODEX, home.join(".codex/sessions")),
28 (AGENT_GEMINI, home.join(".gemini/tmp")),
29 ];
30 let mut out = Vec::new();
31 for (agent, dir) in roots {
32 walk_agent_files(agent, &dir, &mut |path, meta| {
33 out.push(SessionCandidate {
34 agent,
35 path: path.to_path_buf(),
36 updated: meta.modified().unwrap_or(UNIX_EPOCH),
37 });
38 });
39 }
40 out
41}
42
43pub fn count_session_dirs() -> Vec<SessionDirStat> {
45 let Some(home) = user_home_dir() else {
46 return Vec::new();
47 };
48 [
49 (AGENT_CLAUDE, home.join(".claude/projects")),
50 (AGENT_CODEX, home.join(".codex/sessions")),
51 (AGENT_GEMINI, home.join(".gemini/tmp")),
52 ]
53 .into_iter()
54 .filter_map(|(agent, dir)| {
55 let (mut sessions, mut bytes) = (0usize, 0u64);
56 walk_agent_files(agent, &dir, &mut |_, meta| {
57 sessions += 1;
58 bytes += meta.len();
59 });
60 (sessions > 0).then_some(SessionDirStat {
61 agent,
62 dir,
63 sessions,
64 bytes,
65 })
66 })
67 .collect()
68}
69
70pub fn parse_session_file(candidate: &SessionCandidate) -> Option<AgentSession> {
72 let content = fs::read_to_string(&candidate.path).ok()?;
73 parse_session_content(
74 candidate.agent,
75 &candidate.path,
76 candidate.updated,
77 &content,
78 )
79}
80
81pub fn parse_session_path(path: &Path) -> Option<AgentSession> {
83 let agent = agent_source_for_path(path)?;
84 let updated = fs::metadata(path)
85 .and_then(|metadata| metadata.modified())
86 .unwrap_or(UNIX_EPOCH);
87 parse_session_file(&SessionCandidate {
88 agent,
89 path: path.to_path_buf(),
90 updated,
91 })
92}
93
94pub fn parse_session_content(
96 agent: &str,
97 path: &Path,
98 updated: SystemTime,
99 content: &str,
100) -> Option<AgentSession> {
101 if agent == AGENT_GEMINI {
102 parse_gemini_json(path, updated, content)
103 } else {
104 parse_jsonl(agent, path, updated, content)
105 }
106}
107
108pub fn session_log_path_from_str(raw: &str) -> Option<PathBuf> {
110 let trimmed = raw.trim().trim_end_matches(" (deleted)");
111 if trimmed.is_empty() {
112 return None;
113 }
114 let path = Path::new(trimmed);
115 if !path.is_absolute() || !is_agent_session_file(path) {
116 return None;
117 }
118 agent_source_for_path(path).map(|_| normalize_session_log_path(path))
119}
120
121pub fn normalize_session_log_path(path: &Path) -> PathBuf {
123 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
124}
125
126pub fn agent_source_for_path(path: &Path) -> Option<&'static str> {
128 let value = path.to_string_lossy();
129 if value.contains("/.claude/") && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
130 {
131 Some(AGENT_CLAUDE)
132 } else if value.contains("/.codex/")
133 && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
134 {
135 Some(AGENT_CODEX)
136 } else if value.contains("/.gemini/")
137 && path.extension().and_then(|ext| ext.to_str()) == Some("json")
138 {
139 Some(AGENT_GEMINI)
140 } else {
141 None
142 }
143}
144
145pub fn fixture_session_path(agent: &str, home: &Path) -> Option<PathBuf> {
147 match agent {
148 AGENT_CLAUDE => Some(home.join(".claude/projects/test/session.jsonl")),
149 AGENT_CODEX => Some(home.join(".codex/sessions/2026/06/02/session.jsonl")),
150 AGENT_GEMINI => Some(home.join(".gemini/tmp/test/chats/session-test.json")),
151 _ => None,
152 }
153}
154
155pub fn is_codex_cli_entrypoint(target: Option<&str>) -> bool {
157 target.is_some_and(|target| {
158 Path::new(target).file_name().and_then(|name| name.to_str()) == Some("codex")
159 && !target.contains("/node_modules/")
160 })
161}
162
163pub fn codex_exec_prompt(command: &str) -> Option<String> {
165 let mut args = command.split_once(" exec ")?.1.trim();
166 while let Some(rest) = strip_codex_exec_option(args) {
167 args = rest.trim_start();
168 }
169 (!args.starts_with('-'))
170 .then(|| args.trim_matches(['"', '\'']))
171 .and_then(clean_prompt_text)
172}
173
174fn parse_jsonl(
179 agent: &str,
180 path: &Path,
181 updated: SystemTime,
182 content: &str,
183) -> Option<AgentSession> {
184 let mut acc = SessionAccumulator::new(agent, path, updated);
185 let mut codex_model = String::new();
186 let mut claude_message_models = BTreeMap::<String, TokenUsage>::new();
187 let mut claude_seen_usage = HashSet::new();
188
189 for line in content.lines() {
190 let Ok(obj) = serde_json::from_str::<Value>(line) else {
191 continue;
192 };
193 let (session_id, conversation_id) = local_session_ids(&obj);
194 if let Some(id) = session_id {
195 acc.session_id = id;
196 }
197 if let Some(id) = conversation_id {
198 acc.conversation_id = Some(id);
199 }
200 if acc.cwd.is_none() {
201 acc.cwd = obj
202 .get("cwd")
203 .and_then(Value::as_str)
204 .or_else(|| obj.pointer("/payload/cwd").and_then(Value::as_str))
205 .filter(|s| !s.is_empty())
206 .map(ToString::to_string);
207 }
208 if let Some(ts) = obj.get("timestamp").and_then(Value::as_str) {
209 acc.last_message_at = Some(ts.to_string());
210 acc.end_timestamp_ms = iso_ms(ts).or(acc.end_timestamp_ms);
211 }
212 let typ = obj.get("type").and_then(Value::as_str).unwrap_or("");
213 match (agent, typ) {
214 (AGENT_CLAUDE, "result") => {
215 acc.duration_ms = json_u64(&obj, "duration_ms");
216 if let Some(model_usage) = obj.get("modelUsage").and_then(Value::as_object) {
217 for (name, usage) in model_usage {
218 acc.model.get_or_insert_with(|| name.clone());
219 acc.add_usage(
220 name,
221 json_i64(usage, "inputTokens"),
222 json_i64(usage, "outputTokens"),
223 json_i64(usage, "cacheCreationInputTokens"),
224 json_i64(usage, "cacheReadInputTokens"),
225 0,
226 );
227 }
228 }
229 }
230 (AGENT_CLAUDE, "assistant") => {
231 if let Some(name) = obj.pointer("/message/model").and_then(Value::as_str) {
232 acc.model.get_or_insert_with(|| name.to_string());
233 }
234 if let Some(usage) = obj.pointer("/message/usage")
235 && claude_seen_usage.insert(claude_usage_key(&obj))
236 {
237 let name = obj
238 .pointer("/message/model")
239 .and_then(Value::as_str)
240 .unwrap_or("unknown");
241 add_usage(
242 &mut claude_message_models,
243 name,
244 json_i64(usage, "input_tokens"),
245 json_i64(usage, "output_tokens"),
246 json_i64(usage, "cache_creation_input_tokens"),
247 json_i64(usage, "cache_read_input_tokens"),
248 0,
249 );
250 }
251 if let Some(items) = obj.pointer("/message/content").and_then(Value::as_array) {
252 for item in items
253 .iter()
254 .filter(|item| item.get("type").and_then(Value::as_str) == Some("tool_use"))
255 {
256 let name = item.get("name").and_then(Value::as_str).unwrap_or("?");
257 acc.add_tool(name);
258 if let Some(fp) = item
259 .pointer("/input/file_path")
260 .and_then(Value::as_str)
261 .filter(|s| !is_noise_path(s))
262 {
263 acc.add_file(fp);
264 }
265 }
266 }
267 }
268 (AGENT_CLAUDE, "queue-operation") if acc.prompt_preview.is_none() => {
269 if obj.get("operation").and_then(Value::as_str) == Some("enqueue")
270 && let Some(text) = obj.get("content").and_then(Value::as_str)
271 && let Some(text) = clean_prompt_text(text)
272 {
273 acc.prompt_preview = Some(text);
274 }
275 }
276 (AGENT_CLAUDE, "last-prompt") if acc.prompt_preview.is_none() => {
277 if let Some(text) = obj.get("lastPrompt").and_then(Value::as_str)
278 && let Some(text) = clean_prompt_text(text)
279 {
280 acc.prompt_preview = Some(text);
281 }
282 }
283 (AGENT_CLAUDE, "user") => {
284 if acc.prompt_preview.is_none()
285 && !is_claude_tool_result(&obj)
286 && let Some(text) =
287 local_message_preview(obj.pointer("/message/content").unwrap_or(&obj))
288 {
289 acc.prompt_preview = Some(text);
290 }
291 }
292 (AGENT_CODEX, "turn_context") => {
293 if let Some(name) = obj.pointer("/payload/model").and_then(Value::as_str) {
294 codex_model = name.to_string();
295 acc.model = Some(name.to_string());
296 }
297 }
298 (AGENT_CODEX, "event_msg") => {
299 if obj.pointer("/payload/type").and_then(Value::as_str) == Some("token_count")
300 && let Some(usage) = obj.pointer("/payload/info/total_token_usage")
301 {
302 let name = if codex_model.is_empty() {
303 "unknown"
304 } else {
305 &codex_model
306 };
307 acc.set_usage(
308 name,
309 json_i64(usage, "input_tokens"),
310 json_i64(usage, "output_tokens"),
311 0,
312 0,
313 json_i64(usage, "total_tokens"),
314 );
315 }
316 }
317 (AGENT_CODEX, "response_item")
318 if obj.pointer("/payload/type").and_then(Value::as_str)
319 == Some("function_call") =>
320 {
321 let name = obj
322 .pointer("/payload/name")
323 .and_then(Value::as_str)
324 .unwrap_or("?");
325 acc.add_tool(name);
326 }
327 (AGENT_CODEX, "message" | "input" | "user") => {
328 if let Some(text) = local_message_preview(&obj) {
329 acc.prompt_preview = Some(text);
330 }
331 }
332 _ if acc.prompt_preview.is_none() && typ.contains("user") => {
333 if let Some(text) = local_message_preview(&obj) {
334 acc.prompt_preview = Some(text);
335 }
336 }
337 _ => {}
338 }
339 }
340
341 if acc.model_usage.is_empty() {
342 acc.model_usage = claude_message_models;
343 }
344 acc.finish()
345}
346
347fn parse_gemini_json(path: &Path, updated: SystemTime, content: &str) -> Option<AgentSession> {
348 let root: Value = serde_json::from_str(content).ok()?;
349 let mut acc = SessionAccumulator::new(AGENT_GEMINI, path, updated);
350 if let Some(id) = root.get("sessionId").and_then(Value::as_str) {
351 acc.session_id = id.to_string();
352 acc.conversation_id = Some(id.to_string());
353 }
354 acc.start_timestamp_ms = root
355 .get("startTime")
356 .and_then(Value::as_str)
357 .and_then(iso_ms);
358 acc.end_timestamp_ms = root
359 .get("lastUpdated")
360 .and_then(Value::as_str)
361 .and_then(iso_ms)
362 .or(acc.start_timestamp_ms);
363 acc.duration_ms = acc
364 .start_timestamp_ms
365 .zip(acc.end_timestamp_ms)
366 .map(|(start, end)| end.saturating_sub(start))
367 .unwrap_or_default();
368
369 let Some(messages) = root.get("messages").and_then(Value::as_array) else {
370 return acc.finish();
371 };
372 for msg in messages {
373 if let Some(ts) = msg.get("timestamp").and_then(Value::as_str) {
374 acc.last_message_at = Some(ts.to_string());
375 }
376 match msg.get("type").and_then(Value::as_str) {
377 Some("user") if acc.prompt_preview.is_none() => {
378 if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
379 acc.prompt_preview = Some(text);
380 }
381 }
382 Some("gemini") | Some("assistant") | Some("model") => {
383 if let Some(model) = msg.get("model").and_then(Value::as_str) {
384 acc.model.get_or_insert_with(|| model.to_string());
385 if let Some(tokens) = msg.get("tokens") {
386 acc.add_usage(
387 model,
388 json_i64(tokens, "input"),
389 json_i64(tokens, "output"),
390 0,
391 json_i64(tokens, "cached"),
392 json_i64(tokens, "total"),
393 );
394 }
395 }
396 if let Some(tool_calls) = msg.get("toolCalls").and_then(Value::as_array) {
397 for call in tool_calls {
398 let name = call.get("name").and_then(Value::as_str).unwrap_or("?");
399 acc.add_tool(name);
400 if let Some(path) = find_file_arg(call).filter(|path| !is_noise_path(path))
401 {
402 acc.add_file(path);
403 }
404 }
405 }
406 }
407 _ => {}
408 }
409 }
410 acc.finish()
411}
412
413struct SessionAccumulator {
414 agent_type: String,
415 session_id: String,
416 conversation_id: Option<String>,
417 path: PathBuf,
418 updated: SystemTime,
419 start_timestamp_ms: Option<u64>,
420 end_timestamp_ms: Option<u64>,
421 model: Option<String>,
422 model_usage: BTreeMap<String, TokenUsage>,
423 tools: BTreeMap<String, usize>,
424 files: BTreeMap<String, usize>,
425 prompt_preview: Option<String>,
426 duration_ms: u64,
427 cwd: Option<String>,
428 last_message_at: Option<String>,
429}
430
431impl SessionAccumulator {
432 fn new(agent: &str, path: &Path, updated: SystemTime) -> Self {
433 let normalized = normalize_session_log_path(path);
434 let session_id = path
435 .file_stem()
436 .and_then(|stem| stem.to_str())
437 .unwrap_or("session")
438 .to_string();
439 Self {
440 agent_type: agent.to_string(),
441 session_id,
442 conversation_id: None,
443 path: normalized.clone(),
444 updated,
445 start_timestamp_ms: None,
446 end_timestamp_ms: Some(system_time_ms(updated)),
447 model: None,
448 model_usage: BTreeMap::new(),
449 tools: BTreeMap::new(),
450 files: BTreeMap::new(),
451 prompt_preview: None,
452 duration_ms: 0,
453 cwd: None,
454 last_message_at: None,
455 }
456 }
457
458 fn add_usage(
459 &mut self,
460 model: &str,
461 input: i64,
462 output: i64,
463 cache_creation: i64,
464 cache_read: i64,
465 total: i64,
466 ) {
467 add_usage(
468 &mut self.model_usage,
469 model,
470 input,
471 output,
472 cache_creation,
473 cache_read,
474 total,
475 );
476 }
477
478 fn set_usage(
479 &mut self,
480 model: &str,
481 input: i64,
482 output: i64,
483 cache_creation: i64,
484 cache_read: i64,
485 total: i64,
486 ) {
487 let mut usage = TokenUsage::default();
488 usage.add(input, output, cache_creation, cache_read, total);
489 self.model_usage.insert(model.to_string(), usage);
490 }
491
492 fn add_tool(&mut self, name: &str) {
493 *self.tools.entry(name.to_string()).or_default() += 1;
494 }
495
496 fn add_file(&mut self, path: &str) {
497 *self.files.entry(path.to_string()).or_default() += 1;
498 }
499
500 fn finish(self) -> Option<AgentSession> {
501 let token_usage =
502 self.model_usage
503 .values()
504 .fold(TokenUsage::default(), |mut total, usage| {
505 total.input_tokens += usage.input_tokens;
506 total.output_tokens += usage.output_tokens;
507 total.cache_creation_tokens += usage.cache_creation_tokens;
508 total.cache_read_tokens += usage.cache_read_tokens;
509 total.total_tokens += usage.total_tokens;
510 total
511 });
512 if token_usage.total_tokens == 0
513 && self.tools.is_empty()
514 && self.prompt_preview.is_none()
515 && self.model.is_none()
516 {
517 return None;
518 }
519 let display_id = format!("{}:{}", self.agent_type, short_session_id(&self.session_id));
520 Some(AgentSession {
521 agent_type: self.agent_type,
522 session_id: self.session_id,
523 conversation_id: self.conversation_id,
524 display_id,
525 path: self.path,
526 updated: self.updated,
527 start_timestamp_ms: self
528 .start_timestamp_ms
529 .or_else(|| Some(system_time_ms(self.updated).saturating_sub(self.duration_ms))),
530 end_timestamp_ms: self.end_timestamp_ms,
531 model: self.model,
532 usage: token_usage,
533 model_usage: self.model_usage,
534 tools: self.tools,
535 files: self.files,
536 prompt_preview: self.prompt_preview,
537 duration_ms: self.duration_ms,
538 cwd: self.cwd,
539 last_message_at: self.last_message_at,
540 })
541 }
542}
543
544fn walk_agent_files(agent: &'static str, dir: &Path, f: &mut dyn FnMut(&Path, &fs::Metadata)) {
549 let Ok(entries) = fs::read_dir(dir) else {
550 return;
551 };
552 for entry in entries.flatten() {
553 let path = entry.path();
554 if path.is_dir() {
555 walk_agent_files(agent, &path, f);
556 } else if is_agent_file_for(agent, &path)
557 && let Ok(meta) = path.metadata()
558 {
559 f(&path, &meta);
560 }
561 }
562}
563
564fn is_agent_session_file(path: &Path) -> bool {
565 agent_source_for_path(path).is_some()
566}
567
568fn is_agent_file_for(agent: &str, path: &Path) -> bool {
569 match agent {
570 AGENT_CLAUDE | AGENT_CODEX => {
571 path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
572 }
573 AGENT_GEMINI => {
574 path.extension().and_then(|ext| ext.to_str()) == Some("json")
575 && path
576 .file_name()
577 .and_then(|name| name.to_str())
578 .is_some_and(|name| name.starts_with("session-"))
579 && path.to_string_lossy().contains("/chats/")
580 }
581 _ => false,
582 }
583}
584
585pub(crate) fn user_home_dir() -> Option<PathBuf> {
586 std::env::var("SUDO_USER")
587 .ok()
588 .and_then(|user| {
589 fs::read_to_string("/etc/passwd").ok().and_then(|passwd| {
590 passwd
591 .lines()
592 .find(|line| line.starts_with(&format!("{user}:")))
593 .and_then(|line| line.split(':').nth(5))
594 .map(PathBuf::from)
595 })
596 })
597 .or_else(dirs::home_dir)
598}
599
600fn add_usage(
601 models: &mut BTreeMap<String, TokenUsage>,
602 model: &str,
603 input: i64,
604 output: i64,
605 cache_creation: i64,
606 cache_read: i64,
607 total: i64,
608) {
609 models.entry(model.to_string()).or_default().add(
610 input,
611 output,
612 cache_creation,
613 cache_read,
614 total,
615 );
616}
617
618fn local_session_ids(obj: &Value) -> (Option<String>, Option<String>) {
619 let session_id = first_json_string(
620 obj,
621 &["sessionId", "session_id"],
622 &["/payload/session_id", "/payload/sessionId"],
623 );
624 let conversation_id = first_json_string(
625 obj,
626 &["conversation_id", "conversationId", "thread_id", "threadId"],
627 &[
628 "/payload/conversation_id",
629 "/payload/conversationId",
630 "/payload/thread_id",
631 "/payload/threadId",
632 ],
633 )
634 .or_else(|| session_id.clone());
635 (
636 session_id.or_else(|| conversation_id.clone()),
637 conversation_id,
638 )
639}
640
641fn first_json_string(obj: &Value, keys: &[&str], pointers: &[&str]) -> Option<String> {
642 keys.iter()
643 .filter_map(|key| obj.get(*key).and_then(Value::as_str))
644 .chain(
645 pointers
646 .iter()
647 .filter_map(|pointer| obj.pointer(pointer).and_then(Value::as_str)),
648 )
649 .find(|value| !value.is_empty())
650 .map(str::to_string)
651}
652
653fn strip_codex_exec_option(args: &str) -> Option<&str> {
654 let (head, rest) = args.split_once(char::is_whitespace).unwrap_or((args, ""));
655 match head {
656 "--json" | "--skip-git-repo-check" | "--ephemeral" => Some(rest),
657 "-C" | "-a" | "-s" | "-m" | "-c" | "-p" => rest
658 .trim_start()
659 .split_once(char::is_whitespace)
660 .map(|(_, rest)| rest),
661 _ => None,
662 }
663}
664
665fn claude_usage_key(obj: &Value) -> String {
666 obj.get("requestId")
667 .or_else(|| obj.pointer("/message/id"))
668 .or_else(|| obj.get("uuid"))
669 .and_then(Value::as_str)
670 .unwrap_or("usage")
671 .to_string()
672}
673
674fn local_message_preview(value: &Value) -> Option<String> {
675 let mut parts = Vec::new();
676 collect_local_text(value, &mut parts);
677 clean_prompt_text(&parts.join(" "))
678}
679
680fn collect_local_text(value: &Value, out: &mut Vec<String>) {
681 match value {
682 Value::String(text) => out.push(text.clone()),
683 Value::Array(items) => {
684 for item in items {
685 collect_local_text(item, out);
686 }
687 }
688 Value::Object(obj) => {
689 if obj.get("type").and_then(Value::as_str).is_some_and(|typ| {
690 typ == "tool_use" || typ == "function_call" || typ == "tool_result"
691 }) {
692 return;
693 }
694 for key in ["text", "content", "message", "input", "prompt"] {
695 if let Some(value) = obj.get(key) {
696 collect_local_text(value, out);
697 }
698 }
699 }
700 _ => {}
701 }
702}
703
704fn is_claude_tool_result(obj: &Value) -> bool {
705 obj.get("toolUseResult").is_some()
706 || obj.get("tool_use_result").is_some()
707 || obj
708 .pointer("/message/content")
709 .and_then(Value::as_array)
710 .is_some_and(|items| {
711 items
712 .iter()
713 .any(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
714 })
715}
716
717fn find_file_arg(value: &Value) -> Option<&str> {
718 match value {
719 Value::Object(obj) => {
720 for key in ["file_path", "path", "filepath"] {
721 if let Some(path) = obj.get(key).and_then(Value::as_str) {
722 return Some(path);
723 }
724 }
725 obj.values().find_map(find_file_arg)
726 }
727 Value::Array(items) => items.iter().find_map(find_file_arg),
728 _ => None,
729 }
730}
731
732fn is_noise_path(path: &str) -> bool {
733 const NOISE: &[&str] = &[
734 "/.claude/",
735 "/.codex/",
736 "/.gemini/",
737 "/.git/",
738 "/node_modules/",
739 "/.npm/",
740 "/.cache/",
741 "CLAUDE.md",
742 "AGENTS.md",
743 ];
744 NOISE.iter().any(|pat| path.contains(pat))
745}
746
747fn clean_prompt_text(text: &str) -> Option<String> {
748 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
749 let text = text
750 .strip_prefix("<session>")
751 .and_then(|text| text.strip_suffix("</session>"))
752 .unwrap_or(&text)
753 .trim();
754 (!text.is_empty()).then(|| text.to_string())
755}
756
757fn short_session_id(id: &str) -> String {
758 let id = id.trim();
759 if id.is_empty() {
760 return "session".to_string();
761 }
762 let compact = id
763 .rsplit(['/', '\\'])
764 .next()
765 .unwrap_or(id)
766 .trim_end_matches(".jsonl");
767 const MAX_SESSION_ID_CHARS: usize = 12;
768 if compact.chars().count() <= MAX_SESSION_ID_CHARS {
769 return compact.to_string();
770 }
771 let head = compact.chars().take(6).collect::<String>();
772 let tail = compact
773 .chars()
774 .rev()
775 .take(5)
776 .collect::<Vec<_>>()
777 .into_iter()
778 .rev()
779 .collect::<String>();
780 format!("{head}.{tail}")
781}
782
783fn json_i64(value: &Value, key: &str) -> i64 {
784 value.get(key).and_then(Value::as_i64).unwrap_or(0)
785}
786
787fn json_u64(value: &Value, key: &str) -> u64 {
788 value.get(key).and_then(Value::as_u64).unwrap_or(0)
789}
790
791fn iso_ms(value: &str) -> Option<u64> {
792 chrono::DateTime::parse_from_rfc3339(value)
793 .ok()
794 .and_then(|ts| u64::try_from(ts.timestamp_millis()).ok())
795}
796
797fn system_time_ms(value: SystemTime) -> u64 {
798 value
799 .duration_since(UNIX_EPOCH)
800 .unwrap_or_default()
801 .as_millis() as u64
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807 use serde_json::json;
808
809 #[test]
810 fn local_session_ids_keep_distinct_conversation_id() {
811 assert_eq!(
812 local_session_ids(&json!({"sessionId": "run", "conversation_id": "conv"})),
813 (Some("run".to_string()), Some("conv".to_string()))
814 );
815 assert_eq!(
816 local_session_ids(&json!({"payload": {"thread_id": "thread"}})),
817 (Some("thread".to_string()), Some("thread".to_string()))
818 );
819 assert_eq!(
820 local_session_ids(&json!({"payload": {"model": "gpt"}})),
821 (None, None)
822 );
823 }
824}