1use std::fs;
9use std::io::{BufRead, Write};
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use super::driver::Message;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SessionManifest {
19 pub id: String,
21 pub agent: String,
23 pub cwd: String,
25 pub created: String,
27 pub turns: u32,
29}
30
31pub struct SessionStore {
33 pub dir: PathBuf,
35 pub manifest: SessionManifest,
37}
38
39impl SessionStore {
40 pub fn create(agent_name: &str) -> anyhow::Result<Self> {
42 let id = generate_session_id();
43 let sessions_dir = sessions_root()?;
44 let dir = sessions_dir.join(&id);
45 fs::create_dir_all(&dir)?;
46
47 let cwd =
48 std::env::current_dir().map(|p| p.display().to_string()).unwrap_or_else(|_| ".".into());
49
50 let manifest = SessionManifest {
51 id,
52 agent: agent_name.to_string(),
53 cwd,
54 created: chrono_now(),
55 turns: 0,
56 };
57
58 let manifest_path = dir.join("manifest.json");
60 let json = serde_json::to_string_pretty(&manifest)?;
61 fs::write(&manifest_path, json)?;
62
63 Ok(Self { dir, manifest })
64 }
65
66 pub fn resume(session_id: &str) -> anyhow::Result<Self> {
68 let dir = sessions_root()?.join(session_id);
69 if !dir.is_dir() {
70 anyhow::bail!("session not found: {session_id}");
71 }
72
73 let manifest_path = dir.join("manifest.json");
74 let json = fs::read_to_string(&manifest_path)?;
75 let manifest: SessionManifest = serde_json::from_str(&json)?;
76
77 Ok(Self { dir, manifest })
78 }
79
80 pub fn find_recent_for_cwd() -> Option<SessionManifest> {
85 Self::find_recent_for_cwd_within(std::time::Duration::from_secs(24 * 3600))
86 }
87
88 pub fn find_recent_for_cwd_within(max_age: std::time::Duration) -> Option<SessionManifest> {
90 let sessions_dir = sessions_root().ok()?;
91 if !sessions_dir.is_dir() {
92 return None;
93 }
94
95 let cwd = std::env::current_dir().ok()?.display().to_string();
96 let now = std::time::SystemTime::now();
97
98 let mut best: Option<(SessionManifest, std::time::SystemTime)> = None;
99 for entry in fs::read_dir(&sessions_dir).ok()?.flatten() {
100 let manifest_path = entry.path().join("manifest.json");
101 if !manifest_path.is_file() {
102 continue;
103 }
104 if let Ok(json) = fs::read_to_string(&manifest_path) {
105 if let Ok(m) = serde_json::from_str::<SessionManifest>(&json) {
106 if m.cwd == cwd && m.turns > 0 {
107 let mtime = entry.metadata().ok()?.modified().ok()?;
108 if now.duration_since(mtime).unwrap_or(max_age) >= max_age {
110 continue;
111 }
112 if best.as_ref().is_none_or(|(_, t)| mtime > *t) {
113 best = Some((m, mtime));
114 }
115 }
116 }
117 }
118 }
119
120 best.map(|(m, _)| m)
121 }
122
123 pub fn id(&self) -> &str {
125 &self.manifest.id
126 }
127
128 pub fn append_message(&self, msg: &Message) -> anyhow::Result<()> {
130 let path = self.dir.join("messages.jsonl");
131 let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
132 let json = serde_json::to_string(msg)?;
133 writeln!(file, "{json}")?;
134 Ok(())
135 }
136
137 pub fn append_messages(&self, msgs: &[Message]) -> anyhow::Result<()> {
139 let path = self.dir.join("messages.jsonl");
140 let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
141 for msg in msgs {
142 let json = serde_json::to_string(msg)?;
143 writeln!(file, "{json}")?;
144 }
145 Ok(())
146 }
147
148 pub fn load_messages(&self) -> anyhow::Result<Vec<Message>> {
150 let path = self.dir.join("messages.jsonl");
151 if !path.is_file() {
152 return Ok(Vec::new());
153 }
154
155 let file = fs::File::open(&path)?;
156 let reader = std::io::BufReader::new(file);
157 let mut messages = Vec::new();
158
159 for line in reader.lines() {
160 let line = line?;
161 if line.trim().is_empty() {
162 continue;
163 }
164 let msg: Message = serde_json::from_str(&line)?;
165 messages.push(msg);
166 }
167
168 Ok(messages)
169 }
170
171 pub fn record_turn(&mut self) -> anyhow::Result<()> {
173 self.manifest.turns += 1;
174 let manifest_path = self.dir.join("manifest.json");
175 let json = serde_json::to_string_pretty(&self.manifest)?;
176 fs::write(&manifest_path, json)?;
177 Ok(())
178 }
179}
180
181fn sessions_root() -> anyhow::Result<PathBuf> {
183 let home =
184 dirs::home_dir().ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?;
185 Ok(home.join(".apr").join("sessions"))
186}
187
188fn generate_session_id() -> String {
190 use std::time::{SystemTime, UNIX_EPOCH};
191 let dur = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
192 let ts = dur.as_secs();
193 let nanos = dur.subsec_nanos();
194 format!("{ts:x}-{nanos:08x}")
195}
196
197pub fn offer_auto_resume() -> Option<String> {
202 if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
203 return None;
204 }
205 let manifest = SessionStore::find_recent_for_cwd()?;
206 let age = manifest
207 .created
208 .parse::<chrono::DateTime<chrono::Utc>>()
209 .ok()
210 .map(|created| {
211 let elapsed = chrono::Utc::now().signed_duration_since(created);
212 if elapsed.num_hours() > 0 {
213 format!("{}h ago", elapsed.num_hours())
214 } else {
215 format!("{}m ago", elapsed.num_minutes().max(1))
216 }
217 })
218 .unwrap_or_else(|| "recently".to_string());
219 eprintln!(" Found previous session ({age}, {} turns)", manifest.turns);
220 eprint!(" Resume? [Y/n] ");
221 let mut input = String::new();
222 if std::io::stdin().read_line(&mut input).is_err() {
223 return None;
224 }
225 let input = input.trim().to_lowercase();
226 if input.is_empty() || input == "y" || input == "yes" {
227 Some(manifest.id)
228 } else {
229 None
230 }
231}
232
233fn chrono_now() -> String {
235 use std::time::{SystemTime, UNIX_EPOCH};
237 let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
238 let days = secs / 86400;
240 let rem = secs % 86400;
241 let h = rem / 3600;
242 let m = (rem % 3600) / 60;
243 let s = rem % 60;
244 let years = 1970 + days / 365;
246 let day_of_year = days % 365;
247 let month = day_of_year / 30 + 1;
248 let day = day_of_year % 30 + 1;
249 format!("{years:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn create_test_store() -> SessionStore {
258 let tmp = tempfile::tempdir().expect("tmpdir");
259 let id = generate_session_id();
260 let tmp_path = tmp.path().to_path_buf();
261 std::mem::forget(tmp);
263 let dir = tmp_path.join(&id);
264 fs::create_dir_all(&dir).expect("mkdir");
265
266 let manifest = SessionManifest {
267 id,
268 agent: "test-agent".into(),
269 cwd: ".".into(),
270 created: chrono_now(),
271 turns: 0,
272 };
273 let json = serde_json::to_string_pretty(&manifest).expect("json");
274 fs::write(dir.join("manifest.json"), json).expect("write");
275 SessionStore { dir, manifest }
276 }
277
278 #[test]
279 fn test_session_create_and_persist() {
280 let store = create_test_store();
281 assert!(!store.id().is_empty());
282 assert!(store.dir.is_dir());
283
284 store.append_message(&Message::User("hello".into())).expect("append");
285 store.append_message(&Message::Assistant("hi".into())).expect("append");
286
287 let msgs = store.load_messages().expect("load");
288 assert_eq!(msgs.len(), 2);
289 assert!(matches!(&msgs[0], Message::User(s) if s == "hello"));
290 assert!(matches!(&msgs[1], Message::Assistant(s) if s == "hi"));
291
292 let _ = fs::remove_dir_all(&store.dir);
293 }
294
295 #[test]
296 fn test_session_resume_by_path() {
297 let store = create_test_store();
298 store.append_message(&Message::User("test".into())).expect("append");
299
300 let manifest_json = fs::read_to_string(store.dir.join("manifest.json")).expect("read");
302 let manifest: SessionManifest = serde_json::from_str(&manifest_json).expect("parse");
303 let resumed = SessionStore { dir: store.dir.clone(), manifest };
304 let msgs = resumed.load_messages().expect("load");
305 assert_eq!(msgs.len(), 1);
306
307 let _ = fs::remove_dir_all(&store.dir);
308 }
309
310 #[test]
311 fn test_session_resume_nonexistent() {
312 let result = SessionStore::resume("nonexistent-id-12345");
313 assert!(result.is_err());
314 }
315
316 #[test]
317 fn test_generate_session_id_unique() {
318 let id1 = generate_session_id();
319 std::thread::sleep(std::time::Duration::from_millis(1));
321 let id2 = generate_session_id();
322 assert_ne!(id1, id2, "IDs should be unique");
323 assert!(id1.contains('-'));
324 }
325
326 #[test]
327 fn test_append_and_load_empty() {
328 let store = create_test_store();
329 let msgs = store.load_messages().expect("load");
330 assert!(msgs.is_empty());
331 let _ = fs::remove_dir_all(&store.dir);
332 }
333
334 #[test]
335 fn test_record_turn() {
336 let mut store = create_test_store();
337 assert_eq!(store.manifest.turns, 0);
338 store.record_turn().expect("record");
339 assert_eq!(store.manifest.turns, 1);
340
341 let json = fs::read_to_string(store.dir.join("manifest.json")).expect("read");
343 let reloaded: SessionManifest = serde_json::from_str(&json).expect("parse");
344 assert_eq!(reloaded.turns, 1);
345
346 let _ = fs::remove_dir_all(&store.dir);
347 }
348
349 #[test]
351 fn test_find_recent_for_cwd_within_zero_age() {
352 let result = SessionStore::find_recent_for_cwd_within(std::time::Duration::ZERO);
354 assert!(result.is_none(), "zero age should return nothing");
355 }
356
357 #[test]
358 fn test_find_recent_for_cwd_delegates_to_within() {
359 let _ = SessionStore::find_recent_for_cwd();
361 }
362
363 #[test]
366 fn falsify_session_001_jsonl_roundtrip() {
367 let store = create_test_store();
368 let original = vec![
369 Message::User("question".into()),
370 Message::Assistant("answer".into()),
371 Message::User("follow-up".into()),
372 Message::Assistant("response".into()),
373 ];
374 store.append_messages(&original).expect("append");
375 let loaded = store.load_messages().expect("load");
376 assert_eq!(loaded.len(), original.len(), "FALSIFY-SESSION-001: message count preserved");
377 for (i, (orig, load)) in original.iter().zip(loaded.iter()).enumerate() {
378 assert_eq!(
379 format!("{orig:?}"),
380 format!("{load:?}"),
381 "FALSIFY-SESSION-001: message {i} roundtrip mismatch"
382 );
383 }
384 let _ = fs::remove_dir_all(&store.dir);
385 }
386
387 #[test]
388 fn falsify_session_002_resume_preserves_messages() {
389 let store = create_test_store();
390 store.append_message(&Message::User("turn1".into())).expect("append");
391 store.append_message(&Message::Assistant("reply1".into())).expect("append");
392 store.append_message(&Message::User("turn2".into())).expect("append");
393
394 let manifest_json = fs::read_to_string(store.dir.join("manifest.json")).expect("read");
396 let manifest: SessionManifest = serde_json::from_str(&manifest_json).expect("parse");
397 let resumed = SessionStore { dir: store.dir.clone(), manifest };
398 let msgs = resumed.load_messages().expect("load");
399 assert_eq!(msgs.len(), 3, "FALSIFY-SESSION-002: all messages survive resume");
400 assert!(matches!(&msgs[2], Message::User(s) if s == "turn2"));
401 let _ = fs::remove_dir_all(&store.dir);
402 }
403
404 #[test]
405 fn falsify_session_003_manifest_serde_roundtrip() {
406 let manifest = SessionManifest {
407 id: "test-123".into(),
408 agent: "apr-code".into(),
409 cwd: "/home/user/project".into(),
410 created: "2026-04-04T12:00:00Z".into(),
411 turns: 5,
412 };
413 let json = serde_json::to_string(&manifest).expect("serialize");
414 let loaded: SessionManifest = serde_json::from_str(&json).expect("deserialize");
415 assert_eq!(loaded.id, manifest.id, "FALSIFY-SESSION-003: id preserved");
416 assert_eq!(loaded.turns, manifest.turns, "FALSIFY-SESSION-003: turns preserved");
417 assert_eq!(loaded.cwd, manifest.cwd, "FALSIFY-SESSION-003: cwd preserved");
418 }
419
420 #[test]
421 fn falsify_session_004_age_filter_24h() {
422 let result = SessionStore::find_recent_for_cwd_within(std::time::Duration::ZERO);
426 assert!(
427 result.is_none(),
428 "FALSIFY-SESSION-004: zero max_age must return None (no session is 0s old)"
429 );
430
431 let _ = SessionStore::find_recent_for_cwd_within(std::time::Duration::from_secs(
434 365 * 24 * 3600,
435 ));
436 }
437
438 #[test]
439 fn falsify_session_005_unicode_roundtrip() {
440 let store = create_test_store();
443 let special_messages = vec![
444 Message::User("Hello \u{1F600} emoji".into()),
445 Message::Assistant("Line1\nLine2\nLine3".into()),
446 Message::User("Tabs\there\tand\tthere".into()),
447 Message::Assistant("Quotes: \"double\" and 'single'".into()),
448 Message::User("\u{00e9}\u{00e8}\u{00ea} accented".into()),
449 ];
450 store.append_messages(&special_messages).expect("append unicode");
451 let loaded = store.load_messages().expect("load unicode");
452 assert_eq!(
453 loaded.len(),
454 special_messages.len(),
455 "FALSIFY-SESSION-005: all unicode messages preserved"
456 );
457 for (i, (orig, load)) in special_messages.iter().zip(loaded.iter()).enumerate() {
458 assert_eq!(
459 format!("{orig:?}"),
460 format!("{load:?}"),
461 "FALSIFY-SESSION-005: unicode message {i} corrupted"
462 );
463 }
464 let _ = fs::remove_dir_all(&store.dir);
465 }
466
467 #[test]
468 fn falsify_session_006_append_only_monotonic() {
469 let store = create_test_store();
471 store.append_message(&Message::User("first".into())).expect("1");
472 let after_one = store.load_messages().expect("load1");
473 assert_eq!(after_one.len(), 1);
474
475 store.append_message(&Message::Assistant("second".into())).expect("2");
476 let after_two = store.load_messages().expect("load2");
477 assert_eq!(after_two.len(), 2);
478
479 store.append_message(&Message::User("third".into())).expect("3");
480 let after_three = store.load_messages().expect("load3");
481 assert_eq!(after_three.len(), 3);
482
483 assert_eq!(
485 format!("{:?}", after_two[0]),
486 format!("{:?}", after_three[0]),
487 "FALSIFY-SESSION-006: earlier messages must not change"
488 );
489 assert_eq!(
490 format!("{:?}", after_two[1]),
491 format!("{:?}", after_three[1]),
492 "FALSIFY-SESSION-006: earlier messages must not change"
493 );
494 let _ = fs::remove_dir_all(&store.dir);
495 }
496}