Files
huskies/server/src/chat/history.rs
T

191 lines
7.1 KiB
Rust

//! 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<TokioMutex<HashMap<String, RoomConversation>>>;
/// On-disk format for persisted conversation history.
#[derive(Serialize, Deserialize)]
struct PersistedHistory {
conversations: HashMap<String, RoomConversation>,
}
/// 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<String, RoomConversation>`. New writes always use the
/// `conversations` key.
pub fn load_chat_history(
project_root: &std::path::Path,
relative_path: &str,
log_tag: &str,
) -> HashMap<String, RoomConversation> {
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::<PersistedHistory>(&data) {
return p.conversations;
}
// Migration fallback: find the first map-valued field, whatever its name.
match serde_json::from_str::<serde_json::Value>(&data) {
Ok(value) => {
if let Some(obj) = value.as_object() {
for (_key, val) in obj {
if let Ok(map) =
serde_json::from_value::<HashMap<String, RoomConversation>>(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<String, RoomConversation>,
) {
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());
}
}