use crate::models::session::SessionLine;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskEvent {
pub session_id: String,
pub timestamp: DateTime<Utc>,
pub event_type: TaskEventType,
pub task_id: String,
pub subject: Option<String>,
pub description: Option<String>,
pub status: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TaskEventType {
Created,
Updated,
Completed,
}
#[derive(Debug, Clone, Default)]
pub struct SessionTaskMapping {
pub session_tasks: HashMap<String, Vec<TaskEvent>>,
pub task_timeline: HashMap<String, Vec<TaskEvent>>,
}
pub struct TodoWriteParser;
impl TodoWriteParser {
pub fn parse_session(session_path: &Path) -> Result<Vec<TaskEvent>> {
let file = File::open(session_path)
.context(format!("Failed to open session file: {:?}", session_path))?;
let reader = BufReader::new(file);
let mut events = Vec::new();
for line in reader.lines() {
let line = line.context("Failed to read line")?;
if line.trim().is_empty() {
continue;
}
let session_line: SessionLine = match serde_json::from_str(&line) {
Ok(l) => l,
Err(_) => continue, };
if let Some(line_events) = Self::extract_task_events(&session_line) {
events.extend(line_events);
}
}
Ok(events)
}
fn extract_task_events(line: &SessionLine) -> Option<Vec<TaskEvent>> {
let message = line.message.as_ref()?;
let tool_calls = message.tool_calls.as_ref()?;
let mut events = Vec::new();
for tool_call in tool_calls {
if let Some(tool_name) = tool_call.get("name").and_then(|v| v.as_str()) {
match tool_name {
"TaskCreate" | "TodoWrite" => {
if let Some(event) = Self::parse_task_create(line, tool_call) {
events.push(event);
}
}
"TaskUpdate" => {
if let Some(event) = Self::parse_task_update(line, tool_call) {
events.push(event);
}
}
_ => {}
}
}
}
if events.is_empty() {
None
} else {
Some(events)
}
}
fn parse_task_create(line: &SessionLine, tool_call: &serde_json::Value) -> Option<TaskEvent> {
let input = tool_call.get("input")?;
let session_id = line.session_id.clone().unwrap_or_default();
let timestamp = line.timestamp.unwrap_or_else(Utc::now);
let subject = input
.get("subject")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = input
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let task_id = input
.get("taskId")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("task_{}", timestamp.timestamp()));
Some(TaskEvent {
session_id,
timestamp,
event_type: TaskEventType::Created,
task_id,
subject,
description,
status: Some("pending".to_string()),
})
}
fn parse_task_update(line: &SessionLine, tool_call: &serde_json::Value) -> Option<TaskEvent> {
let input = tool_call.get("input")?;
let session_id = line.session_id.clone().unwrap_or_default();
let timestamp = line.timestamp.unwrap_or_else(Utc::now);
let task_id = input
.get("taskId")
.and_then(|v| v.as_str())
.map(|s| s.to_string())?;
let status = input
.get("status")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let subject = input
.get("subject")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = input
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let event_type = match status.as_deref() {
Some("completed") => TaskEventType::Completed,
_ => TaskEventType::Updated,
};
Some(TaskEvent {
session_id,
timestamp,
event_type,
task_id,
subject,
description,
status,
})
}
pub fn build_mapping(events: Vec<TaskEvent>) -> SessionTaskMapping {
let mut mapping = SessionTaskMapping::default();
for event in events {
mapping
.session_tasks
.entry(event.session_id.clone())
.or_default()
.push(event.clone());
mapping
.task_timeline
.entry(event.task_id.clone())
.or_default()
.push(event);
}
for timeline in mapping.task_timeline.values_mut() {
timeline.sort_by_key(|e| e.timestamp);
}
mapping
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_parse_task_create() {
let tool_call = json!({
"name": "TaskCreate",
"input": {
"taskId": "task-123",
"subject": "Implement feature X",
"description": "Add new functionality"
}
});
let line = SessionLine {
session_id: Some("session-1".to_string()),
line_type: "assistant".to_string(),
timestamp: Some(Utc::now()),
cwd: None,
git_branch: None,
message: Some(crate::models::session::SessionMessage {
role: Some("assistant".to_string()),
content: None,
tool_calls: Some(vec![tool_call]),
tool_results: None,
usage: None,
}),
model: None,
usage: None,
summary: None,
parent_session_id: None,
};
let events = TodoWriteParser::extract_task_events(&line);
assert!(events.is_some());
let events = events.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].task_id, "task-123");
assert_eq!(events[0].subject, Some("Implement feature X".to_string()));
assert_eq!(events[0].event_type, TaskEventType::Created);
}
#[test]
fn test_parse_task_update() {
let tool_call = json!({
"name": "TaskUpdate",
"input": {
"taskId": "task-123",
"status": "completed"
}
});
let line = SessionLine {
session_id: Some("session-1".to_string()),
line_type: "assistant".to_string(),
timestamp: Some(Utc::now()),
cwd: None,
git_branch: None,
message: Some(crate::models::session::SessionMessage {
role: Some("assistant".to_string()),
content: None,
tool_calls: Some(vec![tool_call]),
tool_results: None,
usage: None,
}),
model: None,
usage: None,
summary: None,
parent_session_id: None,
};
let events = TodoWriteParser::extract_task_events(&line);
assert!(events.is_some());
let events = events.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].task_id, "task-123");
assert_eq!(events[0].event_type, TaskEventType::Completed);
}
#[test]
fn test_build_mapping() {
let events = vec![
TaskEvent {
session_id: "session-1".to_string(),
timestamp: Utc::now(),
event_type: TaskEventType::Created,
task_id: "task-1".to_string(),
subject: Some("Task 1".to_string()),
description: None,
status: Some("pending".to_string()),
},
TaskEvent {
session_id: "session-1".to_string(),
timestamp: Utc::now(),
event_type: TaskEventType::Completed,
task_id: "task-1".to_string(),
subject: None,
description: None,
status: Some("completed".to_string()),
},
];
let mapping = TodoWriteParser::build_mapping(events);
assert_eq!(mapping.session_tasks.len(), 1);
assert_eq!(mapping.session_tasks.get("session-1").unwrap().len(), 2);
assert_eq!(mapping.task_timeline.len(), 1);
assert_eq!(mapping.task_timeline.get("task-1").unwrap().len(), 2);
}
}