use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct SubagentMeta {
pub tool_use_id: String,
pub agent_type: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TranscriptFile {
pub path: PathBuf,
pub agent_id: Option<String>,
pub meta: SubagentMeta,
}
impl TranscriptFile {
#[must_use]
pub fn label(&self, sid: &str) -> String {
match &self.agent_id {
None => format!("{sid}/main"),
Some(id) => format!("{sid}/agent-{id}"),
}
}
}
#[must_use]
pub fn session_files(projects_dir: &Path, sid: &str) -> Vec<TranscriptFile> {
let mut out = Vec::new();
let main = projects_dir.join(format!("{sid}.jsonl"));
if !main.is_file() {
return out;
}
out.push(TranscriptFile {
path: main,
agent_id: None,
meta: SubagentMeta::default(),
});
let sub_dir = projects_dir.join(sid).join("subagents");
let Ok(entries) = std::fs::read_dir(&sub_dir) else {
return out; };
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
let Some(agent_id) = name
.strip_prefix("agent-")
.and_then(|rest| rest.strip_suffix(".jsonl"))
else {
continue;
};
let meta_path = sub_dir.join(format!("agent-{agent_id}.meta.json"));
let meta = std::fs::read(&meta_path)
.ok()
.and_then(|raw| serde_json::from_slice::<SubagentMeta>(&raw).ok())
.unwrap_or_default();
out.push(TranscriptFile {
path: entry.path(),
agent_id: Some(agent_id.to_owned()),
meta,
});
}
out.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
out
}
#[must_use]
pub fn jsonl_to_records(raw: &[u8]) -> String {
let mut records = String::from("[");
let mut first = true;
for line in raw.split(|&b| b == b'\n') {
let line = trim_ascii(line);
if line.is_empty() || !is_valid_json(line) {
continue;
}
if !first {
records.push(',');
}
first = false;
records.push_str(&String::from_utf8_lossy(line));
}
records.push(']');
records
}
fn is_valid_json(bytes: &[u8]) -> bool {
serde_json::from_slice::<serde::de::IgnoredAny>(bytes).is_ok()
}
fn trim_ascii(mut bytes: &[u8]) -> &[u8] {
while let [first, rest @ ..] = bytes {
if first.is_ascii_whitespace() {
bytes = rest;
} else {
break;
}
}
while let [rest @ .., last] = bytes {
if last.is_ascii_whitespace() {
bytes = rest;
} else {
break;
}
}
bytes
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileFingerprint {
pub len: u64,
pub mtime: std::time::SystemTime,
}
#[must_use]
pub fn fingerprint(path: &Path) -> Option<FileFingerprint> {
let meta = std::fs::metadata(path).ok()?;
Some(FileFingerprint {
len: meta.len(),
mtime: meta.modified().ok()?,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn jsonl_to_records_keeps_valid_lines_verbatim() {
let raw = b"{\"b\":1,\"a\":2}\n{\"x\": \"y\"}\n";
assert_eq!(jsonl_to_records(raw), r#"[{"b":1,"a":2},{"x": "y"}]"#);
}
#[test]
fn jsonl_to_records_skips_blank_and_malformed_lines() {
let raw = b"{\"ok\":1}\n\n \n{\"trunc\":tr\n{\"ok\":2}";
assert_eq!(jsonl_to_records(raw), r#"[{"ok":1},{"ok":2}]"#);
}
#[test]
fn jsonl_to_records_empty_input_is_empty_array() {
assert_eq!(jsonl_to_records(b""), "[]");
assert_eq!(jsonl_to_records(b"\n\n"), "[]");
}
#[test]
fn session_files_missing_main_returns_empty() {
let dir = tempfile::tempdir().unwrap();
assert!(session_files(dir.path(), "ghost").is_empty());
}
#[test]
fn session_files_main_only() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("sid-1.jsonl"), "{}\n").unwrap();
let files = session_files(dir.path(), "sid-1");
assert_eq!(files.len(), 1);
assert_eq!(files[0].agent_id, None);
assert_eq!(files[0].label("sid-1"), "sid-1/main");
}
#[test]
fn session_files_discovers_subagents_with_meta() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("sid-1.jsonl"), "{}\n").unwrap();
let sub = dir.path().join("sid-1").join("subagents");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("agent-abc.jsonl"), "{}\n").unwrap();
std::fs::write(
sub.join("agent-abc.meta.json"),
r#"{"toolUseId":"toolu_01","agentType":"general-purpose","description":"dig"}"#,
)
.unwrap();
std::fs::write(sub.join("agent-zzz.jsonl"), "{}\n").unwrap();
std::fs::write(sub.join("agent-abc.meta.json.bak"), "junk").unwrap();
std::fs::write(sub.join("notes.txt"), "junk").unwrap();
let files = session_files(dir.path(), "sid-1");
assert_eq!(files.len(), 3);
assert_eq!(files[0].agent_id, None, "main sorts first");
assert_eq!(files[1].agent_id.as_deref(), Some("abc"));
assert_eq!(files[1].meta.tool_use_id, "toolu_01");
assert_eq!(files[1].meta.agent_type, "general-purpose");
assert_eq!(files[1].meta.description, "dig");
assert_eq!(files[1].label("sid-1"), "sid-1/agent-abc");
assert_eq!(files[2].agent_id.as_deref(), Some("zzz"));
assert_eq!(files[2].meta, SubagentMeta::default());
}
#[test]
fn fingerprint_tracks_len() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("f.jsonl");
std::fs::write(&p, "{}\n").unwrap();
let a = fingerprint(&p).unwrap();
assert_eq!(a.len, 3);
std::fs::write(&p, "{}\n{}\n").unwrap();
let b = fingerprint(&p).unwrap();
assert_ne!(a, b);
assert!(fingerprint(&dir.path().join("missing")).is_none());
}
}