1use crate::config::paths::Paths;
7use anyhow::Result;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::PathBuf;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SummaryCacheData {
16 pub uuid: String,
18 pub summary: String,
20 pub timestamp: DateTime<Utc>,
22 pub turn_count: Option<usize>,
24}
25
26fn get_summaries_dir() -> PathBuf {
28 Paths::data_dir().join("sessions").join("summaries")
29}
30
31fn ensure_summaries_dir() -> Result<PathBuf> {
33 let dir = get_summaries_dir();
34 if !dir.exists() {
35 fs::create_dir_all(&dir)?;
36 }
37 Ok(dir)
38}
39
40pub fn save_summary(session_id: &str, summary: &str, turn_count: Option<usize>) -> Result<()> {
47 let dir = ensure_summaries_dir()?;
48 let file_path = dir.join(format!("{}.json", session_id));
49
50 let data = SummaryCacheData {
51 uuid: session_id.to_string(),
52 summary: summary.to_string(),
53 timestamp: Utc::now(),
54 turn_count,
55 };
56
57 let json = serde_json::to_string_pretty(&data)?;
58 fs::write(&file_path, json)?;
59
60 Ok(())
61}
62
63pub fn load_summary(session_id: &str) -> Option<String> {
71 let dir = get_summaries_dir();
72 let file_path = dir.join(format!("{}.json", session_id));
73
74 if !file_path.exists() {
75 return None;
76 }
77
78 match fs::read_to_string(&file_path) {
79 Ok(content) => match serde_json::from_str::<SummaryCacheData>(&content) {
80 Ok(data) => Some(data.summary),
81 Err(e) => {
82 tracing::warn!("Failed to parse summary for session {}: {}", session_id, e);
83 None
84 }
85 },
86 Err(e) => {
87 tracing::warn!("Failed to read summary for session {}: {}", session_id, e);
88 None
89 }
90 }
91}
92
93pub fn load_summary_data(session_id: &str) -> Option<SummaryCacheData> {
101 let dir = get_summaries_dir();
102 let file_path = dir.join(format!("{}.json", session_id));
103
104 if !file_path.exists() {
105 return None;
106 }
107
108 fs::read_to_string(&file_path)
109 .ok()
110 .and_then(|content| serde_json::from_str(&content).ok())
111}
112
113pub fn has_summary(session_id: &str) -> bool {
118 let dir = get_summaries_dir();
119 let file_path = dir.join(format!("{}.json", session_id));
120 file_path.exists()
121}
122
123pub fn delete_summary(session_id: &str) -> Result<()> {
128 let dir = get_summaries_dir();
129 let file_path = dir.join(format!("{}.json", session_id));
130
131 if file_path.exists() {
132 fs::remove_file(&file_path)?;
133 }
134
135 Ok(())
136}
137
138pub fn list_summaries() -> Vec<SummaryCacheData> {
143 let dir = get_summaries_dir();
144
145 if !dir.exists() {
146 return Vec::new();
147 }
148
149 let mut summaries = Vec::new();
150
151 if let Ok(entries) = fs::read_dir(&dir) {
152 for entry in entries.flatten() {
153 let path = entry.path();
154 if path.extension().is_some_and(|ext| ext == "json") {
155 if let Ok(content) = fs::read_to_string(&path) {
156 if let Ok(data) = serde_json::from_str::<SummaryCacheData>(&content) {
157 summaries.push(data);
158 }
159 }
160 }
161 }
162 }
163
164 summaries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
166 summaries
167}
168
169pub fn build_resume_message(summary: &str, is_non_interactive: bool) -> String {
181 let base = format!(
182 "This session is being continued from a previous conversation that ran out of context. \
183 The conversation is summarized below:\n{}",
184 summary
185 );
186
187 if is_non_interactive {
188 base
190 } else {
191 format!(
193 "{}\n\nPlease continue the conversation from where we left it off \
194 without asking the user any further questions. \
195 Continue with the last task that you were asked to work on.",
196 base
197 )
198 }
199}
200
201pub fn cleanup_old_summaries(max_age_days: u32) -> Result<usize> {
209 let dir = get_summaries_dir();
210
211 if !dir.exists() {
212 return Ok(0);
213 }
214
215 let cutoff = Utc::now() - chrono::Duration::days(max_age_days as i64);
216 let mut deleted = 0;
217
218 if let Ok(entries) = fs::read_dir(&dir) {
219 for entry in entries.flatten() {
220 let path = entry.path();
221 if path.extension().is_some_and(|ext| ext == "json") {
222 if let Ok(content) = fs::read_to_string(&path) {
223 if let Ok(data) = serde_json::from_str::<SummaryCacheData>(&content) {
224 if data.timestamp < cutoff && fs::remove_file(&path).is_ok() {
225 deleted += 1;
226 }
227 }
228 }
229 }
230 }
231 }
232
233 Ok(deleted)
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 #[allow(unused_imports)]
240 use tempfile::TempDir;
241
242 #[test]
243 fn test_build_resume_message_interactive() {
244 let summary = "User asked about Rust programming. Assistant explained ownership.";
245 let message = build_resume_message(summary, false);
246
247 assert!(message.contains(summary));
248 assert!(message.contains("continue the conversation"));
249 assert!(message.contains("without asking the user"));
250 }
251
252 #[test]
253 fn test_build_resume_message_non_interactive() {
254 let summary = "User asked about Rust programming.";
255 let message = build_resume_message(summary, true);
256
257 assert!(message.contains(summary));
258 assert!(!message.contains("without asking the user"));
259 }
260
261 #[test]
262 fn test_summary_cache_data_serialization() {
263 let data = SummaryCacheData {
264 uuid: "test_session_123".to_string(),
265 summary: "Test summary content".to_string(),
266 timestamp: Utc::now(),
267 turn_count: Some(10),
268 };
269
270 let json = serde_json::to_string(&data).unwrap();
271 let deserialized: SummaryCacheData = serde_json::from_str(&json).unwrap();
272
273 assert_eq!(deserialized.uuid, data.uuid);
274 assert_eq!(deserialized.summary, data.summary);
275 assert_eq!(deserialized.turn_count, Some(10));
276 }
277}