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 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 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#[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}