huskies: merge 670_refactor_hoist_chat_history_persistence_into_a_shared_module_replaces_658
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user