//! Shared chat history persistence used by all transport modules. //! //! Each transport (Slack, WhatsApp, Discord, Matrix) stores its conversation //! history in a transport-specific JSON file but uses the same on-disk format //! and the same load/save logic provided here. use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex as TokioMutex; use crate::chat::transport::matrix::RoomConversation; use crate::slog; /// Shared conversation history type used by all string-keyed chat transports. #[allow(dead_code)] pub type ChatConversationHistory = Arc>>; /// On-disk format for persisted conversation history. #[derive(Serialize, Deserialize)] struct PersistedHistory { conversations: HashMap, } /// Load conversation history from `project_root/relative_path`. /// /// Returns an empty map on any I/O or parse error. /// /// # Migration fallback /// /// Older transport-specific files stored the conversation map under a key that /// varied by transport (`channels` for Slack/Discord, `senders` for WhatsApp, /// `rooms` for Matrix). If deserialising the file as a [`PersistedHistory`] /// (which expects the key `conversations`) fails, this function falls back to /// parsing the file as a generic [`serde_json::Value`], locates the first /// top-level object field whose value is itself an object (the conversation /// map), and returns its contents deserialised as /// `HashMap`. New writes always use the /// `conversations` key. pub fn load_chat_history( project_root: &std::path::Path, relative_path: &str, log_tag: &str, ) -> HashMap { let path = project_root.join(relative_path); let data = match std::fs::read_to_string(&path) { Ok(d) => d, Err(_) => return HashMap::new(), }; // Try the new canonical format first. if let Ok(p) = serde_json::from_str::(&data) { return p.conversations; } // Migration fallback: find the first map-valued field, whatever its name. match serde_json::from_str::(&data) { Ok(value) => { if let Some(obj) = value.as_object() { for (_key, val) in obj { if let Ok(map) = serde_json::from_value::>(val.clone()) { return map; } } } slog!( "[{}] Failed to parse history file: no valid conversation map found", log_tag ); HashMap::new() } Err(e) => { slog!("[{}] Failed to parse history file: {}", log_tag, e); HashMap::new() } } } /// Save conversation history to `project_root/relative_path` under the /// `conversations` key. Errors are logged but not propagated. pub fn save_chat_history( project_root: &std::path::Path, relative_path: &str, log_tag: &str, history: &HashMap, ) { let persisted = PersistedHistory { conversations: history.clone(), }; let path = project_root.join(relative_path); match serde_json::to_string_pretty(&persisted) { Ok(json) => { if let Err(e) = std::fs::write(&path, json) { slog!("[{}] Failed to write history file: {}", log_tag, e); } } Err(e) => slog!("[{}] Failed to serialise history: {}", log_tag, e), } } // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use crate::chat::transport::matrix::{ConversationEntry, ConversationRole}; #[test] fn round_trip_save_load_preserves_history() { let tmp = tempfile::tempdir().unwrap(); let huskies_dir = tmp.path().join(".huskies"); std::fs::create_dir_all(&huskies_dir).unwrap(); let mut history = HashMap::new(); history.insert( "room-1".to_string(), RoomConversation { session_id: Some("sess-xyz".to_string()), entries: vec![ ConversationEntry { role: ConversationRole::User, sender: "alice".to_string(), content: "hello".to_string(), }, ConversationEntry { role: ConversationRole::Assistant, sender: String::new(), content: "hi there!".to_string(), }, ], }, ); save_chat_history(tmp.path(), ".huskies/test_history.json", "test", &history); let loaded = load_chat_history(tmp.path(), ".huskies/test_history.json", "test"); assert_eq!(loaded.len(), 1); let conv = loaded.get("room-1").unwrap(); assert_eq!(conv.session_id.as_deref(), Some("sess-xyz")); assert_eq!(conv.entries.len(), 2); assert_eq!(conv.entries[0].role, ConversationRole::User); assert_eq!(conv.entries[0].content, "hello"); assert_eq!(conv.entries[1].role, ConversationRole::Assistant); assert_eq!(conv.entries[1].content, "hi there!"); } #[test] fn legacy_channels_key_loads_via_migration_fallback() { let tmp = tempfile::tempdir().unwrap(); let huskies_dir = tmp.path().join(".huskies"); std::fs::create_dir_all(&huskies_dir).unwrap(); // Simulate a legacy Slack history file with the `channels` top-level key. let legacy_json = r#"{ "channels": { "C01ABCDEF": { "entries": [ {"role": "user", "sender": "U123", "content": "legacy message"} ] } } }"#; std::fs::write(huskies_dir.join("slack_history.json"), legacy_json).unwrap(); let loaded = load_chat_history(tmp.path(), ".huskies/slack_history.json", "slack"); assert_eq!(loaded.len(), 1); let conv = loaded.get("C01ABCDEF").unwrap(); assert_eq!(conv.entries.len(), 1); assert_eq!(conv.entries[0].content, "legacy message"); assert_eq!(conv.entries[0].role, ConversationRole::User); } #[test] fn load_returns_empty_when_file_missing() { let tmp = tempfile::tempdir().unwrap(); let loaded = load_chat_history(tmp.path(), ".huskies/missing.json", "test"); assert!(loaded.is_empty()); } #[test] fn load_returns_empty_on_invalid_json() { let tmp = tempfile::tempdir().unwrap(); let huskies_dir = tmp.path().join(".huskies"); std::fs::create_dir_all(&huskies_dir).unwrap(); std::fs::write(huskies_dir.join("bad.json"), "not json {{{").unwrap(); let loaded = load_chat_history(tmp.path(), ".huskies/bad.json", "test"); assert!(loaded.is_empty()); } }