toolpath-claude 0.6.2

Derive Toolpath provenance documents from Claude conversation logs
Documentation
use crate::error::Result;
use crate::paths::PathResolver;
use crate::reader::ConversationReader;
use crate::types::{Conversation, ConversationMetadata, HistoryEntry};
use std::path::PathBuf;

#[derive(Debug, Clone)]
pub struct ConvoIO {
    resolver: PathResolver,
}

impl ConvoIO {
    pub fn new() -> Self {
        Self {
            resolver: PathResolver::new(),
        }
    }

    pub fn with_resolver(resolver: PathResolver) -> Self {
        Self { resolver }
    }

    pub fn resolver(&self) -> &PathResolver {
        &self.resolver
    }

    pub fn read_conversation(&self, project_path: &str, session_id: &str) -> Result<Conversation> {
        let path = self.resolver.conversation_file(project_path, session_id)?;
        ConversationReader::read_conversation(&path)
    }

    pub fn read_conversation_metadata(
        &self,
        project_path: &str,
        session_id: &str,
    ) -> Result<ConversationMetadata> {
        let path = self.resolver.conversation_file(project_path, session_id)?;
        ConversationReader::read_conversation_metadata(&path)
    }

    pub fn list_conversations(&self, project_path: &str) -> Result<Vec<String>> {
        self.resolver.list_conversations(project_path)
    }

    pub fn list_conversation_metadata(
        &self,
        project_path: &str,
    ) -> Result<Vec<ConversationMetadata>> {
        let sessions = self.list_conversations(project_path)?;
        let mut metadata = Vec::new();

        for session_id in sessions {
            match self.read_conversation_metadata(project_path, &session_id) {
                Ok(meta) => metadata.push(meta),
                Err(e) => {
                    eprintln!("Warning: Failed to read metadata for {}: {}", session_id, e);
                }
            }
        }

        metadata.sort_by(|a, b| b.last_activity.cmp(&a.last_activity));
        Ok(metadata)
    }

    pub fn list_projects(&self) -> Result<Vec<String>> {
        self.resolver.list_project_dirs()
    }

    pub fn read_history(&self) -> Result<Vec<HistoryEntry>> {
        let path = self.resolver.history_file()?;
        ConversationReader::read_history(&path)
    }

    pub fn exists(&self) -> bool {
        self.resolver.exists()
    }

    pub fn claude_dir_path(&self) -> Result<PathBuf> {
        self.resolver.claude_dir()
    }

    pub fn conversation_exists(&self, project_path: &str, session_id: &str) -> Result<bool> {
        let path = self.resolver.conversation_file(project_path, session_id)?;
        Ok(path.exists())
    }

    pub fn project_exists(&self, project_path: &str) -> bool {
        self.resolver
            .project_dir(project_path)
            .map(|p| p.exists())
            .unwrap_or(false)
    }
}

impl Default for ConvoIO {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn setup_io() -> (TempDir, ConvoIO) {
        let temp = TempDir::new().unwrap();
        let claude_dir = temp.path().join(".claude");
        let project_dir = claude_dir.join("projects/-test-project");
        fs::create_dir_all(&project_dir).unwrap();

        let entry1 = r#"{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","cwd":"/test/project","message":{"role":"user","content":"Hello"}}"#;
        let entry2 = r#"{"type":"assistant","uuid":"u2","timestamp":"2024-01-01T00:01:00Z","message":{"role":"assistant","content":"Hi"}}"#;
        fs::write(
            project_dir.join("session-1.jsonl"),
            format!("{}\n{}\n", entry1, entry2),
        )
        .unwrap();

        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
        let io = ConvoIO::with_resolver(resolver);
        (temp, io)
    }

    #[test]
    fn test_default() {
        let _io = ConvoIO::default();
    }

    #[test]
    fn test_read_conversation() {
        let (_temp, io) = setup_io();
        let convo = io.read_conversation("/test/project", "session-1").unwrap();
        assert_eq!(convo.entries.len(), 2);
    }

    #[test]
    fn test_read_conversation_metadata() {
        let (_temp, io) = setup_io();
        let meta = io
            .read_conversation_metadata("/test/project", "session-1")
            .unwrap();
        assert_eq!(meta.message_count, 2);
    }

    #[test]
    fn test_list_conversations() {
        let (_temp, io) = setup_io();
        let sessions = io.list_conversations("/test/project").unwrap();
        assert_eq!(sessions.len(), 1);
    }

    #[test]
    fn test_list_conversation_metadata() {
        let (_temp, io) = setup_io();
        let meta = io.list_conversation_metadata("/test/project").unwrap();
        assert_eq!(meta.len(), 1);
        assert_eq!(meta[0].message_count, 2);
    }

    #[test]
    fn test_list_projects() {
        let (_temp, io) = setup_io();
        let projects = io.list_projects().unwrap();
        assert_eq!(projects.len(), 1);
    }

    #[test]
    fn test_exists() {
        let (_temp, io) = setup_io();
        assert!(io.exists());
    }

    #[test]
    fn test_claude_dir_path() {
        let (_temp, io) = setup_io();
        let path = io.claude_dir_path().unwrap();
        assert!(path.exists());
    }

    #[test]
    fn test_conversation_exists() {
        let (_temp, io) = setup_io();
        assert!(
            io.conversation_exists("/test/project", "session-1")
                .unwrap()
        );
        assert!(
            !io.conversation_exists("/test/project", "nonexistent")
                .unwrap()
        );
    }

    #[test]
    fn test_project_exists() {
        let (_temp, io) = setup_io();
        assert!(io.project_exists("/test/project"));
        assert!(!io.project_exists("/nonexistent"));
    }

    #[test]
    fn test_read_history_no_file() {
        let (_temp, io) = setup_io();
        let history = io.read_history().unwrap();
        assert!(history.is_empty());
    }

    #[test]
    fn test_resolver_accessor() {
        let (_temp, io) = setup_io();
        assert!(io.resolver().exists());
    }
}