Skip to main content

claude_wrapper/
history.rs

1//! Read-side access to Claude Code's on-disk session history.
2//!
3//! Claude Code stores per-project session logs as line-delimited
4//! JSON under `~/.claude/projects/<slug>/<session_id>.jsonl`, with
5//! one JSON object per line. This module gives a typed Rust API
6//! over those logs without prescribing a representation for the
7//! conversation -- consumers (UIs, MCP servers, tools) can render
8//! however they want.
9//!
10//! Three levels of granularity:
11//!
12//! - [`HistoryRoot::list_projects`] -- enumerate project directories
13//!   with summary metadata (session count, latest activity).
14//! - [`HistoryRoot::list_sessions`] -- enumerate session files for
15//!   one project (or all projects), with summary metadata
16//!   (message count, first/last timestamps, optional auto-title).
17//! - [`HistoryRoot::read_session`] -- parse a session into typed
18//!   [`HistoryEntry`] values.
19//!
20//! # Liberal parsing
21//!
22//! Each line is parsed independently; malformed lines are skipped
23//! (with a tracing warning) rather than failing the whole session.
24//! Unknown entry types come through as [`HistoryEntry::Other`]
25//! carrying the raw [`serde_json::Value`] so callers can inspect
26//! them. The shape Claude Code writes today includes at least
27//! `user`, `assistant`, `queue-operation`, `attachment`, `ai-title`,
28//! `last-prompt` -- only `user` and `assistant` get typed variants;
29//! the rest land in [`HistoryEntry::Other`].
30//!
31//! # Slug encoding
32//!
33//! Project directory names are filesystem-safe encodings of an
34//! absolute path -- e.g. `/Users/josh/Code/foo` becomes
35//! `-Users-josh-Code-foo`. [`ProjectSummary::decoded_path`] is a
36//! best-effort decode (replace leading dash with `/` and remaining
37//! dashes with `/`); it round-trips for paths that contain no
38//! literal dashes in directory names. For uncertain cases keep the
39//! `slug` and treat the decoded form as a hint.
40//!
41//! # Example
42//!
43//! ```no_run
44//! use claude_wrapper::history::HistoryRoot;
45//!
46//! # fn example() -> claude_wrapper::Result<()> {
47//! let root = HistoryRoot::home()?;
48//! for project in root.list_projects()? {
49//!     println!("{}: {} sessions", project.slug, project.session_count);
50//!     for session in root.list_sessions(Some(&project.slug))? {
51//!         println!("  {} ({} msgs)", session.session_id, session.message_count);
52//!     }
53//! }
54//! # Ok(()) }
55//! ```
56
57use std::fs;
58use std::io::{BufRead, BufReader};
59use std::path::{Path, PathBuf};
60use std::time::SystemTime;
61
62use serde::Serialize;
63use serde_json::Value;
64
65use crate::error::{Error, Result};
66
67/// Root directory of Claude Code's on-disk history. Defaults to
68/// `~/.claude/projects`; override with [`HistoryRoot::at`] for
69/// tests or non-default installs.
70#[derive(Debug, Clone)]
71pub struct HistoryRoot {
72    path: PathBuf,
73}
74
75/// Sort order for [`HistoryRoot::list_projects_with`] /
76/// [`HistoryRoot::list_sessions_with`].
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
78pub enum ListSort {
79    /// Sort by the on-disk identifier alphabetically: slug for
80    /// projects, session id for sessions. This is the default for
81    /// the zero-arg [`HistoryRoot::list_projects`] /
82    /// [`HistoryRoot::list_sessions`] methods to preserve the
83    /// historical behavior of the pre-pagination API.
84    #[default]
85    NameAsc,
86    /// Sort by most-recent activity, descending. For projects this
87    /// is `last_modified` (filesystem mtime). For sessions this is
88    /// `last_timestamp` (the last JSONL entry's `timestamp` field,
89    /// compared lexicographically -- which matches chronological
90    /// order for the ISO-8601 UTC strings Claude Code writes).
91    /// Items with `None` last-time end up at the tail.
92    RecencyDesc,
93}
94
95/// Filter + sort + paginate options for the listing methods.
96///
97/// `Default::default()` preserves the historical zero-arg behavior:
98/// no limit, no offset, name-ascending sort, and **`include_empty
99/// = true`** (everything is returned). Callers wanting paginated
100/// or filtered output -- the typical case for the new `_with`
101/// methods -- override the relevant fields explicitly.
102#[derive(Debug, Clone)]
103pub struct ListOptions {
104    /// Max items to return after sorting + offset. `None` = no cap.
105    pub limit: Option<usize>,
106    /// Skip the first N items after sorting. Used with `limit` for
107    /// pagination. `0` means "start from the first item."
108    pub offset: usize,
109    /// When `false`, drop entries with no real activity -- for
110    /// projects, `session_count == 0`; for sessions, `message_count
111    /// == 0` (the orphan stub files Claude Code sometimes leaves
112    /// behind when a session never produced a turn). Default `true`
113    /// so the zero-arg [`HistoryRoot::list_projects`] /
114    /// [`HistoryRoot::list_sessions`] methods preserve their
115    /// pre-pagination "include everything" behavior. New paginated
116    /// callers (e.g. an MCP tool layer) should set this to `false`
117    /// to hide orphan stub sessions and empty project directories.
118    pub include_empty: bool,
119    /// Sort order. See [`ListSort`].
120    pub sort: ListSort,
121}
122
123impl Default for ListOptions {
124    fn default() -> Self {
125        Self {
126            limit: None,
127            offset: 0,
128            include_empty: true,
129            sort: ListSort::default(),
130        }
131    }
132}
133
134impl HistoryRoot {
135    /// Resolve the default `~/.claude/projects`. Errors if `$HOME`
136    /// (or the platform-specific user home) cannot be determined.
137    pub fn home() -> Result<Self> {
138        let home = home_dir().ok_or_else(|| Error::History {
139            message: "could not determine user home directory".to_string(),
140        })?;
141        Ok(Self {
142            path: home.join(".claude").join("projects"),
143        })
144    }
145
146    /// Use a specific path as the projects root. Useful for tests
147    /// (point at a tempdir) and for non-default installs.
148    pub fn at(path: impl Into<PathBuf>) -> Self {
149        Self { path: path.into() }
150    }
151
152    /// The configured root directory.
153    pub fn path(&self) -> &Path {
154        &self.path
155    }
156
157    /// List every project directory at the root, sorted by slug.
158    ///
159    /// Convenience wrapper around [`Self::list_projects_with`] with
160    /// [`ListOptions::default`] (no limit, no offset, name-ascending
161    /// sort, includes empty projects). Existing callers keep their
162    /// behavior; new callers wanting pagination or recency sort
163    /// should use [`Self::list_projects_with`].
164    ///
165    /// Returns an empty vec if the root directory doesn't exist.
166    pub fn list_projects(&self) -> Result<Vec<ProjectSummary>> {
167        self.list_projects_with(&ListOptions::default())
168    }
169
170    /// List project directories with filter / sort / pagination.
171    ///
172    /// Reads every direct child directory of the root, summarizes
173    /// each, then applies (in order):
174    ///
175    /// 1. Filter out empty projects (`session_count == 0`) when
176    ///    `opts.include_empty` is `false`.
177    /// 2. Sort by `opts.sort` ([`ListSort::NameAsc`] by default,
178    ///    [`ListSort::RecencyDesc`] for "most recent first").
179    /// 3. Skip the first `opts.offset` items.
180    /// 4. Truncate to `opts.limit` items.
181    ///
182    /// Returns an empty vec if the root directory doesn't exist.
183    pub fn list_projects_with(&self, opts: &ListOptions) -> Result<Vec<ProjectSummary>> {
184        let entries = match fs::read_dir(&self.path) {
185            Ok(it) => it,
186            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
187            Err(e) => return Err(e.into()),
188        };
189
190        let mut out = Vec::new();
191        for entry in entries.flatten() {
192            let ft = match entry.file_type() {
193                Ok(ft) => ft,
194                Err(_) => continue,
195            };
196            if !ft.is_dir() {
197                continue;
198            }
199            let slug = entry.file_name().to_string_lossy().into_owned();
200            let summary = summarize_project(&entry.path(), slug);
201            if !opts.include_empty && summary.session_count == 0 {
202                continue;
203            }
204            out.push(summary);
205        }
206        match opts.sort {
207            ListSort::NameAsc => out.sort_by(|a, b| a.slug.cmp(&b.slug)),
208            ListSort::RecencyDesc => out.sort_by(|a, b| {
209                // None at the tail.
210                match (a.last_modified, b.last_modified) {
211                    (Some(am), Some(bm)) => bm.cmp(&am),
212                    (Some(_), None) => std::cmp::Ordering::Less,
213                    (None, Some(_)) => std::cmp::Ordering::Greater,
214                    (None, None) => a.slug.cmp(&b.slug),
215                }
216            }),
217        }
218        apply_offset_limit(&mut out, opts);
219        Ok(out)
220    }
221
222    /// List sessions, optionally filtered to one project's `slug`,
223    /// sorted by session id.
224    ///
225    /// Convenience wrapper around [`Self::list_sessions_with`] with
226    /// [`ListOptions::default`].
227    pub fn list_sessions(&self, slug: Option<&str>) -> Result<Vec<SessionSummary>> {
228        self.list_sessions_with(slug, &ListOptions::default())
229    }
230
231    /// List sessions with filter / sort / pagination.
232    ///
233    /// When `slug` is `Some`, only that project is walked. When
234    /// `None`, every project directory is unioned. The options
235    /// pipeline is the same as [`Self::list_projects_with`]:
236    /// filter empty (`message_count == 0`) sessions unless
237    /// `opts.include_empty`, sort, then offset + limit.
238    pub fn list_sessions_with(
239        &self,
240        slug: Option<&str>,
241        opts: &ListOptions,
242    ) -> Result<Vec<SessionSummary>> {
243        // Project enumeration here always wants every project (no
244        // pagination), because we'll paginate the merged sessions.
245        let enumerate_opts = ListOptions {
246            include_empty: true,
247            ..ListOptions::default()
248        };
249        let project_dirs = match slug {
250            Some(s) => vec![self.path.join(s)],
251            None => self
252                .list_projects_with(&enumerate_opts)?
253                .into_iter()
254                .map(|p| self.path.join(&p.slug))
255                .collect(),
256        };
257
258        let mut out = Vec::new();
259        for dir in project_dirs {
260            let project_slug = dir
261                .file_name()
262                .map(|n| n.to_string_lossy().into_owned())
263                .unwrap_or_default();
264            let entries = match fs::read_dir(&dir) {
265                Ok(it) => it,
266                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
267                Err(e) => return Err(e.into()),
268            };
269            for entry in entries.flatten() {
270                let path = entry.path();
271                if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
272                    continue;
273                }
274                let Some(session_id) = path
275                    .file_stem()
276                    .and_then(|s| s.to_str())
277                    .map(str::to_string)
278                else {
279                    continue;
280                };
281                if let Some(summary) = summarize_session(&path, session_id, project_slug.clone()) {
282                    if !opts.include_empty && summary.message_count == 0 {
283                        continue;
284                    }
285                    out.push(summary);
286                }
287            }
288        }
289        match opts.sort {
290            ListSort::NameAsc => out.sort_by(|a, b| a.session_id.cmp(&b.session_id)),
291            ListSort::RecencyDesc => out.sort_by(|a, b| {
292                // ISO 8601 UTC strings sort lexicographically by time.
293                // None at the tail.
294                match (a.last_timestamp.as_deref(), b.last_timestamp.as_deref()) {
295                    (Some(at), Some(bt)) => bt.cmp(at),
296                    (Some(_), None) => std::cmp::Ordering::Less,
297                    (None, Some(_)) => std::cmp::Ordering::Greater,
298                    (None, None) => a.session_id.cmp(&b.session_id),
299                }
300            }),
301        }
302        apply_offset_limit(&mut out, opts);
303        Ok(out)
304    }
305
306    /// Read one session's full entry log.
307    ///
308    /// Walks every project directory looking for `<session_id>.jsonl`.
309    /// Errors with [`Error::History`] if no session file matches.
310    /// Malformed lines are skipped with a tracing warning.
311    pub fn read_session(&self, session_id: &str) -> Result<SessionLog> {
312        let (path, project_slug) =
313            self.find_session(session_id)?
314                .ok_or_else(|| Error::History {
315                    message: format!(
316                        "no session with id `{session_id}` under {}",
317                        self.path.display()
318                    ),
319                })?;
320        parse_session(&path, session_id.to_string(), project_slug)
321    }
322
323    /// Locate the on-disk path for a session id, plus its project
324    /// slug. Returns `Ok(None)` if no such session exists. Useful
325    /// when a caller wants to read with non-default semantics
326    /// (streaming, tailing, etc.) without going through
327    /// [`Self::read_session`].
328    pub fn find_session(&self, session_id: &str) -> Result<Option<(PathBuf, String)>> {
329        for project in self.list_projects()? {
330            let candidate = self
331                .path
332                .join(&project.slug)
333                .join(format!("{session_id}.jsonl"));
334            if candidate.is_file() {
335                return Ok(Some((candidate, project.slug)));
336            }
337        }
338        Ok(None)
339    }
340}
341
342/// Summary of one project directory.
343#[derive(Debug, Clone, Serialize)]
344pub struct ProjectSummary {
345    /// On-disk directory name (the encoded path).
346    pub slug: String,
347    /// Best-effort decode of the slug back to a filesystem path.
348    /// See module docs for caveats.
349    pub decoded_path: PathBuf,
350    /// Number of `*.jsonl` files in the directory.
351    pub session_count: usize,
352    /// Latest filesystem modification time across the project's
353    /// session files. None if the directory is empty or stats fail.
354    pub last_modified: Option<SystemTime>,
355}
356
357/// Summary of one session's `.jsonl` file.
358#[derive(Debug, Clone, Serialize)]
359pub struct SessionSummary {
360    /// Filename stem -- the session UUID Claude Code assigned.
361    pub session_id: String,
362    /// The owning project's slug (directory name).
363    pub project_slug: String,
364    /// Count of `user` + `assistant` entries (excludes
365    /// queue-operation, attachment, ai-title, last-prompt, etc.).
366    pub message_count: usize,
367    /// First timestamp seen in the file (any entry type), as the
368    /// raw string Claude Code wrote.
369    pub first_timestamp: Option<String>,
370    /// Last timestamp seen.
371    pub last_timestamp: Option<String>,
372    /// Auto-generated title if Claude Code emitted an `ai-title`
373    /// entry; None otherwise.
374    pub title: Option<String>,
375    /// First ~160 chars of the first user message's text content,
376    /// flattened to a single line. Useful as a fallback display name
377    /// when `title` is None (which is most sessions today since
378    /// claude-code only writes ai-titles intermittently). None when
379    /// the session has no readable user message.
380    pub first_user_preview: Option<String>,
381    /// Sum of `message.usage.total_cost_usd` across every assistant
382    /// entry. Always None on current claude-code (the field is written
383    /// as `null`); kept in the shape so we can plumb it through if the
384    /// upstream behavior changes. Use `total_tokens` for a usage proxy.
385    pub total_cost_usd: Option<f64>,
386    /// Sum of input + output + cache tokens across every assistant
387    /// entry. None when the session has no assistant entries. Cheap to
388    /// derive from `message.usage`, which claude-code DOES write.
389    pub total_tokens: Option<u64>,
390    /// File size in bytes.
391    pub size_bytes: u64,
392}
393
394/// Full parsed session.
395#[derive(Debug, Clone, Serialize)]
396pub struct SessionLog {
397    pub session_id: String,
398    pub project_slug: String,
399    pub entries: Vec<HistoryEntry>,
400}
401
402/// One parsed line from a session `.jsonl`.
403///
404/// Only `user` and `assistant` entry types get typed variants;
405/// everything else (`queue-operation`, `attachment`, `ai-title`,
406/// `last-prompt`, future types) lands in [`Self::Other`] with the
407/// raw JSON for caller inspection.
408#[derive(Debug, Clone, Serialize)]
409#[serde(tag = "kind", rename_all = "snake_case")]
410pub enum HistoryEntry {
411    User {
412        uuid: Option<String>,
413        timestamp: Option<String>,
414        cwd: Option<String>,
415        git_branch: Option<String>,
416        message: Value,
417        #[serde(flatten)]
418        rest: serde_json::Map<String, Value>,
419    },
420    Assistant {
421        uuid: Option<String>,
422        timestamp: Option<String>,
423        message: Value,
424        #[serde(flatten)]
425        rest: serde_json::Map<String, Value>,
426    },
427    Other {
428        /// The `type` field as Claude Code wrote it.
429        type_tag: String,
430        /// The full raw entry.
431        raw: Value,
432    },
433}
434
435// -- helpers --------------------------------------------------------
436
437/// Apply offset + limit in-place to a sorted vec. Pulled out so the
438/// project and session list paths share the same pagination logic.
439fn apply_offset_limit<T>(items: &mut Vec<T>, opts: &ListOptions) {
440    if opts.offset >= items.len() {
441        items.clear();
442        return;
443    }
444    if opts.offset > 0 {
445        items.drain(..opts.offset);
446    }
447    if let Some(lim) = opts.limit
448        && items.len() > lim
449    {
450        items.truncate(lim);
451    }
452}
453
454fn summarize_project(dir: &Path, slug: String) -> ProjectSummary {
455    let mut session_count = 0usize;
456    let mut last_modified: Option<SystemTime> = None;
457    if let Ok(entries) = fs::read_dir(dir) {
458        for entry in entries.flatten() {
459            let path = entry.path();
460            if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
461                session_count += 1;
462                if let Ok(meta) = entry.metadata()
463                    && let Ok(mtime) = meta.modified()
464                {
465                    last_modified = Some(match last_modified {
466                        Some(prev) if prev > mtime => prev,
467                        _ => mtime,
468                    });
469                }
470            }
471        }
472    }
473    ProjectSummary {
474        decoded_path: decode_slug(&slug),
475        slug,
476        session_count,
477        last_modified,
478    }
479}
480
481fn summarize_session(
482    path: &Path,
483    session_id: String,
484    project_slug: String,
485) -> Option<SessionSummary> {
486    let meta = fs::metadata(path).ok()?;
487    let size_bytes = meta.len();
488
489    let file = fs::File::open(path).ok()?;
490    let reader = BufReader::new(file);
491
492    let mut message_count = 0usize;
493    let mut first_timestamp = None;
494    let mut last_timestamp = None;
495    let mut title = None;
496    let mut first_user_preview: Option<String> = None;
497    let mut total_cost_usd: Option<f64> = None;
498    let mut total_tokens: Option<u64> = None;
499
500    for line in reader.lines().map_while(std::io::Result::ok) {
501        let trimmed = line.trim();
502        if trimmed.is_empty() {
503            continue;
504        }
505        let v: Value = match serde_json::from_str(trimmed) {
506            Ok(v) => v,
507            Err(_) => continue,
508        };
509        let ty = v.get("type").and_then(Value::as_str).unwrap_or("");
510        match ty {
511            "user" => {
512                message_count += 1;
513                if first_user_preview.is_none()
514                    && let Some(p) = extract_user_text_preview(&v, 160)
515                {
516                    first_user_preview = Some(p);
517                }
518            }
519            "assistant" => {
520                message_count += 1;
521                if let Some(c) = v
522                    .get("message")
523                    .and_then(|m| m.get("usage"))
524                    .and_then(|u| u.get("total_cost_usd"))
525                    .and_then(Value::as_f64)
526                {
527                    *total_cost_usd.get_or_insert(0.0) += c;
528                }
529                if let Some(usage) = v.get("message").and_then(|m| m.get("usage")) {
530                    // Sum every token bucket so cache + non-cache both count.
531                    let mut t = 0u64;
532                    for k in [
533                        "input_tokens",
534                        "output_tokens",
535                        "cache_creation_input_tokens",
536                        "cache_read_input_tokens",
537                    ] {
538                        if let Some(n) = usage.get(k).and_then(Value::as_u64) {
539                            t += n;
540                        }
541                    }
542                    if t > 0 {
543                        *total_tokens.get_or_insert(0) += t;
544                    }
545                }
546            }
547            "ai-title" => {
548                // Claude Code writes this field as `aiTitle` (camelCase),
549                // not `title`. Read both for resilience against future
550                // renames -- whichever is present and non-empty wins.
551                let candidate = v
552                    .get("aiTitle")
553                    .and_then(Value::as_str)
554                    .or_else(|| v.get("title").and_then(Value::as_str));
555                if let Some(t) = candidate
556                    && !t.is_empty()
557                {
558                    title = Some(t.to_string());
559                }
560            }
561            _ => {}
562        }
563        if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
564            if first_timestamp.is_none() {
565                first_timestamp = Some(ts.to_string());
566            }
567            last_timestamp = Some(ts.to_string());
568        }
569    }
570
571    Some(SessionSummary {
572        session_id,
573        project_slug,
574        message_count,
575        first_timestamp,
576        last_timestamp,
577        title,
578        first_user_preview,
579        total_cost_usd,
580        total_tokens,
581        size_bytes,
582    })
583}
584
585/// Pull a single-line, truncated preview out of a user-entry JSON.
586/// Accepts both `message.content: "string"` and the structured form
587/// `message.content: [{type:"text", text:"..."}, ...]`. Skips entries
588/// where the first user "message" is actually a tool_result (those
589/// happen when claude-code resumes a session that was mid-tool).
590fn extract_user_text_preview(entry: &Value, max_chars: usize) -> Option<String> {
591    let content = entry.get("message")?.get("content")?;
592    let raw = if let Some(s) = content.as_str() {
593        s.to_string()
594    } else if let Some(arr) = content.as_array() {
595        let mut buf = String::new();
596        for block in arr {
597            let ty = block.get("type").and_then(Value::as_str).unwrap_or("");
598            if ty == "text"
599                && let Some(t) = block.get("text").and_then(Value::as_str)
600            {
601                if !buf.is_empty() {
602                    buf.push(' ');
603                }
604                buf.push_str(t);
605            }
606        }
607        buf
608    } else {
609        return None;
610    };
611    let one_line = raw
612        .split('\n')
613        .map(str::trim)
614        .filter(|l| !l.is_empty())
615        .collect::<Vec<_>>()
616        .join(" ");
617    if one_line.is_empty() {
618        return None;
619    }
620    let truncated: String = one_line.chars().take(max_chars).collect();
621    if truncated.len() < one_line.len() {
622        Some(format!("{truncated}..."))
623    } else {
624        Some(truncated)
625    }
626}
627
628fn parse_session(path: &Path, session_id: String, project_slug: String) -> Result<SessionLog> {
629    let file = fs::File::open(path)?;
630    let reader = BufReader::new(file);
631
632    let mut entries = Vec::new();
633    for (lineno, line) in reader.lines().enumerate() {
634        let line = match line {
635            Ok(l) => l,
636            Err(e) => {
637                tracing::warn!(
638                    path = %path.display(),
639                    line = lineno + 1,
640                    error = %e,
641                    "history: skipping unreadable line",
642                );
643                continue;
644            }
645        };
646        let trimmed = line.trim();
647        if trimmed.is_empty() {
648            continue;
649        }
650        match parse_entry(trimmed) {
651            Ok(entry) => entries.push(entry),
652            Err(e) => {
653                tracing::warn!(
654                    path = %path.display(),
655                    line = lineno + 1,
656                    error = %e,
657                    "history: skipping malformed line",
658                );
659            }
660        }
661    }
662    Ok(SessionLog {
663        session_id,
664        project_slug,
665        entries,
666    })
667}
668
669fn parse_entry(line: &str) -> std::result::Result<HistoryEntry, serde_json::Error> {
670    let mut value: Value = serde_json::from_str(line)?;
671    let ty = value
672        .get("type")
673        .and_then(Value::as_str)
674        .unwrap_or("")
675        .to_string();
676    match ty.as_str() {
677        "user" => Ok(HistoryEntry::User {
678            uuid: value.get("uuid").and_then(Value::as_str).map(String::from),
679            timestamp: value
680                .get("timestamp")
681                .and_then(Value::as_str)
682                .map(String::from),
683            cwd: value.get("cwd").and_then(Value::as_str).map(String::from),
684            git_branch: value
685                .get("gitBranch")
686                .and_then(Value::as_str)
687                .map(String::from),
688            message: value.get("message").cloned().unwrap_or(Value::Null),
689            rest: take_object(&mut value),
690        }),
691        "assistant" => Ok(HistoryEntry::Assistant {
692            uuid: value.get("uuid").and_then(Value::as_str).map(String::from),
693            timestamp: value
694                .get("timestamp")
695                .and_then(Value::as_str)
696                .map(String::from),
697            message: value.get("message").cloned().unwrap_or(Value::Null),
698            rest: take_object(&mut value),
699        }),
700        other => Ok(HistoryEntry::Other {
701            type_tag: other.to_string(),
702            raw: value,
703        }),
704    }
705}
706
707fn take_object(_value: &mut Value) -> serde_json::Map<String, Value> {
708    // Currently we don't bother carrying "everything else" through;
709    // callers needing the full raw form can re-read via Other or
710    // file-level access. Keeps the typed surface small. Reserved
711    // for future use if a typed-with-all-fields shape is wanted.
712    serde_json::Map::new()
713}
714
715fn decode_slug(slug: &str) -> PathBuf {
716    // Claude Code encodes paths by replacing `/` with `-`. The
717    // decode is best-effort: drop a leading `-` and replace
718    // remaining `-` with `/`. Paths containing literal dashes in
719    // directory names won't round-trip; that's a known limitation.
720    let body = slug.strip_prefix('-').unwrap_or(slug);
721    PathBuf::from(format!("/{}", body.replace('-', "/")))
722}
723
724fn home_dir() -> Option<PathBuf> {
725    // Avoid pulling the home crate just for this. $HOME on Unix,
726    // %USERPROFILE% on Windows -- both honored by std::env::var.
727    if let Ok(h) = std::env::var("HOME")
728        && !h.is_empty()
729    {
730        return Some(PathBuf::from(h));
731    }
732    if let Ok(h) = std::env::var("USERPROFILE")
733        && !h.is_empty()
734    {
735        return Some(PathBuf::from(h));
736    }
737    None
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use std::io::Write;
744
745    fn write_session(dir: &Path, session_id: &str, lines: &[&str]) -> PathBuf {
746        let path = dir.join(format!("{session_id}.jsonl"));
747        let mut f = fs::File::create(&path).expect("create jsonl");
748        for line in lines {
749            writeln!(f, "{line}").unwrap();
750        }
751        path
752    }
753
754    // Set the file mtime explicitly so recency-sort tests don't depend
755    // on filesystem mtime granularity (Linux ext4 ticks at 1s by
756    // default, so fixtures written back-to-back end up with identical
757    // mtimes and the sort is non-deterministic).
758    fn set_mtime(path: &Path, secs_since_epoch: u64) {
759        let f = fs::OpenOptions::new()
760            .write(true)
761            .open(path)
762            .expect("reopen for mtime");
763        let when = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs_since_epoch);
764        f.set_modified(when).expect("set mtime");
765    }
766
767    fn fixture_root() -> tempfile::TempDir {
768        let tmp = tempfile::tempdir().expect("tempdir");
769        // Project A: two sessions
770        let a = tmp.path().join("-Users-josh-Code-projA");
771        fs::create_dir_all(&a).unwrap();
772        write_session(
773            &a,
774            "session-aaa",
775            &[
776                r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"/Users/josh/Code/projA","gitBranch":"main","message":{"role":"user","content":"hello"}}"#,
777                r#"{"type":"assistant","uuid":"a1","timestamp":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":"hi"}}"#,
778                r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-01-01T00:00:02Z"}"#,
779                r#"{"type":"ai-title","aiTitle":"hello world"}"#,
780            ],
781        );
782        write_session(
783            &a,
784            "session-bbb",
785            &[
786                r#"{"type":"user","uuid":"u2","timestamp":"2026-01-02T00:00:00Z","message":{"role":"user","content":"second"}}"#,
787            ],
788        );
789        // Project B: one session, with one malformed line we'll skip
790        let b = tmp.path().join("-private-tmp-projB");
791        fs::create_dir_all(&b).unwrap();
792        write_session(
793            &b,
794            "session-ccc",
795            &[
796                r#"{"type":"user","uuid":"u3","timestamp":"2026-02-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
797                r#"NOT VALID JSON"#,
798                r#"{"type":"assistant","uuid":"a3","timestamp":"2026-02-01T00:00:01Z","message":{"role":"assistant","content":"y"}}"#,
799            ],
800        );
801        tmp
802    }
803
804    #[test]
805    fn list_projects_returns_directories_sorted_by_slug() {
806        let tmp = fixture_root();
807        let root = HistoryRoot::at(tmp.path());
808        let projects = root.list_projects().expect("list projects");
809        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
810        assert_eq!(slugs, ["-Users-josh-Code-projA", "-private-tmp-projB"]);
811    }
812
813    #[test]
814    fn list_projects_counts_sessions() {
815        let tmp = fixture_root();
816        let root = HistoryRoot::at(tmp.path());
817        let projects = root.list_projects().expect("list");
818        let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
819        let b = projects.iter().find(|p| p.slug.contains("projB")).unwrap();
820        assert_eq!(a.session_count, 2);
821        assert_eq!(b.session_count, 1);
822    }
823
824    #[test]
825    fn list_projects_decodes_slug_to_filesystem_path() {
826        let tmp = fixture_root();
827        let root = HistoryRoot::at(tmp.path());
828        let projects = root.list_projects().expect("list");
829        let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
830        assert_eq!(a.decoded_path, PathBuf::from("/Users/josh/Code/projA"));
831    }
832
833    #[test]
834    fn list_projects_returns_empty_when_root_missing() {
835        let tmp = tempfile::tempdir().unwrap();
836        let root = HistoryRoot::at(tmp.path().join("does-not-exist"));
837        let projects = root.list_projects().expect("ok");
838        assert!(projects.is_empty());
839    }
840
841    #[test]
842    fn list_sessions_filtered_by_slug() {
843        let tmp = fixture_root();
844        let root = HistoryRoot::at(tmp.path());
845        let sessions = root
846            .list_sessions(Some("-Users-josh-Code-projA"))
847            .expect("list");
848        let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
849        assert_eq!(ids, ["session-aaa", "session-bbb"]);
850        assert!(
851            sessions
852                .iter()
853                .all(|s| s.project_slug == "-Users-josh-Code-projA")
854        );
855    }
856
857    #[test]
858    fn list_sessions_unfiltered_returns_union() {
859        let tmp = fixture_root();
860        let root = HistoryRoot::at(tmp.path());
861        let sessions = root.list_sessions(None).expect("list");
862        assert_eq!(sessions.len(), 3);
863    }
864
865    #[test]
866    fn session_summary_counts_only_user_and_assistant() {
867        let tmp = fixture_root();
868        let root = HistoryRoot::at(tmp.path());
869        let sessions = root.list_sessions(Some("-Users-josh-Code-projA")).unwrap();
870        let aaa = sessions
871            .iter()
872            .find(|s| s.session_id == "session-aaa")
873            .unwrap();
874        // 2 message entries (user + assistant); queue-operation and ai-title don't count.
875        assert_eq!(aaa.message_count, 2);
876        assert_eq!(aaa.title.as_deref(), Some("hello world"));
877        assert_eq!(aaa.first_timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
878    }
879
880    #[test]
881    fn read_session_returns_typed_entries_and_skips_malformed_lines() {
882        let tmp = fixture_root();
883        let root = HistoryRoot::at(tmp.path());
884        let log = root.read_session("session-ccc").expect("read");
885        assert_eq!(log.session_id, "session-ccc");
886        assert_eq!(log.project_slug, "-private-tmp-projB");
887        // 3 lines in the file; 1 is malformed; expect 2 entries.
888        assert_eq!(log.entries.len(), 2);
889        assert!(matches!(log.entries[0], HistoryEntry::User { .. }));
890        assert!(matches!(log.entries[1], HistoryEntry::Assistant { .. }));
891    }
892
893    #[test]
894    fn read_session_user_entry_carries_metadata() {
895        let tmp = fixture_root();
896        let root = HistoryRoot::at(tmp.path());
897        let log = root.read_session("session-aaa").expect("read");
898        match &log.entries[0] {
899            HistoryEntry::User {
900                uuid,
901                timestamp,
902                cwd,
903                git_branch,
904                ..
905            } => {
906                assert_eq!(uuid.as_deref(), Some("u1"));
907                assert_eq!(timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
908                assert_eq!(cwd.as_deref(), Some("/Users/josh/Code/projA"));
909                assert_eq!(git_branch.as_deref(), Some("main"));
910            }
911            other => panic!("expected User entry, got {other:?}"),
912        }
913    }
914
915    #[test]
916    fn read_session_other_entry_preserves_type_tag_and_raw() {
917        let tmp = fixture_root();
918        let root = HistoryRoot::at(tmp.path());
919        let log = root.read_session("session-aaa").expect("read");
920        // Find the queue-operation entry.
921        let queue_op = log
922            .entries
923            .iter()
924            .find(|e| matches!(e, HistoryEntry::Other { type_tag, .. } if type_tag == "queue-operation"))
925            .expect("queue-operation entry");
926        if let HistoryEntry::Other { raw, .. } = queue_op {
927            assert_eq!(raw["operation"], "enqueue");
928        }
929    }
930
931    #[test]
932    fn read_session_unknown_id_errors() {
933        let tmp = fixture_root();
934        let root = HistoryRoot::at(tmp.path());
935        let err = root.read_session("not-a-real-session").unwrap_err();
936        assert!(matches!(err, Error::History { .. }));
937        assert!(format!("{err}").contains("no session with id"));
938    }
939
940    #[test]
941    fn find_session_returns_none_for_unknown_id() {
942        let tmp = fixture_root();
943        let root = HistoryRoot::at(tmp.path());
944        let found = root.find_session("nope").expect("ok");
945        assert!(found.is_none());
946    }
947
948    #[test]
949    fn find_session_locates_real_session() {
950        let tmp = fixture_root();
951        let root = HistoryRoot::at(tmp.path());
952        let (path, slug) = root
953            .find_session("session-ccc")
954            .expect("ok")
955            .expect("found");
956        assert!(path.ends_with("session-ccc.jsonl"));
957        assert_eq!(slug, "-private-tmp-projB");
958    }
959
960    #[test]
961    fn decode_slug_round_trips_simple_paths() {
962        assert_eq!(
963            decode_slug("-Users-josh-Code-foo"),
964            PathBuf::from("/Users/josh/Code/foo")
965        );
966        assert_eq!(decode_slug("-tmp-bar"), PathBuf::from("/tmp/bar"));
967    }
968
969    // -- ListOptions / pagination -----------------------------------
970
971    /// Build a fixture with five projects of varying activity so
972    /// recency sort and pagination have meaningful inputs.
973    fn paginated_fixture() -> tempfile::TempDir {
974        let tmp = tempfile::tempdir().unwrap();
975        // Two empty projects (no .jsonl files), three with one each.
976        for stem in ["-zzz-empty1", "-aaa-empty2"] {
977            fs::create_dir_all(tmp.path().join(stem)).unwrap();
978        }
979        for (stem, ts, mtime) in [
980            ("-bbb-proj", "2026-03-01T00:00:00Z", 1_700_000_000),
981            ("-ccc-proj", "2026-04-01T00:00:00Z", 1_700_001_000),
982            ("-ddd-proj", "2026-05-01T00:00:00Z", 1_700_002_000),
983        ] {
984            let dir = tmp.path().join(stem);
985            fs::create_dir_all(&dir).unwrap();
986            let session_path = write_session(
987                &dir,
988                "s1",
989                &[&format!(
990                    r#"{{"type":"user","uuid":"u","timestamp":"{ts}","message":{{"role":"user","content":"x"}}}}"#
991                )],
992            );
993            set_mtime(&session_path, mtime);
994        }
995        tmp
996    }
997
998    #[test]
999    fn list_projects_with_include_empty_false_filters_them_out() {
1000        let tmp = paginated_fixture();
1001        let root = HistoryRoot::at(tmp.path());
1002        let projects = root
1003            .list_projects_with(&ListOptions {
1004                include_empty: false,
1005                ..Default::default()
1006            })
1007            .expect("list");
1008        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1009        // Empty projects (-zzz-empty1 / -aaa-empty2) filtered out.
1010        assert_eq!(slugs, ["-bbb-proj", "-ccc-proj", "-ddd-proj"]);
1011    }
1012
1013    #[test]
1014    fn list_projects_with_default_includes_empty_for_bc() {
1015        // Default::default() must preserve legacy "include everything"
1016        // semantics so zero-arg list_projects() doesn't change behavior.
1017        let tmp = paginated_fixture();
1018        let root = HistoryRoot::at(tmp.path());
1019        let projects = root
1020            .list_projects_with(&ListOptions::default())
1021            .expect("list");
1022        assert_eq!(projects.len(), 5);
1023    }
1024
1025    #[test]
1026    fn list_projects_zero_arg_preserves_legacy_inclusion() {
1027        // The original list_projects() returned everything in slug order;
1028        // we must NOT regress that contract for existing callers.
1029        let tmp = paginated_fixture();
1030        let root = HistoryRoot::at(tmp.path());
1031        let projects = root.list_projects().expect("list");
1032        assert_eq!(projects.len(), 5);
1033        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1034        assert_eq!(
1035            slugs,
1036            [
1037                "-aaa-empty2",
1038                "-bbb-proj",
1039                "-ccc-proj",
1040                "-ddd-proj",
1041                "-zzz-empty1",
1042            ]
1043        );
1044    }
1045
1046    #[test]
1047    fn list_projects_with_limit_caps_results() {
1048        let tmp = paginated_fixture();
1049        let root = HistoryRoot::at(tmp.path());
1050        let projects = root
1051            .list_projects_with(&ListOptions {
1052                limit: Some(2),
1053                include_empty: true,
1054                ..Default::default()
1055            })
1056            .expect("list");
1057        assert_eq!(projects.len(), 2);
1058    }
1059
1060    #[test]
1061    fn list_projects_with_offset_skips() {
1062        let tmp = paginated_fixture();
1063        let root = HistoryRoot::at(tmp.path());
1064        let projects = root
1065            .list_projects_with(&ListOptions {
1066                offset: 3,
1067                include_empty: true,
1068                ..Default::default()
1069            })
1070            .expect("list");
1071        // NameAsc default; skipping 3 from [aaa, bbb, ccc, ddd, zzz]
1072        // leaves [ddd, zzz].
1073        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1074        assert_eq!(slugs, ["-ddd-proj", "-zzz-empty1"]);
1075    }
1076
1077    #[test]
1078    fn list_projects_with_offset_past_end_returns_empty() {
1079        let tmp = paginated_fixture();
1080        let root = HistoryRoot::at(tmp.path());
1081        let projects = root
1082            .list_projects_with(&ListOptions {
1083                offset: 99,
1084                include_empty: true,
1085                ..Default::default()
1086            })
1087            .expect("list");
1088        assert!(projects.is_empty());
1089    }
1090
1091    #[test]
1092    fn list_projects_with_recency_desc_sort() {
1093        let tmp = paginated_fixture();
1094        let root = HistoryRoot::at(tmp.path());
1095        // -ddd-proj has the newest session (May 2026), then -ccc, then -bbb.
1096        // The fixture writes them in order so filesystem mtimes also
1097        // progress. Filter empties so the tail isn't a no-mtime project.
1098        let projects = root
1099            .list_projects_with(&ListOptions {
1100                sort: ListSort::RecencyDesc,
1101                include_empty: false,
1102                ..Default::default()
1103            })
1104            .expect("list");
1105        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1106        assert_eq!(slugs, ["-ddd-proj", "-ccc-proj", "-bbb-proj"]);
1107    }
1108
1109    #[test]
1110    fn list_sessions_with_include_empty_false_filters_zero_message() {
1111        let tmp = tempfile::tempdir().unwrap();
1112        let dir = tmp.path().join("-proj");
1113        fs::create_dir_all(&dir).unwrap();
1114        // One real session.
1115        write_session(
1116            &dir,
1117            "real",
1118            &[
1119                r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1120            ],
1121        );
1122        // One orphan: just a queue-op, no user/assistant.
1123        write_session(
1124            &dir,
1125            "orphan",
1126            &[
1127                r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
1128            ],
1129        );
1130        let root = HistoryRoot::at(tmp.path());
1131        let sessions = root
1132            .list_sessions_with(
1133                Some("-proj"),
1134                &ListOptions {
1135                    include_empty: false,
1136                    ..Default::default()
1137                },
1138            )
1139            .expect("list");
1140        let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1141        assert_eq!(ids, ["real"]);
1142    }
1143
1144    #[test]
1145    fn list_sessions_with_default_returns_orphans_for_bc() {
1146        let tmp = tempfile::tempdir().unwrap();
1147        let dir = tmp.path().join("-proj");
1148        fs::create_dir_all(&dir).unwrap();
1149        write_session(
1150            &dir,
1151            "orphan",
1152            &[
1153                r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
1154            ],
1155        );
1156        let root = HistoryRoot::at(tmp.path());
1157        let sessions = root
1158            .list_sessions_with(Some("-proj"), &ListOptions::default())
1159            .expect("list");
1160        assert_eq!(sessions.len(), 1);
1161        assert_eq!(sessions[0].message_count, 0);
1162    }
1163
1164    #[test]
1165    fn list_sessions_with_recency_desc_sort() {
1166        let tmp = tempfile::tempdir().unwrap();
1167        let dir = tmp.path().join("-proj");
1168        fs::create_dir_all(&dir).unwrap();
1169        let old_p = write_session(
1170            &dir,
1171            "old",
1172            &[
1173                r#"{"type":"user","uuid":"u","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1174            ],
1175        );
1176        let new_p = write_session(
1177            &dir,
1178            "new",
1179            &[
1180                r#"{"type":"user","uuid":"u","timestamp":"2026-12-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1181            ],
1182        );
1183        let mid_p = write_session(
1184            &dir,
1185            "mid",
1186            &[
1187                r#"{"type":"user","uuid":"u","timestamp":"2026-06-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1188            ],
1189        );
1190        set_mtime(&old_p, 1_700_000_000);
1191        set_mtime(&mid_p, 1_700_001_000);
1192        set_mtime(&new_p, 1_700_002_000);
1193        let root = HistoryRoot::at(tmp.path());
1194        let sessions = root
1195            .list_sessions_with(
1196                Some("-proj"),
1197                &ListOptions {
1198                    sort: ListSort::RecencyDesc,
1199                    ..Default::default()
1200                },
1201            )
1202            .expect("list");
1203        let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1204        assert_eq!(ids, ["new", "mid", "old"]);
1205    }
1206
1207    #[test]
1208    fn list_sessions_with_limit_and_offset_combine() {
1209        let tmp = tempfile::tempdir().unwrap();
1210        let dir = tmp.path().join("-proj");
1211        fs::create_dir_all(&dir).unwrap();
1212        for i in 0..5 {
1213            write_session(
1214                &dir,
1215                &format!("s{i}"),
1216                &[&format!(
1217                    r#"{{"type":"user","uuid":"u","timestamp":"2026-01-0{i}T00:00:00Z","message":{{"role":"user","content":"x"}}}}"#
1218                )],
1219            );
1220        }
1221        let root = HistoryRoot::at(tmp.path());
1222        let sessions = root
1223            .list_sessions_with(
1224                Some("-proj"),
1225                &ListOptions {
1226                    offset: 1,
1227                    limit: Some(2),
1228                    ..Default::default()
1229                },
1230            )
1231            .expect("list");
1232        let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1233        // NameAsc default: ids are s0..s4; skip 1, take 2 → ["s1","s2"].
1234        assert_eq!(ids, ["s1", "s2"]);
1235    }
1236
1237    // -- aiTitle parsing bug fix ---------------------------------------
1238
1239    #[test]
1240    fn session_summary_parses_ai_title_camelcase() {
1241        // Real claude-code writes the title under `aiTitle`, not
1242        // `title`. Regression test for the field-name bug.
1243        let tmp = tempfile::tempdir().unwrap();
1244        let dir = tmp.path().join("-proj");
1245        fs::create_dir_all(&dir).unwrap();
1246        write_session(
1247            &dir,
1248            "real-shape",
1249            &[
1250                r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1251                r#"{"type":"ai-title","aiTitle":"My Session","sessionId":"real-shape"}"#,
1252            ],
1253        );
1254        let root = HistoryRoot::at(tmp.path());
1255        let sessions = root.list_sessions(Some("-proj")).expect("list");
1256        let s = sessions
1257            .iter()
1258            .find(|s| s.session_id == "real-shape")
1259            .unwrap();
1260        assert_eq!(s.title.as_deref(), Some("My Session"));
1261    }
1262
1263    #[test]
1264    fn session_summary_legacy_title_field_still_works() {
1265        // Older fixtures used `title`; we still accept it as a fallback.
1266        let tmp = tempfile::tempdir().unwrap();
1267        let dir = tmp.path().join("-proj");
1268        fs::create_dir_all(&dir).unwrap();
1269        write_session(
1270            &dir,
1271            "legacy",
1272            &[
1273                r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1274                r#"{"type":"ai-title","title":"Legacy Form"}"#,
1275            ],
1276        );
1277        let root = HistoryRoot::at(tmp.path());
1278        let sessions = root.list_sessions(Some("-proj")).expect("list");
1279        let s = sessions.iter().find(|s| s.session_id == "legacy").unwrap();
1280        assert_eq!(s.title.as_deref(), Some("Legacy Form"));
1281    }
1282}