use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use super::super::AgentInfo;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimestampWindow {
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
}
impl TimestampWindow {
pub fn new(start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
Self { start, end }
}
pub fn contains(&self, ts: DateTime<Utc>) -> bool {
ts >= self.start && ts <= self.end
}
pub fn contains_systime(&self, st: SystemTime) -> bool {
let dt: DateTime<Utc> = st.into();
self.contains(dt)
}
}
pub(super) fn derive_session_window(session_path: &Path) -> Result<TimestampWindow> {
let metadata = fs::metadata(session_path)
.with_context(|| format!("session metadata: {}", session_path.display()))?;
let mut first: Option<DateTime<Utc>> = None;
let mut last: Option<DateTime<Utc>> = None;
if let Ok(content) = fs::read_to_string(session_path) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let value: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => continue,
};
let ts = match value.get("timestamp") {
Some(serde_json::Value::String(s)) => s.parse::<DateTime<Utc>>().ok(),
Some(serde_json::Value::Number(n)) => n.as_i64().and_then(|secs| {
let (s, ns) = if secs > 1_000_000_000_000 {
(secs / 1000, ((secs % 1000) * 1_000_000) as u32)
} else {
(secs, 0u32)
};
DateTime::<Utc>::from_timestamp(s, ns)
}),
_ => None,
};
if let Some(ts) = ts {
if first.is_none() {
first = Some(ts);
}
last = Some(ts);
}
}
}
if let (Some(s), Some(e)) = (first, last) {
let (start, end) = if s <= e { (s, e) } else { (e, s) };
return Ok(TimestampWindow::new(start, end));
}
let mtime: DateTime<Utc> = metadata.modified()?.into();
let created: DateTime<Utc> = metadata.created().map(|c| c.into()).unwrap_or(mtime);
let (start, end) = if created <= mtime {
(created, mtime)
} else {
(mtime, created)
};
eprintln!(
"warning: no parseable timestamps in {} — falling back to file metadata for session window",
session_path.display()
);
Ok(TimestampWindow::new(start, end))
}
pub(super) fn find_agent_sessions(session_path: &Path) -> Result<Vec<AgentInfo>> {
let parent_dir = session_path
.parent()
.context("Session file has no parent directory")?;
let session_stem = session_path
.file_stem()
.context("Session file has no stem")?;
let subagents_dir = parent_dir.join(session_stem).join("subagents");
let mut agents = Vec::new();
if subagents_dir.exists() {
for entry in fs::read_dir(&subagents_dir)? {
let entry = entry?;
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& name.starts_with("agent-")
&& path.extension().and_then(|e| e.to_str()) == Some("jsonl")
{
let content = fs::read_to_string(&path)?;
let messages = content.lines().filter(|l| !l.trim().is_empty()).count();
agents.push(AgentInfo {
id: path.to_string_lossy().to_string(),
file: format!("agents/{}", name),
messages,
});
}
}
}
Ok(agents)
}
pub(super) fn find_mcp_logs(cwd_encoded: &str, window: TimestampWindow) -> Result<Vec<PathBuf>> {
let root = crate::paths::claude_mcp_logs_dir(cwd_encoded);
if !root.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in fs::read_dir(&root)? {
let entry = entry?;
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let name = match dir.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if !name.starts_with("mcp-logs-") {
continue;
}
for inner in fs::read_dir(&dir)? {
let inner = inner?;
let p = inner.path();
if p.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let meta = match inner.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let modified = match meta.modified() {
Ok(t) => t,
Err(_) => continue,
};
if window.contains_systime(modified) {
out.push(p);
}
}
}
Ok(out)
}
pub(super) fn find_tool_outputs(
uid: u32,
user_slug: &str,
session_uuid: &str,
) -> Result<Vec<PathBuf>> {
let tasks_dir = crate::paths::tmp_claude_tasks_dir(uid, user_slug, session_uuid);
if !tasks_dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in fs::read_dir(&tasks_dir)? {
let entry = entry?;
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) == Some("output") && p.is_file() {
out.push(p);
}
}
Ok(out)
}
pub(super) fn find_history_slice(window: TimestampWindow) -> Result<Vec<String>> {
let path = crate::paths::claude_history_jsonl();
if !path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&path)?;
Ok(filter_history_lines(&content, &window))
}
pub(super) fn filter_history_lines(content: &str, window: &TimestampWindow) -> Vec<String> {
let mut out = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let value: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => continue,
};
let ts = match value.get("timestamp") {
Some(serde_json::Value::String(s)) => s.parse::<DateTime<Utc>>().ok(),
Some(serde_json::Value::Number(n)) => n.as_i64().and_then(|secs| {
let (s, ns) = if secs > 1_000_000_000_000 {
(secs / 1000, ((secs % 1000) * 1_000_000) as u32)
} else {
(secs, 0u32)
};
DateTime::<Utc>::from_timestamp(s, ns)
}),
_ => None,
};
if let Some(ts) = ts
&& window.contains(ts)
{
out.push(line.to_string());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn make_window(start_offset_secs: i64, end_offset_secs: i64) -> TimestampWindow {
let now = Utc::now();
TimestampWindow::new(
now + Duration::seconds(start_offset_secs),
now + Duration::seconds(end_offset_secs),
)
}
#[test]
fn derive_session_window_uses_jsonl_first_and_last_timestamps() {
let tmp = tempfile::tempdir().unwrap();
let session_path = tmp.path().join("c3744b8d.jsonl");
let body = concat!(
r#"{"role":"user","timestamp":"2026-04-29T10:00:00Z"}"#,
"\n",
r#"{"role":"assistant","timestamp":"2026-04-29T10:05:00Z"}"#,
"\n",
r#"{"role":"user","timestamp":"2026-04-29T10:42:30Z"}"#,
"\n",
);
fs::write(&session_path, body).unwrap();
let w = derive_session_window(&session_path).unwrap();
let expected_start: DateTime<Utc> = "2026-04-29T10:00:00Z".parse().unwrap();
let expected_end: DateTime<Utc> = "2026-04-29T10:42:30Z".parse().unwrap();
assert_eq!(w.start, expected_start);
assert_eq!(w.end, expected_end);
}
#[test]
fn derive_session_window_skips_lines_without_parseable_timestamps() {
let tmp = tempfile::tempdir().unwrap();
let session_path = tmp.path().join("aa.jsonl");
let body = concat!(
"not json\n",
r#"{"no":"timestamp"}"#,
"\n",
r#"{"role":"user","timestamp":"2026-04-29T11:00:00Z"}"#,
"\n",
r#"{"role":"assistant","timestamp":"2026-04-29T11:30:00Z"}"#,
"\n",
);
fs::write(&session_path, body).unwrap();
let w = derive_session_window(&session_path).unwrap();
let expected_start: DateTime<Utc> = "2026-04-29T11:00:00Z".parse().unwrap();
let expected_end: DateTime<Utc> = "2026-04-29T11:30:00Z".parse().unwrap();
assert_eq!(w.start, expected_start);
assert_eq!(w.end, expected_end);
}
#[test]
fn derive_session_window_falls_back_to_metadata_when_empty() {
let tmp = tempfile::tempdir().unwrap();
let session_path = tmp.path().join("empty.jsonl");
fs::write(&session_path, "").unwrap();
let w = derive_session_window(&session_path).unwrap();
let now = Utc::now();
assert!(w.start <= w.end, "fallback window must be ordered");
let drift = (now - w.end).num_seconds().abs();
assert!(drift < 60, "fallback end should be close to mtime/now");
}
#[test]
fn derive_session_window_falls_back_when_no_parseable_timestamps() {
let tmp = tempfile::tempdir().unwrap();
let session_path = tmp.path().join("garbage.jsonl");
fs::write(&session_path, "not json\n{\"no\":1}\n").unwrap();
let w = derive_session_window(&session_path).unwrap();
assert!(w.start <= w.end);
}
#[test]
fn timestamp_window_contains_inclusive() {
let w = make_window(-60, 60);
assert!(w.contains(w.start));
assert!(w.contains(w.end));
assert!(w.contains(Utc::now()));
assert!(!w.contains(w.end + Duration::seconds(1)));
assert!(!w.contains(w.start - Duration::seconds(1)));
}
#[test]
fn find_mcp_logs_returns_empty_for_missing_root() {
let cwd = "-no-such-cwd-encoding-xxxxxxx";
let w = make_window(-3600, 3600);
let logs = find_mcp_logs(cwd, w).unwrap();
assert!(logs.is_empty());
}
#[test]
fn find_tool_outputs_returns_empty_for_missing_dir() {
let uuid = "00000000-aaaa-bbbb-cccc-111111111111";
let outs = find_tool_outputs(99999, "-no-such-user", uuid).unwrap();
assert!(outs.is_empty());
}
#[test]
fn find_history_slice_handles_missing_file_gracefully() {
let w = make_window(-1_000_000_000, 1_000_000_000);
let result = find_history_slice(w);
assert!(result.is_ok());
}
#[test]
fn filter_history_lines_keeps_only_in_window() {
let content = concat!(
r#"{"timestamp": "2026-04-29T12:00:00Z", "msg": "in"}"#,
"\n",
r#"{"timestamp": "2026-04-29T08:00:00Z", "msg": "before"}"#,
"\n",
r#"{"timestamp": "2026-04-29T18:00:00Z", "msg": "after"}"#,
"\n",
r#"{"no-timestamp-field": true}"#,
"\n",
"not even json\n",
"\n", );
let start: DateTime<Utc> = "2026-04-29T10:00:00Z".parse().unwrap();
let end: DateTime<Utc> = "2026-04-29T14:00:00Z".parse().unwrap();
let w = TimestampWindow::new(start, end);
let kept = filter_history_lines(content, &w);
assert_eq!(kept.len(), 1, "expected exactly one in-window line");
assert!(kept[0].contains("\"in\""));
}
#[test]
fn filter_history_lines_accepts_epoch_seconds_and_millis() {
let secs: i64 = 1_777_809_600; let millis: i64 = secs * 1_000 + 250;
let content = format!(
"{{\"timestamp\": {secs}, \"msg\": \"sec\"}}\n{{\"timestamp\": {millis}, \"msg\": \"ms\"}}\n",
);
let start = DateTime::<Utc>::from_timestamp(secs - 60, 0).unwrap();
let end = DateTime::<Utc>::from_timestamp(secs + 60, 0).unwrap();
let w = TimestampWindow::new(start, end);
let kept = filter_history_lines(&content, &w);
assert_eq!(kept.len(), 2, "both seconds and millis variants must match");
}
}