1use anyhow::{Result, anyhow};
4use chrono::Local;
5use llimorse::ChatListener;
6use llimorse::line_format::ChatMessage;
7use std::fs::{self, File, OpenOptions};
8use std::io::{BufRead, BufReader, Write};
9use std::path::{Component, Path, PathBuf};
10use std::time::SystemTime;
11use tracing::error;
12
13#[cfg(feature = "clap")]
14use clap::builder::{TypedValueParser, ValueParserFactory};
15#[cfg(feature = "clap")]
16use clap::error::Result as ClapResult;
17#[cfg(feature = "clap")]
18use clap::{Arg, Command};
19
20#[derive(Debug)]
22pub struct SessionLog {
23 output: Option<File>,
25}
26
27impl SessionLog {
28 pub fn new<P: AsRef<Path>>(file: P) -> Result<Self> {
30 Ok(SessionLog {
31 output: Some(
32 OpenOptions::new()
33 .create_new(true)
34 .append(true)
35 .open(file)?,
36 ),
37 })
38 }
39
40 pub fn load<P: AsRef<Path>>(load_from: P) -> Result<Vec<ChatMessage>> {
44 BufReader::new(File::open(load_from)?)
45 .lines()
46 .map(|line| -> Result<ChatMessage> {
47 let line = line.map_err(|err| anyhow!("Failed to read data: {err}"))?;
48 serde_json::from_str(&line)
49 .map_err(|err| anyhow!("Failed to parse data: {line}: {err}"))
50 })
51 .collect()
52 }
53
54 pub fn null() -> Self {
58 SessionLog { output: None }
59 }
60
61 fn do_log(&mut self, message: &llimorse::line_format::ChatMessage) -> Result<()> {
63 let Some(file) = &mut self.output else {
64 return Ok(());
65 };
66
67 let json = serde_json::to_string(message)
68 .map_err(|err| anyhow!("Failed to serialize {message:?}: {err}"))?;
69
70 file.write_all(json.as_bytes())?;
71 file.write_all(b"\n")?;
72 file.flush()?;
73
74 Ok(())
75 }
76}
77
78impl ChatListener for SessionLog {
79 fn log_message(&mut self, message: &ChatMessage) {
80 if let Err(err) = self.do_log(message) {
81 error!("Failed to log {message:?}: {err}; log will be discontinued from here");
82 self.output.take();
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum Resume {
90 Fresh,
92 Newest,
94 File(PathBuf),
96}
97
98#[cfg(feature = "clap")]
99impl Resume {
100 pub fn value_parser() -> ResumeValueParser {
106 ResumeValueParser
107 }
108}
109
110#[cfg(feature = "clap")]
111#[derive(Clone)]
113pub struct ResumeValueParser;
114
115#[cfg(feature = "clap")]
116impl TypedValueParser for ResumeValueParser {
117 type Value = Resume;
118
119 fn parse_ref(
120 &self,
121 _cmd: &Command,
122 _arg: Option<&Arg>,
123 value: &std::ffi::OsStr,
124 ) -> ClapResult<Self::Value> {
125 if value.is_empty() {
126 Ok(Resume::Newest)
127 } else {
128 Ok(Resume::File(PathBuf::from(value)))
129 }
130 }
131}
132
133#[cfg(feature = "clap")]
134impl ValueParserFactory for Resume {
135 type Parser = ResumeValueParser;
136
137 fn value_parser() -> Self::Parser {
138 ResumeValueParser
139 }
140}
141
142#[derive(Debug)]
147pub struct SessionManager {
148 pub log: SessionLog,
150 pub history: Vec<ChatMessage>,
152}
153
154impl SessionManager {
155 pub fn new(session_logs: Option<&Path>, resume: &Resume) -> Result<Self> {
165 let history = match resume {
166 Resume::Fresh => Vec::new(),
167 Resume::Newest => {
168 let dir = session_logs.ok_or_else(|| {
169 anyhow!(
170 "No session log to resume: picking the newest log \
171 requires a session-logs directory, but none was given"
172 )
173 })?;
174 let file = Self::newest_log(dir)?;
175 SessionLog::load(&file).map_err(|err| anyhow!("{}: {err}", file.display()))?
176 }
177 Resume::File(file) => {
178 let file = Self::resolve_resume_file(file, session_logs)?;
179 SessionLog::load(&file).map_err(|err| anyhow!("{}: {err}", file.display()))?
180 }
181 };
182
183 let log = match session_logs {
184 Some(dir) => {
185 let path = dir.join(Self::new_log_name());
186 SessionLog::new(&path).map_err(|err| anyhow!("{}: {err}", path.display()))?
187 }
188 None => SessionLog::null(),
189 };
190
191 Ok(SessionManager { log, history })
192 }
193
194 fn newest_log(dir: &Path) -> Result<PathBuf> {
197 let mut newest: Option<(SystemTime, PathBuf)> = None;
198 for entry in fs::read_dir(dir).map_err(|err| anyhow!("{}: {err}", dir.display()))? {
199 let entry = entry.map_err(|err| anyhow!("{}: {err}", dir.display()))?;
200 let path = entry.path();
201 if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
202 continue;
203 }
204 let meta = entry
205 .metadata()
206 .map_err(|err| anyhow!("{}: {err}", path.display()))?;
207 if !meta.is_file() || meta.len() == 0 {
208 continue;
209 }
210 let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
211 let supersedes = match &newest {
212 None => true,
213 Some((time, name)) => mtime > *time || (mtime == *time && path > *name),
214 };
215 if supersedes {
216 newest = Some((mtime, path));
217 }
218 }
219 newest.map(|(_, path)| path).ok_or_else(|| {
220 anyhow!(
221 "No non-empty session log found in {} to resume",
222 dir.display()
223 )
224 })
225 }
226
227 fn resolve_resume_file(file: &Path, session_logs: Option<&Path>) -> Result<PathBuf> {
235 if file.exists() || !Self::is_bare_name(file) {
236 return Ok(file.to_path_buf());
237 }
238 let Some(dir) = session_logs else {
239 return Ok(file.to_path_buf());
240 };
241 let mut candidates = vec![dir.join(file)];
242 if !file.to_string_lossy().ends_with(".jsonl") {
243 candidates.push(dir.join(format!("{}.jsonl", file.display())));
244 }
245 for candidate in &candidates {
246 if candidate.exists() {
247 return Ok(candidate.clone());
248 }
249 }
250 let mut names = vec![file.display().to_string()];
251 if let Some(candidate) = candidates.get(1) {
252 names.push(candidate.display().to_string());
253 }
254 Err(anyhow!(
255 "Session log {} not found (looked in the current directory and in {})",
256 names.join(" or "),
257 dir.display()
258 ))
259 }
260
261 fn is_bare_name(file: &Path) -> bool {
263 let mut components = file.components();
264 matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
265 }
266
267 fn new_log_name() -> String {
269 Local::now().format("%Y-%m-%dT%H_%M_%S.jsonl").to_string()
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 struct ScratchDir(PathBuf);
279
280 impl ScratchDir {
281 fn new(name: &str) -> Self {
283 let dir =
284 std::env::temp_dir().join(format!("session-manager-{name}-{}", std::process::id()));
285 fs::create_dir_all(&dir).unwrap();
286 ScratchDir(dir)
287 }
288
289 fn path(&self) -> &Path {
291 &self.0
292 }
293
294 fn join(&self, file: &str) -> PathBuf {
296 self.0.join(file)
297 }
298
299 fn write(&self, file: &str, content: &str) {
301 fs::write(self.join(file), content).unwrap();
302 }
303 }
304
305 impl Drop for ScratchDir {
306 fn drop(&mut self) {
307 let _ = fs::remove_dir_all(&self.0);
308 }
309 }
310
311 #[test]
313 fn newest_log_picks_the_jsonl_file_and_skips_empty_and_foreign_files() {
314 let dir = ScratchDir::new("pick");
315 dir.write("2026-01-01T00_00_00.jsonl", "not-empty\n");
316 dir.write("2026-01-02T00_00_00.jsonl", "");
317 dir.write("notes.txt", "not a session log\n");
318 fs::create_dir(dir.join("2026-01-03T00_00_00.jsonl")).unwrap();
319
320 let newest = SessionManager::newest_log(dir.path()).unwrap();
321 assert_eq!(newest, dir.join("2026-01-01T00_00_00.jsonl"));
322 }
323
324 #[test]
326 fn newest_log_errors_on_a_missing_directory() {
327 let dir = ScratchDir::new("missing");
328 let missing = dir.join("does-not-exist");
329 let err = SessionManager::newest_log(&missing).unwrap_err();
330 assert!(err.to_string().contains("does-not-exist"));
331 }
332
333 #[test]
335 fn newest_log_errors_when_no_log_is_usable() {
336 let dir = ScratchDir::new("empty");
337 dir.write("2026-01-01T00_00_00.jsonl", "");
338 dir.write("notes.txt", "hello\n");
339
340 let err = SessionManager::newest_log(dir.path()).unwrap_err();
341 assert!(err.to_string().contains("No non-empty session log"));
342 }
343
344 #[test]
346 fn fresh_with_no_directory_creates_nothing() {
347 let manager = SessionManager::new(None, &Resume::Fresh).unwrap();
348 assert!(manager.history.is_empty());
349 }
350
351 #[test]
353 fn newest_without_a_directory_is_an_error() {
354 let err = SessionManager::new(None, &Resume::Newest).unwrap_err();
355 assert!(
356 err.to_string()
357 .contains("requires a session-logs directory")
358 );
359 }
360
361 #[test]
363 fn fresh_with_a_directory_creates_a_log_file() {
364 let dir = ScratchDir::new("fresh");
365 let manager = SessionManager::new(Some(dir.path()), &Resume::Fresh).unwrap();
366 assert!(manager.history.is_empty());
367
368 let files: Vec<_> = fs::read_dir(dir.path())
369 .unwrap()
370 .map(|entry| entry.unwrap().file_name())
371 .collect();
372 assert_eq!(files.len(), 1);
373 assert!(files[0].to_string_lossy().ends_with(".jsonl"));
374 }
375
376 #[test]
379 fn resume_a_file_loads_its_messages() {
380 let dir = ScratchDir::new("resume-file");
381 dir.write(
382 "2026-01-01T00_00_00.jsonl",
383 "{\"role\":\"user\",\"content\":\"hi\"}\n",
384 );
385 let old = dir.join("2026-01-01T00_00_00.jsonl");
386
387 let manager = SessionManager::new(Some(dir.path()), &Resume::File(old)).unwrap();
388 assert_eq!(manager.history.len(), 1);
389
390 let files: Vec<_> = fs::read_dir(dir.path())
392 .unwrap()
393 .map(|entry| entry.unwrap().file_name())
394 .collect();
395 assert_eq!(files.len(), 2);
396 }
397
398 #[test]
401 fn bare_resume_name_falls_back_to_the_session_logs_dir() {
402 let dir = ScratchDir::new("fallback");
403 dir.write(
404 "session-bare.jsonl",
405 "{\"role\":\"user\",\"content\":\"hi\"}\n",
406 );
407
408 let manager = SessionManager::new(
409 Some(dir.path()),
410 &Resume::File(PathBuf::from("session-bare.jsonl")),
411 )
412 .unwrap();
413 assert_eq!(manager.history.len(), 1);
414 }
415
416 #[test]
419 fn bare_resume_name_without_an_extension_tries_jsonl_in_the_session_logs_dir() {
420 let dir = ScratchDir::new("jsonl-fallback");
421 dir.write(
422 "session-bare.jsonl",
423 "{\"role\":\"user\",\"content\":\"hi\"}\n",
424 );
425
426 let manager = SessionManager::new(
427 Some(dir.path()),
428 &Resume::File(PathBuf::from("session-bare")),
429 )
430 .unwrap();
431 assert_eq!(manager.history.len(), 1);
432 }
433
434 #[test]
436 fn resume_path_with_a_directory_part_is_used_as_is() {
437 let dir = ScratchDir::new("no-fallback");
438 fs::create_dir(dir.join("inner")).unwrap();
439 fs::write(
440 dir.join("inner").join("session-bare.jsonl"),
441 "{\"role\":\"user\",\"content\":\"hi\"}\n",
442 )
443 .unwrap();
444
445 let err = SessionManager::new(
448 Some(dir.path()),
449 &Resume::File(PathBuf::from("inner/session-bare.jsonl")),
450 )
451 .unwrap_err();
452 assert!(err.to_string().contains("inner/session-bare.jsonl"));
453 }
454
455 #[test]
458 fn bare_resume_name_in_neither_place_is_an_error() {
459 let dir = ScratchDir::new("nowhere");
460 let err = SessionManager::new(
461 Some(dir.path()),
462 &Resume::File(PathBuf::from("no-such-session.jsonl")),
463 )
464 .unwrap_err();
465 let msg = err.to_string();
466 assert!(msg.contains("no-such-session.jsonl"));
467 assert!(msg.contains(&*dir.path().to_string_lossy()));
468 }
469
470 #[test]
473 fn bare_resume_name_without_an_extension_in_neither_place_is_an_error() {
474 let dir = ScratchDir::new("nowhere-jsonl");
475 let err = SessionManager::new(
476 Some(dir.path()),
477 &Resume::File(PathBuf::from("no-such-session")),
478 )
479 .unwrap_err();
480 let msg = err.to_string();
481 assert!(msg.contains("no-such-session"));
482 assert!(msg.contains("no-such-session.jsonl"));
483 assert!(msg.contains(&*dir.path().to_string_lossy()));
484 }
485
486 #[test]
489 fn bare_resume_name_without_a_session_logs_dir_is_an_error() {
490 let err = SessionManager::new(None, &Resume::File(PathBuf::from("no-such-session.jsonl")))
491 .unwrap_err();
492 assert!(err.to_string().contains("no-such-session.jsonl"));
493 }
494
495 #[test]
497 fn newest_resumes_the_newest_log() {
498 let dir = ScratchDir::new("newest");
499 dir.write(
500 "2026-01-01T00_00_00.jsonl",
501 "{\"role\":\"user\",\"content\":\"old\"}\n",
502 );
503 dir.write(
504 "2026-01-02T00_00_00.jsonl",
505 "{\"role\":\"user\",\"content\":\"new\"}\n",
506 );
507
508 let manager = SessionManager::new(Some(dir.path()), &Resume::Newest).unwrap();
509 assert_eq!(manager.history.len(), 1);
510 }
511}