Skip to main content

openheim/rag/
history.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5use crate::config::config_dir;
6use crate::core::models::{Message, Role};
7use crate::error::{Error, Result};
8use std::path::PathBuf;
9
10#[cfg(test)]
11mod tests {
12    use super::*;
13    use tempfile::tempdir;
14
15    fn make_manager() -> (HistoryManager, tempfile::TempDir) {
16        let dir = tempdir().unwrap();
17        let mgr = HistoryManager::with_dir(dir.path().to_path_buf());
18        (mgr, dir)
19    }
20
21    #[test]
22    fn create_and_load_conversation_roundtrip() {
23        let (mgr, _dir) = make_manager();
24        let conv = mgr
25            .create_conversation(Some("gpt-4".into()), Some("openai".into()), vec![])
26            .unwrap();
27        let loaded = mgr.load_conversation(&conv.meta.id).unwrap();
28        assert_eq!(loaded.meta.id, conv.meta.id);
29        assert_eq!(loaded.meta.model.as_deref(), Some("gpt-4"));
30        assert_eq!(loaded.meta.provider.as_deref(), Some("openai"));
31        assert!(loaded.messages.is_empty());
32    }
33
34    #[test]
35    fn load_nonexistent_conversation_errors() {
36        let (mgr, _dir) = make_manager();
37        let id = Uuid::new_v4();
38        let err = mgr.load_conversation(&id).unwrap_err();
39        assert!(err.to_string().contains(&id.to_string()));
40    }
41
42    #[test]
43    fn save_sets_title_from_first_user_message() {
44        let (mgr, _dir) = make_manager();
45        let mut conv = mgr.create_conversation(None, None, vec![]).unwrap();
46        conv.messages.push(Message::user("hello world".into()));
47        mgr.save_conversation(&conv).unwrap();
48        let loaded = mgr.load_conversation(&conv.meta.id).unwrap();
49        assert_eq!(loaded.meta.title.as_deref(), Some("hello world"));
50    }
51
52    #[test]
53    fn save_truncates_long_title() {
54        let (mgr, _dir) = make_manager();
55        let mut conv = mgr.create_conversation(None, None, vec![]).unwrap();
56        let long_msg: String = "a".repeat(100);
57        conv.messages.push(Message::user(long_msg));
58        mgr.save_conversation(&conv).unwrap();
59        let loaded = mgr.load_conversation(&conv.meta.id).unwrap();
60        assert_eq!(loaded.meta.title.as_ref().map(|t| t.len()), Some(80));
61    }
62
63    #[test]
64    fn list_conversations_returns_most_recent_first() {
65        let (mgr, _dir) = make_manager();
66        mgr.create_conversation(None, None, vec![]).unwrap();
67        mgr.create_conversation(None, None, vec![]).unwrap();
68        let list = mgr.list_conversations().unwrap();
69        assert_eq!(list.len(), 2);
70        assert!(list[0].updated_at >= list[1].updated_at);
71    }
72
73    #[test]
74    fn list_conversations_empty_dir() {
75        let (mgr, _dir) = make_manager();
76        let list = mgr.list_conversations().unwrap();
77        assert!(list.is_empty());
78    }
79
80    #[test]
81    fn get_last_conversation_returns_none_when_empty() {
82        let (mgr, _dir) = make_manager();
83        let result = mgr.get_last_conversation().unwrap();
84        assert!(result.is_none());
85    }
86
87    #[test]
88    fn get_last_conversation_returns_most_recent() {
89        let (mgr, _dir) = make_manager();
90        mgr.create_conversation(None, None, vec![]).unwrap();
91        let second = mgr.create_conversation(None, None, vec![]).unwrap();
92        // Save second with a message so its updated_at is newer
93        let mut conv = second.clone();
94        conv.messages.push(Message::user("latest".into()));
95        mgr.save_conversation(&conv).unwrap();
96        let last = mgr.get_last_conversation().unwrap().unwrap();
97        assert_eq!(last.meta.id, conv.meta.id);
98    }
99
100    #[test]
101    fn resolve_conversation_loads_existing_by_id() {
102        let (mgr, _dir) = make_manager();
103        let existing = mgr
104            .create_conversation(Some("gpt-4".into()), None, vec![])
105            .unwrap();
106        let resolved = mgr
107            .resolve_conversation(Some(existing.meta.id), None, None, vec![])
108            .unwrap();
109        assert_eq!(resolved.meta.id, existing.meta.id);
110        assert_eq!(resolved.meta.model.as_deref(), Some("gpt-4"));
111    }
112
113    #[test]
114    fn resolve_conversation_creates_new_for_unknown_id() {
115        let (mgr, _dir) = make_manager();
116        let new_id = Uuid::new_v4();
117        let resolved = mgr
118            .resolve_conversation(Some(new_id), Some("claude".into()), None, vec![])
119            .unwrap();
120        assert_eq!(resolved.meta.id, new_id);
121        assert_eq!(resolved.meta.model.as_deref(), Some("claude"));
122    }
123
124    #[test]
125    fn resolve_conversation_creates_fresh_when_no_id() {
126        let (mgr, _dir) = make_manager();
127        let resolved = mgr.resolve_conversation(None, None, None, vec![]).unwrap();
128        assert!(resolved.messages.is_empty());
129        // Verify it was persisted
130        mgr.load_conversation(&resolved.meta.id).unwrap();
131    }
132
133    #[test]
134    fn conversation_skills_are_persisted() {
135        let (mgr, _dir) = make_manager();
136        let conv = mgr
137            .create_conversation(None, None, vec!["coding".into(), "rust".into()])
138            .unwrap();
139        let loaded = mgr.load_conversation(&conv.meta.id).unwrap();
140        assert_eq!(loaded.meta.skills, vec!["coding", "rust"]);
141    }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct ConversationMeta {
146    pub id: Uuid,
147    pub created_at: DateTime<Utc>,
148    pub updated_at: DateTime<Utc>,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub model: Option<String>,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub provider: Option<String>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub title: Option<String>,
155    #[serde(default)]
156    pub skills: Vec<String>,
157    #[serde(skip_serializing_if = "Option::is_none", default)]
158    pub cwd: Option<std::path::PathBuf>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct Conversation {
163    pub meta: ConversationMeta,
164    pub messages: Vec<Message>,
165}
166
167/// Lightweight struct for deserializing only the metadata portion of a conversation file.
168#[derive(Debug, Deserialize)]
169struct ConversationEnvelope {
170    meta: ConversationMeta,
171}
172
173#[derive(Clone)]
174pub struct HistoryManager {
175    history_dir: PathBuf,
176}
177
178impl HistoryManager {
179    pub fn new() -> Result<Self> {
180        let dir = config_dir()?.join("history");
181        std::fs::create_dir_all(&dir)?;
182        Ok(Self { history_dir: dir })
183    }
184
185    fn conversation_path(&self, id: &Uuid) -> PathBuf {
186        self.history_dir.join(format!("{}.json", id))
187    }
188
189    pub fn create_conversation(
190        &self,
191        model: Option<String>,
192        provider: Option<String>,
193        skills: Vec<String>,
194    ) -> Result<Conversation> {
195        let now = Utc::now();
196        let conv = Conversation {
197            meta: ConversationMeta {
198                id: Uuid::new_v4(),
199                created_at: now,
200                updated_at: now,
201                model,
202                provider,
203                title: None,
204                skills,
205                cwd: None,
206            },
207            messages: Vec::new(),
208        };
209        self.save_conversation(&conv)?;
210        Ok(conv)
211    }
212
213    pub fn load_conversation(&self, id: &Uuid) -> Result<Conversation> {
214        let path = self.conversation_path(id);
215        if !path.exists() {
216            return Err(Error::Other(format!(
217                "Conversation {} not found at {}",
218                id,
219                path.display()
220            )));
221        }
222        let data = std::fs::read_to_string(&path)?;
223        let conv: Conversation = serde_json::from_str(&data)?;
224        Ok(conv)
225    }
226
227    pub fn save_conversation(&self, conv: &Conversation) -> Result<()> {
228        let path = self.conversation_path(&conv.meta.id);
229        let mut conv_to_save = conv.clone();
230        conv_to_save.meta.updated_at = Utc::now();
231
232        if conv_to_save.meta.title.is_none()
233            && let Some(msg) = conv_to_save.messages.iter().find(|m| m.role == Role::User)
234            && let Some(content) = &msg.content
235        {
236            let title: String = content.chars().take(80).collect();
237            conv_to_save.meta.title = Some(title);
238        }
239
240        let json = serde_json::to_string_pretty(&conv_to_save)?;
241        std::fs::write(&path, json)?;
242        Ok(())
243    }
244
245    pub fn delete_conversation(&self, id: &Uuid) -> Result<()> {
246        let path = self.conversation_path(id);
247        if !path.exists() {
248            return Err(Error::Other(format!("Conversation {id} not found")));
249        }
250        std::fs::remove_file(&path)?;
251        Ok(())
252    }
253
254    pub fn list_conversations(&self) -> Result<Vec<ConversationMeta>> {
255        let mut metas = Vec::new();
256        for entry in std::fs::read_dir(&self.history_dir)? {
257            let entry = entry?;
258            let path = entry.path();
259            if path.extension().and_then(|e| e.to_str()) == Some("json") {
260                let data = std::fs::read_to_string(&path)?;
261                if let Ok(envelope) = serde_json::from_str::<ConversationEnvelope>(&data) {
262                    metas.push(envelope.meta);
263                }
264            }
265        }
266        metas.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
267        Ok(metas)
268    }
269
270    pub fn get_last_conversation(&self) -> Result<Option<Conversation>> {
271        let metas = self.list_conversations()?;
272        match metas.first() {
273            Some(meta) => Ok(Some(self.load_conversation(&meta.id)?)),
274            None => Ok(None),
275        }
276    }
277
278    #[cfg(test)]
279    pub fn with_dir(dir: std::path::PathBuf) -> Self {
280        Self { history_dir: dir }
281    }
282
283    pub fn resolve_conversation(
284        &self,
285        chat_id: Option<Uuid>,
286        model: Option<String>,
287        provider: Option<String>,
288        skills: Vec<String>,
289    ) -> Result<Conversation> {
290        match chat_id {
291            Some(id) => {
292                let path = self.conversation_path(&id);
293                if path.exists() {
294                    self.load_conversation(&id)
295                } else {
296                    let now = Utc::now();
297                    let conv = Conversation {
298                        meta: ConversationMeta {
299                            id,
300                            created_at: now,
301                            updated_at: now,
302                            model,
303                            provider,
304                            title: None,
305                            skills,
306                            cwd: None,
307                        },
308                        messages: Vec::new(),
309                    };
310                    self.save_conversation(&conv)?;
311                    Ok(conv)
312                }
313            }
314            None => self.create_conversation(model, provider, skills),
315        }
316    }
317}