use crate::cowork::peek::{PeekError, PeekMessage, parse_rfc3339};
use serde_json::Value;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
pub fn encode_cwd(cwd: &Path) -> String {
cwd.to_string_lossy().replace('/', "-")
}
pub fn claude_project_dir(home: &Path, cwd: &Path) -> PathBuf {
home.join(".claude/projects").join(encode_cwd(cwd))
}
pub fn latest_session_file(project_dir: &Path) -> Option<(PathBuf, SystemTime)> {
let entries = fs::read_dir(project_dir).ok()?;
entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|x| x == "jsonl").unwrap_or(false))
.filter_map(|e| {
let mtime = e.metadata().ok()?.modified().ok()?;
Some((e.path(), mtime))
})
.max_by_key(|(_, m)| *m)
}
pub fn parse_jsonl_messages(
path: &Path,
since: Option<&str>,
limit: usize,
) -> Result<(Vec<PeekMessage>, bool), PeekError> {
let since_cutoff: Option<i64> = match since {
Some(raw) => Some(parse_rfc3339(raw).ok_or_else(|| {
PeekError::Parse(format!("invalid `since` RFC3339 timestamp: {raw}"))
})?),
None => None,
};
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
let mut all: Vec<PeekMessage> = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let val: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(msg) = extract_message(&val) {
if let Some(cutoff) = since_cutoff {
if let Some(msg_ts) = parse_rfc3339(&msg.at) {
if msg_ts <= cutoff {
continue;
}
}
}
all.push(msg);
}
}
let total = all.len();
let truncated = total > limit;
let start = total.saturating_sub(limit);
let tail = all.split_off(start);
Ok((tail, truncated))
}
fn extract_message(val: &Value) -> Option<PeekMessage> {
let top_type = val.get("type").and_then(|v| v.as_str()).unwrap_or("");
if top_type != "user" && top_type != "assistant" {
return None;
}
if val.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false) {
return None;
}
let message = val.get("message")?;
let role = message.get("role").and_then(|v| v.as_str())?;
if role != "user" && role != "assistant" {
return None;
}
let content = message.get("content")?;
let text = match content {
Value::String(s) => s.trim().to_string(),
Value::Array(blocks) => {
let parts: Vec<String> = blocks
.iter()
.filter_map(|b| {
let block_type = b.get("type").and_then(|v| v.as_str())?;
if block_type == "text" {
b.get("text")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
}
})
.collect();
parts.join("\n")
}
_ => return None,
};
if text.is_empty() {
return None;
}
let at = val
.get("timestamp")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(PeekMessage {
role: role.to_string(),
at,
text,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn reads_plain_text_and_structured_content() {
let fixture = Path::new("tests/fixtures/cowork/claude/session.jsonl");
let (messages, truncated) = parse_jsonl_messages(fixture, None, 30).expect("parse");
assert_eq!(messages.len(), 4);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[0].text, "Hello from user turn 1");
assert_eq!(messages[1].role, "assistant");
assert_eq!(messages[1].text, "Hello from assistant turn 1");
assert_eq!(messages[3].text, "Second assistant reply");
assert!(messages[0].at <= messages[3].at);
assert!(!truncated);
}
#[test]
fn filters_tool_use_blocks_and_is_meta_entries() {
let fixture = Path::new("tests/fixtures/cowork/claude/session_with_tools.jsonl");
let (messages, _) = parse_jsonl_messages(fixture, None, 30).expect("parse");
assert_eq!(messages.len(), 4);
for m in &messages {
assert!(m.role == "user" || m.role == "assistant");
assert!(!m.text.is_empty());
assert!(!m.text.contains("tool_use"));
assert!(!m.text.contains("tool_result"));
}
assert_eq!(messages[0].text, "User turn with tool");
assert_eq!(messages[1].text, "Let me check");
assert_eq!(messages[2].text, "Here is the listing");
assert_eq!(messages[3].text, "Follow-up question");
}
#[test]
fn honors_limit_by_taking_tail_and_sets_truncated() {
let fixture = Path::new("tests/fixtures/cowork/claude/session.jsonl");
let (messages, truncated) = parse_jsonl_messages(fixture, None, 2).expect("parse");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].text, "Second user message");
assert_eq!(messages[1].text, "Second assistant reply");
assert!(truncated);
}
#[test]
fn since_filter_compares_instants_not_strings() {
let fixture = Path::new("tests/fixtures/cowork/claude_since/session.jsonl");
let since = Some("2026-04-13T10:00:00+08:00");
let (messages, _) = parse_jsonl_messages(fixture, since, 30).expect("parse");
assert_eq!(
messages.len(),
2,
"expected 2 messages strictly newer than the +08:00 cutoff \
(UTC 02:00), got {}: {:?}",
messages.len(),
messages.iter().map(|m| &m.text).collect::<Vec<_>>()
);
assert!(messages.iter().any(|m| m.text.contains("02:30")));
assert!(messages.iter().any(|m| m.text.contains("05:00")));
assert!(!messages.iter().any(|m| m.text.contains("01:00")));
}
#[test]
fn encoded_cwd_replaces_slashes_with_dashes() {
assert_eq!(encode_cwd(Path::new("/Users/foo/bar")), "-Users-foo-bar");
assert_eq!(encode_cwd(Path::new("/a")), "-a");
}
}