clync 0.2.3

Encrypted sync for Claude Code across machines
use anyhow::Result;
use serde::Serialize;
use std::path::Path;

use crate::parser::parse_jsonl_file;
use crate::scanner::{ScanFilter, scan_sessions};

#[derive(Debug, Serialize)]
pub struct SessionSummary {
    pub uuid: String,
    pub project: String,
    pub messages: usize,
    pub first_message: Option<String>,
    pub size_bytes: u64,
    pub mtime: u64,
}

pub fn list_sessions(
    claude_projects_dir: &Path,
    query: Option<&str>,
    filter: &ScanFilter,
) -> Result<Vec<SessionSummary>> {
    let sessions = scan_sessions(claude_projects_dir, filter)?;
    let mut summaries = Vec::new();

    for session in &sessions {
        let entries = parse_jsonl_file(&session.jsonl_path).unwrap_or_default();

        let first_user_message = entries
            .iter()
            .find(|e| e.entry_type.as_deref() == Some("user"))
            .and_then(|e| {
                e.extra.get("message").and_then(|m| {
                    m.get("content")
                        .and_then(|c| c.as_str().map(|s| truncate(s, 120).to_string()))
                        .or_else(|| {
                            m.get("content").and_then(|c| {
                                c.as_array().and_then(|arr| {
                                    arr.iter().find_map(|item| {
                                        item.get("text")
                                            .and_then(|t| t.as_str())
                                            .map(|s| truncate(s, 120).to_string())
                                    })
                                })
                            })
                        })
                })
            });

        let message_count = entries
            .iter()
            .filter(|e| matches!(e.entry_type.as_deref(), Some("user") | Some("assistant")))
            .count();

        let summary = SessionSummary {
            uuid: session.uuid.clone(),
            project: session.entry.project_path.clone(),
            messages: message_count,
            first_message: first_user_message,
            size_bytes: session.entry.size,
            mtime: session.entry.mtime,
        };

        if let Some(q) = query {
            let q_lower = q.to_lowercase();
            let matches = summary.project.to_lowercase().contains(&q_lower)
                || summary.uuid.contains(&q_lower)
                || summary
                    .first_message
                    .as_ref()
                    .is_some_and(|m| m.to_lowercase().contains(&q_lower));
            if !matches {
                continue;
            }
        }

        summaries.push(summary);
    }

    summaries.sort_by_key(|s| std::cmp::Reverse(s.mtime));
    Ok(summaries)
}

fn truncate(s: &str, max: usize) -> &str {
    if s.len() <= max {
        return s;
    }
    let mut end = max;
    while end > 0 && !s.is_char_boundary(end) {
        end -= 1;
    }
    &s[..end]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn truncate_short_string() {
        assert_eq!(truncate("hello", 10), "hello");
    }

    #[test]
    fn truncate_exact_length() {
        assert_eq!(truncate("hello", 5), "hello");
    }

    #[test]
    fn truncate_long_string() {
        assert_eq!(truncate("hello world", 5), "hello");
    }

    #[test]
    fn truncate_multibyte_utf8() {
        let s = "hello\u{00FC}world";
        let result = truncate(s, 6);
        assert_eq!(result, "hello");
    }

    #[test]
    fn truncate_empty() {
        assert_eq!(truncate("", 5), "");
    }

    #[test]
    fn truncate_zero_max() {
        assert_eq!(truncate("hello", 0), "");
    }
}