Skip to main content

llimorse_chat/
log.rs

1//! [`llimorse::ChatListener`] implementation to keep a log.
2
3use 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/// [`llimorse::ChatListener`] implementation to keep a log in a file.
21#[derive(Debug)]
22pub struct SessionLog {
23    /// Where to write the log output
24    output: Option<File>,
25}
26
27impl SessionLog {
28    /// Store the log in the given file.
29    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    /// Load the log from `load_from`.
41    ///
42    /// This log can be applied via [`llimorse::Agent::push_history()`].
43    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    /// Create a null log (i.e. does not store anything).
55    ///
56    /// This helps creating an `Agent<SessionLog>` that can both store a log or not.
57    pub fn null() -> Self {
58        SessionLog { output: None }
59    }
60
61    /// Log the given `message` to the output, raising errors.
62    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/// What a caller asked for with its `--resume` flag (or equivalent).
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum Resume {
90    /// No resume requested: start a fresh session.
91    Fresh,
92    /// Resume the newest non-empty session log in the session-logs directory.
93    Newest,
94    /// Resume the given session log file.
95    File(PathBuf),
96}
97
98#[cfg(feature = "clap")]
99impl Resume {
100    /// Create a clap value parser for Resume that handles optional file paths.
101    ///
102    /// This parser supports:
103    /// - `--resume` with no value -> `Resume::Newest`
104    /// - `--resume <path>` -> `Resume::File(path)`
105    pub fn value_parser() -> ResumeValueParser {
106        ResumeValueParser
107    }
108}
109
110#[cfg(feature = "clap")]
111/// Value parser for [`Resume`] that handles optional file paths in clap arguments.
112#[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/// Manages the storage and retrieval of session logs.
143///
144/// Creates the log file that the current run writes to, and loads the transcript of the session
145/// being resumed, if any.
146#[derive(Debug)]
147pub struct SessionManager {
148    /// The session log this run writes to (null when no session-logs directory was given).
149    pub log: SessionLog,
150    /// Transcript of the session being resumed; empty when starting fresh.
151    pub history: Vec<ChatMessage>,
152}
153
154impl SessionManager {
155    /// Create the session log for this run and, if requested, load the transcript of the session
156    /// to resume.
157    ///
158    /// The resume target is resolved *before* the new log file is created, so the file created by
159    /// this call can never be picked as the newest log.
160    ///
161    /// A `Resume::File` whose path is a bare file name (no directory part) and not found in the
162    /// current directory is looked up in the session-logs directory, if one was given, also
163    /// trying `<name>.jsonl` when the name has no extension.
164    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    /// Find the newest non-empty session log in `dir`, by modification time (ties broken by name,
195    /// which is timestamped).
196    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    /// Resolve the file to resume from.
228    ///
229    /// A bare file name (no directory part, i.e. no slash) that does not exist in the current
230    /// directory is looked up in the session-logs directory, if one was given, also trying
231    /// `<name>.jsonl` when the name has no extension. Anything else is used as-is: a path that
232    /// exists nowhere then fails with the natural load error, unless a directory was given and
233    /// the bare name is in neither place, which is its own error.
234    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    /// A bare file name: exactly one path component, so no directory part (no slash).
262    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    /// A session log file name, timestamped so that names sort chronologically.
268    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    /// A scratch directory for one test, removed on drop.
278    struct ScratchDir(PathBuf);
279
280    impl ScratchDir {
281        /// Create a fresh scratch directory under `$TMPDIR`.
282        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        /// The underlying path.
290        fn path(&self) -> &Path {
291            &self.0
292        }
293
294        /// Join a file name onto the scratch directory.
295        fn join(&self, file: &str) -> PathBuf {
296            self.0.join(file)
297        }
298
299        /// Write `content` to `file` inside the scratch directory.
300        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    /// File selection: `.jsonl` only, non-empty, regular files — by mtime.
312    #[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    /// A missing directory is an error, not a fresh start.
325    #[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    /// A directory without any usable log is an error.
334    #[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    /// With no resume requested and no directory, nothing is loaded.
345    #[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    /// `Resume::Newest` without a directory is an error.
352    #[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    /// A fresh session in a directory creates exactly one log file there.
362    #[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    /// Resuming a file loads its messages, and the fresh log is created
377    /// alongside the resumed one.
378    #[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        // The fresh log was created next to the resumed one.
391        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    /// A bare file name that is not in the current directory is looked up in the
399    /// session-logs directory.
400    #[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    /// A bare file name without an extension is looked up in the session-logs directory with
417    /// `.jsonl` appended.
418    #[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    /// A path with a directory part is used as-is: no lookup in the session-logs directory.
435    #[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        // The file exists only under the session-logs directory, at a path that does not exist
446        // in the current directory, so the load must fail rather than fall back.
447        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    /// A bare name in neither the current directory nor the session-logs directory is an error
456    /// that mentions both places.
457    #[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    /// A bare name without an extension, in neither place, is an error that names the `.jsonl`
471    /// candidate that was tried in the session-logs directory as well.
472    #[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    /// A bare name that is not found, with no session-logs directory, keeps the natural load
487    /// error.
488    #[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    /// `Resume::Newest` picks up the newest non-empty log in the directory.
496    #[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}