use anyhow::{Context, Result};
use rayon::prelude::*;
use serde::Deserialize;
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::filter::DateFilter;
use crate::output::{DateRange, MessageCounts, PromptSummary, SessionSummary};
#[derive(Deserialize)]
#[serde(tag = "type")]
enum JournalEntry {
#[serde(rename = "user")]
User(UserEntry),
#[serde(rename = "assistant")]
Assistant(AssistantEntry),
#[serde(other)]
Other,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UserEntry {
timestamp: Option<String>,
session_id: Option<String>,
cwd: Option<String>,
git_branch: Option<String>,
#[serde(default)]
is_sidechain: Option<bool>,
message: Option<UserMessage>,
}
#[derive(Deserialize)]
struct UserMessage {
content: MessageContent,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum MessageContent {
Text(String),
Blocks(Vec<ContentBlock>),
}
#[derive(Deserialize)]
#[serde(tag = "type")]
enum ContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "tool_use")]
ToolUse {
name: Option<String>,
input: Option<serde_json::Value>,
},
#[serde(other)]
Unknown,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AssistantEntry {
timestamp: Option<String>,
session_id: Option<String>,
cwd: Option<String>,
git_branch: Option<String>,
message: Option<AssistantMessage>,
}
#[derive(Deserialize)]
struct AssistantMessage {
content: Option<Vec<ContentBlock>>,
usage: Option<TokenUsage>,
}
#[derive(Deserialize)]
struct TokenUsage {
input_tokens: Option<u64>,
output_tokens: Option<u64>,
}
#[derive(Deserialize)]
struct EntryHeader {
#[serde(rename = "type")]
entry_type: Option<String>,
timestamp: Option<String>,
}
pub struct RawSession {
pub session_id: String,
pub project: String,
pub project_path: String,
pub git_branch: Option<String>,
pub user_entries: Vec<ParsedUserEntry>,
pub assistant_entries: Vec<ParsedAssistantEntry>,
}
pub struct ParsedUserEntry {
pub timestamp: String,
pub text: String,
}
pub struct ParsedAssistantEntry {
pub timestamp: String,
pub tool_uses: Vec<String>,
pub input_tokens: u64,
pub output_tokens: u64,
pub file_paths: Vec<String>,
}
pub struct SessionFile {
pub path: PathBuf,
pub mtime: SystemTime,
}
pub fn discover_session_files(claude_dir: &Path) -> Result<Vec<SessionFile>> {
let projects_dir = claude_dir.join("projects");
if !projects_dir.exists() {
anyhow::bail!(
"Claude Code のセッションデータが見つかりません: {}\n\n\
Claude Code(CLI または VS Code 拡張)を使用すると、\n\
セッションデータが自動的にこのディレクトリに保存されます。\n\
カスタムディレクトリを指定する場合は --claude-dir オプションを使用してください。",
projects_dir.display()
);
}
let pattern = format!("{}/**/*.jsonl", projects_dir.display());
let mut files = Vec::new();
for entry in glob::glob(&pattern).context("Failed to read glob pattern")? {
let path = match entry {
Ok(p) => p,
Err(_) => continue,
};
let metadata = match fs::metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
if !metadata.is_file() {
continue;
}
let mtime = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
files.push(SessionFile { path, mtime });
}
Ok(files)
}
const MAX_PROMPT_LEN: usize = 500;
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let truncated: String = s.chars().take(max).collect();
format!("{truncated}...")
}
}
fn extract_user_text(content: &MessageContent) -> Option<String> {
match content {
MessageContent::Text(s) => {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(truncate(trimmed, MAX_PROMPT_LEN))
}
}
MessageContent::Blocks(blocks) => {
let texts: Vec<&str> = blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect();
if texts.is_empty() {
None
} else {
Some(truncate(&texts.join("\n"), MAX_PROMPT_LEN))
}
}
}
}
fn extract_tool_info(blocks: &[ContentBlock]) -> (Vec<String>, Vec<String>) {
let mut tool_names = Vec::new();
let mut file_paths = Vec::new();
for block in blocks {
if let ContentBlock::ToolUse {
name: Some(n),
input,
} = block
{
tool_names.push(n.clone());
if let Some(input_val) = input
&& matches!(n.as_str(), "Read" | "Write" | "Edit" | "Glob" | "Grep")
{
if let Some(fp) = input_val.get("file_path").and_then(|v| v.as_str()) {
file_paths.push(fp.to_string());
}
if let Some(fp) = input_val.get("path").and_then(|v| v.as_str()) {
file_paths.push(fp.to_string());
}
}
}
}
(tool_names, file_paths)
}
fn extract_project_from_cwd(cwd: &str) -> String {
Path::new(cwd)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| cwd.to_string())
}
pub fn parse_session_file(path: &Path, filter: &DateFilter) -> Result<Option<RawSession>> {
let file = File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
let reader = BufReader::new(file);
let mut user_entries = Vec::new();
let mut assistant_entries = Vec::new();
let mut session_id = String::new();
let mut project = String::new();
let mut project_path = String::new();
let mut git_branch: Option<String> = None;
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
if line.trim().is_empty() {
continue;
}
let header: EntryHeader = match serde_json::from_str(&line) {
Ok(h) => h,
Err(_) => continue,
};
let entry_type = match &header.entry_type {
Some(t) => t.as_str(),
None => continue,
};
if !matches!(entry_type, "user" | "assistant") {
continue;
}
if let Some(ts) = &header.timestamp {
if !filter.matches(ts) {
continue;
}
} else {
continue;
}
let entry: JournalEntry = match serde_json::from_str(&line) {
Ok(e) => e,
Err(_) => continue,
};
match entry {
JournalEntry::User(user) => {
if user.is_sidechain.unwrap_or(false) {
continue;
}
if session_id.is_empty()
&& let Some(sid) = &user.session_id
{
session_id = sid.clone();
}
if project.is_empty()
&& let Some(cwd) = &user.cwd
{
project = extract_project_from_cwd(cwd);
project_path = cwd.clone();
}
if git_branch.is_none() {
git_branch = user.git_branch.clone();
}
if let Some(msg) = &user.message
&& let Some(text) = extract_user_text(&msg.content)
{
user_entries.push(ParsedUserEntry {
timestamp: user.timestamp.unwrap_or_default(),
text,
});
}
}
JournalEntry::Assistant(assistant) => {
if session_id.is_empty()
&& let Some(sid) = &assistant.session_id
{
session_id = sid.clone();
}
if project.is_empty()
&& let Some(cwd) = &assistant.cwd
{
project = extract_project_from_cwd(cwd);
project_path = cwd.clone();
}
if git_branch.is_none() {
git_branch = assistant.git_branch.clone();
}
if let Some(msg) = &assistant.message {
let blocks = msg.content.as_deref().unwrap_or(&[]);
let (tool_uses, file_paths) = extract_tool_info(blocks);
let (input_tokens, output_tokens) = msg
.usage
.as_ref()
.map(|u| (u.input_tokens.unwrap_or(0), u.output_tokens.unwrap_or(0)))
.unwrap_or((0, 0));
assistant_entries.push(ParsedAssistantEntry {
timestamp: assistant.timestamp.unwrap_or_default(),
tool_uses,
input_tokens,
output_tokens,
file_paths,
});
}
}
JournalEntry::Other => {}
}
}
if user_entries.is_empty() && assistant_entries.is_empty() {
return Ok(None);
}
if session_id.is_empty() {
session_id = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
}
Ok(Some(RawSession {
session_id,
project,
project_path,
git_branch,
user_entries,
assistant_entries,
}))
}
pub fn collect_sessions(claude_dir: &Path, filter: &DateFilter) -> Result<Vec<RawSession>> {
let files = discover_session_files(claude_dir)?;
let cutoff = filter.mtime_cutoff();
let candidates: Vec<&SessionFile> = files
.iter()
.filter(|f| cutoff.map(|c| f.mtime >= c).unwrap_or(true))
.collect();
let sessions: Vec<RawSession> = candidates
.par_iter()
.filter_map(|sf| parse_session_file(&sf.path, filter).ok().flatten())
.collect();
Ok(sessions)
}
pub fn summarize_session(session: &RawSession) -> SessionSummary {
let mut tool_usage: HashMap<String, u32> = HashMap::new();
let mut total_input_tokens: u64 = 0;
let mut total_output_tokens: u64 = 0;
let mut all_file_paths: Vec<String> = Vec::new();
for entry in &session.assistant_entries {
for tool in &entry.tool_uses {
*tool_usage.entry(tool.clone()).or_insert(0) += 1;
}
total_input_tokens += entry.input_tokens;
total_output_tokens += entry.output_tokens;
all_file_paths.extend(entry.file_paths.iter().cloned());
}
all_file_paths.sort();
all_file_paths.dedup();
let user_prompts: Vec<PromptSummary> = session
.user_entries
.iter()
.map(|e| PromptSummary {
text: e.text.clone(),
timestamp: e.timestamp.clone(),
})
.collect();
let mut timestamps: Vec<&str> = Vec::new();
for e in &session.user_entries {
timestamps.push(&e.timestamp);
}
for e in &session.assistant_entries {
timestamps.push(&e.timestamp);
}
timestamps.sort();
let time_range = DateRange {
start: timestamps.first().map(|s| s.to_string()),
end: timestamps.last().map(|s| s.to_string()),
};
SessionSummary {
session_id: session.session_id.clone(),
project: session.project.clone(),
project_path: session.project_path.clone(),
git_branch: session.git_branch.clone(),
time_range,
user_prompts,
tool_usage,
message_counts: MessageCounts {
user: session.user_entries.len(),
assistant: session.assistant_entries.len(),
},
total_input_tokens,
total_output_tokens,
files_touched: all_file_paths,
}
}