clync 0.1.5

Encrypted sync for Claude Code across machines
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationEntry {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub uuid: Option<String>,
    #[serde(
        default,
        rename = "parentUuid",
        skip_serializing_if = "Option::is_none"
    )]
    pub parent_uuid: Option<String>,
    #[serde(default, rename = "sessionId", skip_serializing_if = "Option::is_none")]
    pub session_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timestamp: Option<Value>,
    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
    pub entry_type: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    #[serde(flatten)]
    pub extra: Value,
}

impl ConversationEntry {
    pub fn content_hash(&self) -> u64 {
        let serialized = serde_json::to_string(self).unwrap_or_default();
        fnv1a_hash(serialized.as_bytes())
    }

    pub fn timestamp_millis(&self) -> u64 {
        match &self.timestamp {
            Some(Value::Number(n)) => n.as_u64().unwrap_or(0),
            Some(Value::String(s)) => s.parse().unwrap_or(0),
            _ => 0,
        }
    }
}

pub fn parse_jsonl(data: &[u8]) -> Result<Vec<ConversationEntry>> {
    let text = std::str::from_utf8(data).context("session file is not valid UTF-8")?;
    let mut entries = Vec::new();
    for line in text.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        match serde_json::from_str::<ConversationEntry>(trimmed) {
            Ok(entry) => entries.push(entry),
            Err(e) => {
                eprintln!("warning: skipping unparseable JSONL line: {e}");
            }
        }
    }
    Ok(entries)
}

pub fn entries_to_jsonl(entries: &[ConversationEntry]) -> Result<Vec<u8>> {
    let mut output = Vec::new();
    for entry in entries {
        let line = serde_json::to_string(entry)?;
        output.extend_from_slice(line.as_bytes());
        output.push(b'\n');
    }
    Ok(output)
}

pub fn parse_jsonl_file(path: &Path) -> Result<Vec<ConversationEntry>> {
    let data = std::fs::read(path).with_context(|| format!("could not read {}", path.display()))?;
    parse_jsonl(&data)
}

pub fn file_content_hash(path: &Path) -> Result<u64> {
    let data = std::fs::read(path)?;
    Ok(fnv1a_hash(&data))
}

fn fnv1a_hash(data: &[u8]) -> u64 {
    let mut hash: u64 = 0xcbf29ce484222325;
    for &byte in data {
        hash ^= byte as u64;
        hash = hash.wrapping_mul(0x100000001b3);
    }
    hash
}

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

    #[test]
    fn parse_empty() {
        let entries = parse_jsonl(b"").unwrap();
        assert!(entries.is_empty());
    }

    #[test]
    fn parse_blank_lines() {
        let input = b"\n\n{\"type\":\"mode\"}\n\n";
        let entries = parse_jsonl(input).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].entry_type.as_deref(), Some("mode"));
    }

    #[test]
    fn parse_conversation_entry() {
        let input = br#"{"uuid":"abc-123","parentUuid":"parent-1","sessionId":"sess-1","timestamp":1700000000,"type":"user","message":{"content":"hello"}}"#;
        let entries = parse_jsonl(input).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].uuid.as_deref(), Some("abc-123"));
        assert_eq!(entries[0].parent_uuid.as_deref(), Some("parent-1"));
        assert_eq!(entries[0].session_id.as_deref(), Some("sess-1"));
        assert_eq!(entries[0].timestamp_millis(), 1700000000);
        assert_eq!(entries[0].entry_type.as_deref(), Some("user"));
    }

    #[test]
    fn roundtrip() {
        let input = b"{\"uuid\":\"a\",\"type\":\"user\",\"timestamp\":100}\n{\"uuid\":\"b\",\"type\":\"assistant\",\"timestamp\":200}\n";
        let entries = parse_jsonl(input).unwrap();
        let output = entries_to_jsonl(&entries).unwrap();
        let reparsed = parse_jsonl(&output).unwrap();
        assert_eq!(entries.len(), reparsed.len());
        assert_eq!(entries[0].uuid, reparsed[0].uuid);
        assert_eq!(entries[1].uuid, reparsed[1].uuid);
    }

    #[test]
    fn content_hash_differs() {
        let a: ConversationEntry =
            serde_json::from_str(r#"{"uuid":"a","type":"user","timestamp":100}"#).unwrap();
        let b: ConversationEntry =
            serde_json::from_str(r#"{"uuid":"a","type":"user","timestamp":200}"#).unwrap();
        assert_ne!(a.content_hash(), b.content_hash());
    }

    #[test]
    fn content_hash_same() {
        let a: ConversationEntry =
            serde_json::from_str(r#"{"uuid":"x","type":"user","timestamp":100}"#).unwrap();
        let b: ConversationEntry =
            serde_json::from_str(r#"{"uuid":"x","type":"user","timestamp":100}"#).unwrap();
        assert_eq!(a.content_hash(), b.content_hash());
    }

    #[test]
    fn flatten_preserves_unknown_fields() {
        let input = r#"{"uuid":"a","type":"user","custom_field":"value","nested":{"key":1}}"#;
        let entry: ConversationEntry = serde_json::from_str(input).unwrap();
        let serialized = serde_json::to_string(&entry).unwrap();
        assert!(serialized.contains("custom_field"));
        assert!(serialized.contains("nested"));
    }
}