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    /// Derive claude's project-directory slug for a filesystem path,
307    /// matching the CLI exactly: the path is **canonicalized**
308    /// (resolving symlinks -- e.g. `/var` -> `/private/var` on macOS,
309    /// `/tmp` on Linux) and then every `/` and `.` is encoded as `-`.
310    ///
311    /// This is the forward complement of
312    /// [`ProjectSummary::decoded_path`] and the reliable way to locate
313    /// the project directory for a working directory -- see
314    /// [`Self::sessions_for_path`]. Without the canonicalization and
315    /// the `.`-encoding, a cwd under a symlinked root, or containing a
316    /// `.` in a path segment, derives a slug that doesn't match what
317    /// claude wrote, so enumeration finds nothing.
318    ///
319    /// Falls back to the path as given when it cannot be canonicalized
320    /// (e.g. it does not exist on disk).
321    #[must_use]
322    pub fn project_slug(path: impl AsRef<Path>) -> String {
323        let path = path.as_ref();
324        let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
325        encode_path_slug(&canonical.to_string_lossy())
326    }
327
328    /// List sessions for a specific working directory, deriving its
329    /// project slug via [`Self::project_slug`].
330    ///
331    /// This is the current-project enumeration entry point: it
332    /// canonicalizes and encodes the cwd exactly as claude does, so
333    /// sessions written from symlinked roots (`/tmp`, `/var`) or dotted
334    /// path segments are found. Convenience over
335    /// `list_sessions(Some(&HistoryRoot::project_slug(cwd)))`.
336    pub fn sessions_for_path(&self, cwd: impl AsRef<Path>) -> Result<Vec<SessionSummary>> {
337        self.sessions_for_path_with(cwd, &ListOptions::default())
338    }
339
340    /// [`Self::sessions_for_path`] with explicit [`ListOptions`].
341    pub fn sessions_for_path_with(
342        &self,
343        cwd: impl AsRef<Path>,
344        opts: &ListOptions,
345    ) -> Result<Vec<SessionSummary>> {
346        let slug = Self::project_slug(cwd);
347        self.list_sessions_with(Some(&slug), opts)
348    }
349
350    /// Read one session's full entry log.
351    ///
352    /// Walks every project directory looking for `<session_id>.jsonl`.
353    /// Errors with [`Error::History`] if no session file matches.
354    /// Malformed lines are skipped with a tracing warning.
355    pub fn read_session(&self, session_id: &str) -> Result<SessionLog> {
356        let (path, project_slug) =
357            self.find_session(session_id)?
358                .ok_or_else(|| Error::History {
359                    message: format!(
360                        "no session with id `{session_id}` under {}",
361                        self.path.display()
362                    ),
363                })?;
364        parse_session(&path, session_id.to_string(), project_slug)
365    }
366
367    /// Locate the on-disk path for a session id, plus its project
368    /// slug. Returns `Ok(None)` if no such session exists. Useful
369    /// when a caller wants to read with non-default semantics
370    /// (streaming, tailing, etc.) without going through
371    /// [`Self::read_session`].
372    pub fn find_session(&self, session_id: &str) -> Result<Option<(PathBuf, String)>> {
373        for project in self.list_projects()? {
374            let candidate = self
375                .path
376                .join(&project.slug)
377                .join(format!("{session_id}.jsonl"));
378            if candidate.is_file() {
379                return Ok(Some((candidate, project.slug)));
380            }
381        }
382        Ok(None)
383    }
384}
385
386/// Summary of one project directory.
387#[derive(Debug, Clone, Serialize)]
388pub struct ProjectSummary {
389    /// On-disk directory name (the encoded path).
390    pub slug: String,
391    /// Best-effort decode of the slug back to a filesystem path.
392    /// See module docs for caveats.
393    pub decoded_path: PathBuf,
394    /// Whether `decoded_path` was verified against the real filesystem.
395    ///
396    /// `true` when the slug was disambiguated by checking `path.exists()` at
397    /// each segment boundary. `false` when no filesystem path matched during
398    /// decoding and the result is a naive `-`-to-`/` replacement.
399    ///
400    /// # Example
401    ///
402    /// ```rust
403    /// # use claude_wrapper::history::ProjectSummary;
404    /// // A real project: slug round-trips via filesystem check
405    /// // is_decode_verified == true when the actual directory exists
406    /// // is_decode_verified == false when decoding a slug for a path
407    /// //   that no longer exists on disk
408    /// ```
409    pub is_decode_verified: bool,
410    /// Number of `*.jsonl` files in the directory.
411    pub session_count: usize,
412    /// Latest filesystem modification time across the project's
413    /// session files. None if the directory is empty or stats fail.
414    pub last_modified: Option<SystemTime>,
415}
416
417/// Summary of one session's `.jsonl` file.
418#[derive(Debug, Clone, Serialize)]
419pub struct SessionSummary {
420    /// Filename stem -- the session UUID Claude Code assigned.
421    pub session_id: String,
422    /// The owning project's slug (directory name).
423    pub project_slug: String,
424    /// Count of `user` + `assistant` entries (excludes
425    /// queue-operation, attachment, ai-title, last-prompt, etc.).
426    pub message_count: usize,
427    /// First timestamp seen in the file (any entry type), as the
428    /// raw string Claude Code wrote.
429    pub first_timestamp: Option<String>,
430    /// Last timestamp seen.
431    pub last_timestamp: Option<String>,
432    /// Auto-generated title if Claude Code emitted an `ai-title`
433    /// entry; None otherwise.
434    pub title: Option<String>,
435    /// First ~160 chars of the first user message's text content,
436    /// flattened to a single line. Useful as a fallback display name
437    /// when `title` is None (which is most sessions today since
438    /// claude-code only writes ai-titles intermittently). None when
439    /// the session has no readable user message.
440    pub first_user_preview: Option<String>,
441    /// Sum of `message.usage.total_cost_usd` across every assistant
442    /// entry. Always None on current claude-code (the field is written
443    /// as `null`); kept in the shape so we can plumb it through if the
444    /// upstream behavior changes. Use `total_tokens` for a usage proxy.
445    pub total_cost_usd: Option<f64>,
446    /// Sum of input + output + cache tokens across every assistant
447    /// entry. None when the session has no assistant entries. Cheap to
448    /// derive from `message.usage`, which claude-code DOES write.
449    pub total_tokens: Option<u64>,
450    /// File size in bytes.
451    pub size_bytes: u64,
452}
453
454/// Full parsed session.
455#[derive(Debug, Clone, Serialize)]
456pub struct SessionLog {
457    /// The session id (the `.jsonl` file stem).
458    pub session_id: String,
459    /// Slug of the project the session belongs to.
460    pub project_slug: String,
461    /// Every parsed entry, in file order.
462    pub entries: Vec<HistoryEntry>,
463}
464
465/// One parsed line from a session `.jsonl`.
466///
467/// Only `user` and `assistant` entry types get typed variants;
468/// everything else (`queue-operation`, `attachment`, `ai-title`,
469/// `last-prompt`, future types) lands in [`Self::Other`] with the
470/// raw JSON for caller inspection.
471#[derive(Debug, Clone, Serialize)]
472#[serde(tag = "kind", rename_all = "snake_case")]
473pub enum HistoryEntry {
474    /// A `user` entry: a prompt turn written by the user.
475    User {
476        /// Entry uuid, when present.
477        uuid: Option<String>,
478        /// ISO-8601 timestamp, when present.
479        timestamp: Option<String>,
480        /// Working directory recorded for the turn, when present.
481        cwd: Option<String>,
482        /// Git branch recorded for the turn, when present.
483        git_branch: Option<String>,
484        /// The raw `message` payload as Claude Code wrote it.
485        message: Value,
486        /// Any additional fields not modeled above.
487        #[serde(flatten)]
488        rest: serde_json::Map<String, Value>,
489    },
490    /// An `assistant` entry: a model response turn.
491    Assistant {
492        /// Entry uuid, when present.
493        uuid: Option<String>,
494        /// ISO-8601 timestamp, when present.
495        timestamp: Option<String>,
496        /// The raw `message` payload as Claude Code wrote it.
497        message: Value,
498        /// Any additional fields not modeled above.
499        #[serde(flatten)]
500        rest: serde_json::Map<String, Value>,
501    },
502    /// Any other entry type, carried as raw JSON for caller inspection.
503    Other {
504        /// The `type` field as Claude Code wrote it.
505        type_tag: String,
506        /// The full raw entry.
507        raw: Value,
508    },
509}
510
511// -- helpers --------------------------------------------------------
512
513/// Apply offset + limit in-place to a sorted vec. Pulled out so the
514/// project and session list paths share the same pagination logic.
515fn apply_offset_limit<T>(items: &mut Vec<T>, opts: &ListOptions) {
516    if opts.offset >= items.len() {
517        items.clear();
518        return;
519    }
520    if opts.offset > 0 {
521        items.drain(..opts.offset);
522    }
523    if let Some(lim) = opts.limit
524        && items.len() > lim
525    {
526        items.truncate(lim);
527    }
528}
529
530fn summarize_project(dir: &Path, slug: String) -> ProjectSummary {
531    let mut session_count = 0usize;
532    let mut last_modified: Option<SystemTime> = None;
533    if let Ok(entries) = fs::read_dir(dir) {
534        for entry in entries.flatten() {
535            let path = entry.path();
536            if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
537                session_count += 1;
538                if let Ok(meta) = entry.metadata()
539                    && let Ok(mtime) = meta.modified()
540                {
541                    last_modified = Some(match last_modified {
542                        Some(prev) if prev > mtime => prev,
543                        _ => mtime,
544                    });
545                }
546            }
547        }
548    }
549    let (decoded_path, is_decode_verified) = decode_slug_anchored(&slug);
550    ProjectSummary {
551        decoded_path,
552        is_decode_verified,
553        slug,
554        session_count,
555        last_modified,
556    }
557}
558
559fn summarize_session(
560    path: &Path,
561    session_id: String,
562    project_slug: String,
563) -> Option<SessionSummary> {
564    let meta = fs::metadata(path).ok()?;
565    let size_bytes = meta.len();
566
567    let file = fs::File::open(path).ok()?;
568    let reader = BufReader::new(file);
569
570    let mut message_count = 0usize;
571    let mut first_timestamp = None;
572    let mut last_timestamp = None;
573    let mut title = None;
574    let mut first_user_preview: Option<String> = None;
575    let mut total_cost_usd: Option<f64> = None;
576    let mut total_tokens: Option<u64> = None;
577
578    for line in reader.lines().map_while(std::io::Result::ok) {
579        let trimmed = line.trim();
580        if trimmed.is_empty() {
581            continue;
582        }
583        let v: Value = match serde_json::from_str(trimmed) {
584            Ok(v) => v,
585            Err(_) => continue,
586        };
587        let ty = v.get("type").and_then(Value::as_str).unwrap_or("");
588        match ty {
589            "user" => {
590                message_count += 1;
591                if first_user_preview.is_none()
592                    && let Some(p) = extract_user_text_preview(&v, 160)
593                {
594                    first_user_preview = Some(p);
595                }
596            }
597            "assistant" => {
598                message_count += 1;
599                if let Some(c) = v
600                    .get("message")
601                    .and_then(|m| m.get("usage"))
602                    .and_then(|u| u.get("total_cost_usd"))
603                    .and_then(Value::as_f64)
604                {
605                    *total_cost_usd.get_or_insert(0.0) += c;
606                }
607                if let Some(usage) = v.get("message").and_then(|m| m.get("usage")) {
608                    // Sum every token bucket so cache + non-cache both count.
609                    let mut t = 0u64;
610                    for k in [
611                        "input_tokens",
612                        "output_tokens",
613                        "cache_creation_input_tokens",
614                        "cache_read_input_tokens",
615                    ] {
616                        if let Some(n) = usage.get(k).and_then(Value::as_u64) {
617                            t += n;
618                        }
619                    }
620                    if t > 0 {
621                        *total_tokens.get_or_insert(0) += t;
622                    }
623                }
624            }
625            "ai-title" => {
626                // Claude Code writes this field as `aiTitle` (camelCase),
627                // not `title`. Read both for resilience against future
628                // renames -- whichever is present and non-empty wins.
629                let candidate = v
630                    .get("aiTitle")
631                    .and_then(Value::as_str)
632                    .or_else(|| v.get("title").and_then(Value::as_str));
633                if let Some(t) = candidate
634                    && !t.is_empty()
635                {
636                    title = Some(t.to_string());
637                }
638            }
639            _ => {}
640        }
641        if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
642            if first_timestamp.is_none() {
643                first_timestamp = Some(ts.to_string());
644            }
645            last_timestamp = Some(ts.to_string());
646        }
647    }
648
649    Some(SessionSummary {
650        session_id,
651        project_slug,
652        message_count,
653        first_timestamp,
654        last_timestamp,
655        title,
656        first_user_preview,
657        total_cost_usd,
658        total_tokens,
659        size_bytes,
660    })
661}
662
663/// Pull a single-line, truncated preview out of a user-entry JSON.
664/// Accepts both `message.content: "string"` and the structured form
665/// `message.content: [{type:"text", text:"..."}, ...]`. Skips entries
666/// where the first user "message" is actually a tool_result (those
667/// happen when claude-code resumes a session that was mid-tool).
668fn extract_user_text_preview(entry: &Value, max_chars: usize) -> Option<String> {
669    let content = entry.get("message")?.get("content")?;
670    let raw = if let Some(s) = content.as_str() {
671        s.to_string()
672    } else if let Some(arr) = content.as_array() {
673        let mut buf = String::new();
674        for block in arr {
675            let ty = block.get("type").and_then(Value::as_str).unwrap_or("");
676            if ty == "text"
677                && let Some(t) = block.get("text").and_then(Value::as_str)
678            {
679                if !buf.is_empty() {
680                    buf.push(' ');
681                }
682                buf.push_str(t);
683            }
684        }
685        buf
686    } else {
687        return None;
688    };
689    let one_line = raw
690        .split('\n')
691        .map(str::trim)
692        .filter(|l| !l.is_empty())
693        .collect::<Vec<_>>()
694        .join(" ");
695    if one_line.is_empty() {
696        return None;
697    }
698    let truncated: String = one_line.chars().take(max_chars).collect();
699    if truncated.len() < one_line.len() {
700        Some(format!("{truncated}..."))
701    } else {
702        Some(truncated)
703    }
704}
705
706fn parse_session(path: &Path, session_id: String, project_slug: String) -> Result<SessionLog> {
707    let file = fs::File::open(path)?;
708    let reader = BufReader::new(file);
709
710    let mut entries = Vec::new();
711    for (lineno, line) in reader.lines().enumerate() {
712        let line = match line {
713            Ok(l) => l,
714            Err(e) => {
715                tracing::warn!(
716                    path = %path.display(),
717                    line = lineno + 1,
718                    error = %e,
719                    "history: skipping unreadable line",
720                );
721                continue;
722            }
723        };
724        let trimmed = line.trim();
725        if trimmed.is_empty() {
726            continue;
727        }
728        match parse_entry(trimmed) {
729            Ok(entry) => entries.push(entry),
730            Err(e) => {
731                tracing::warn!(
732                    path = %path.display(),
733                    line = lineno + 1,
734                    error = %e,
735                    "history: skipping malformed line",
736                );
737            }
738        }
739    }
740    Ok(SessionLog {
741        session_id,
742        project_slug,
743        entries,
744    })
745}
746
747fn parse_entry(line: &str) -> std::result::Result<HistoryEntry, serde_json::Error> {
748    let mut value: Value = serde_json::from_str(line)?;
749    let ty = value
750        .get("type")
751        .and_then(Value::as_str)
752        .unwrap_or("")
753        .to_string();
754    match ty.as_str() {
755        "user" => Ok(HistoryEntry::User {
756            uuid: value.get("uuid").and_then(Value::as_str).map(String::from),
757            timestamp: value
758                .get("timestamp")
759                .and_then(Value::as_str)
760                .map(String::from),
761            cwd: value.get("cwd").and_then(Value::as_str).map(String::from),
762            git_branch: value
763                .get("gitBranch")
764                .and_then(Value::as_str)
765                .map(String::from),
766            message: value.get("message").cloned().unwrap_or(Value::Null),
767            rest: take_object(&mut value),
768        }),
769        "assistant" => Ok(HistoryEntry::Assistant {
770            uuid: value.get("uuid").and_then(Value::as_str).map(String::from),
771            timestamp: value
772                .get("timestamp")
773                .and_then(Value::as_str)
774                .map(String::from),
775            message: value.get("message").cloned().unwrap_or(Value::Null),
776            rest: take_object(&mut value),
777        }),
778        other => Ok(HistoryEntry::Other {
779            type_tag: other.to_string(),
780            raw: value,
781        }),
782    }
783}
784
785fn take_object(_value: &mut Value) -> serde_json::Map<String, Value> {
786    // Currently we don't bother carrying "everything else" through;
787    // callers needing the full raw form can re-read via Other or
788    // file-level access. Keeps the typed surface small. Reserved
789    // for future use if a typed-with-all-fields shape is wanted.
790    serde_json::Map::new()
791}
792
793/// Decode a project slug back to a filesystem path, anchoring on the
794/// real filesystem to disambiguate literal hyphens in directory names.
795///
796/// Claude Code encodes an absolute path by replacing each
797/// non-alphanumeric character with `-` (see [`encode_path_slug`]). The
798/// naive inverse (replace every `-` with `/`) is ambiguous: a `-` in the
799/// slug could have been a `/`, `.`, `_`, space, or a literal hyphen in a
800/// directory name -- like `claude-wrapper` -- making it indistinguishable
801/// from a `/` boundary. This walks the slug left to right and, at each segment
802/// boundary, checks the filesystem to decide whether the boundary is a
803/// `/` (slash form) or a literal `-` (hyphen form).
804///
805/// Returns `(decoded_path, is_decode_verified)`. `is_decode_verified`
806/// is `true` when every boundary was resolved against an existing path
807/// and `false` when at least one boundary matched nothing on disk and
808/// fell back to the naive split.
809///
810/// Tiebreak: when both forms exist, the deeper hyphenated form wins.
811fn decode_slug_anchored(slug: &str) -> (PathBuf, bool) {
812    let body = slug.strip_prefix('-').unwrap_or(slug);
813    let mut segments = body.split('-');
814    let mut built_path = PathBuf::from("/");
815    let mut is_decode_verified = true;
816
817    // First segment seeds the current component. An empty slug yields
818    // an empty component and falls straight through to the final push.
819    let mut current_component = segments.next().unwrap_or("").to_string();
820
821    for next_segment in segments {
822        let hyphen_component = format!("{current_component}-{next_segment}");
823        let slash_exists = built_path.join(&current_component).exists();
824        let hyphen_exists = built_path.join(&hyphen_component).exists();
825
826        // Prefer the hyphen form whenever it exists (covers both the
827        // hyphen-only case and the both-exist tiebreak). Otherwise take
828        // the slash form, marking the decode unverified when neither
829        // form is backed by a real path.
830        if hyphen_exists {
831            current_component = hyphen_component;
832        } else {
833            if !slash_exists {
834                is_decode_verified = false;
835            }
836            built_path.push(&current_component);
837            current_component = next_segment.to_string();
838        }
839    }
840
841    built_path.push(&current_component);
842    (built_path, is_decode_verified)
843}
844
845/// Encode an absolute filesystem path into claude's project-directory
846/// slug: every non-alphanumeric character becomes `-` (so
847/// `/private/var/T/tmp.X` becomes `-private-var-T-tmp-X`, and
848/// `/Users/me/claude_wrapper` becomes `-Users-me-claude-wrapper`; the
849/// leading `/` yields the leading `-`). This matches the Claude Code
850/// CLI, which replaces every non-alphanumeric char -- including `_`,
851/// spaces, and other separators -- when building the project-directory
852/// name under `~/.claude/projects/`. Does not canonicalize -- see
853/// [`HistoryRoot::project_slug`], which canonicalizes first.
854fn encode_path_slug(path: &str) -> String {
855    path.chars()
856        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
857        .collect()
858}
859
860fn home_dir() -> Option<PathBuf> {
861    // Avoid pulling the home crate just for this. $HOME on Unix,
862    // %USERPROFILE% on Windows -- both honored by std::env::var.
863    if let Ok(h) = std::env::var("HOME")
864        && !h.is_empty()
865    {
866        return Some(PathBuf::from(h));
867    }
868    if let Ok(h) = std::env::var("USERPROFILE")
869        && !h.is_empty()
870    {
871        return Some(PathBuf::from(h));
872    }
873    None
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879    use std::io::Write;
880
881    fn write_session(dir: &Path, session_id: &str, lines: &[&str]) -> PathBuf {
882        let path = dir.join(format!("{session_id}.jsonl"));
883        let mut f = fs::File::create(&path).expect("create jsonl");
884        for line in lines {
885            writeln!(f, "{line}").unwrap();
886        }
887        path
888    }
889
890    // Set the file mtime explicitly so recency-sort tests don't depend
891    // on filesystem mtime granularity (Linux ext4 ticks at 1s by
892    // default, so fixtures written back-to-back end up with identical
893    // mtimes and the sort is non-deterministic).
894    fn set_mtime(path: &Path, secs_since_epoch: u64) {
895        let f = fs::OpenOptions::new()
896            .write(true)
897            .open(path)
898            .expect("reopen for mtime");
899        let when = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs_since_epoch);
900        f.set_modified(when).expect("set mtime");
901    }
902
903    fn fixture_root() -> tempfile::TempDir {
904        let tmp = tempfile::tempdir().expect("tempdir");
905        // Project A: two sessions
906        let a = tmp.path().join("-Users-josh-Code-projA");
907        fs::create_dir_all(&a).unwrap();
908        write_session(
909            &a,
910            "session-aaa",
911            &[
912                r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"/Users/josh/Code/projA","gitBranch":"main","message":{"role":"user","content":"hello"}}"#,
913                r#"{"type":"assistant","uuid":"a1","timestamp":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":"hi"}}"#,
914                r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-01-01T00:00:02Z"}"#,
915                r#"{"type":"ai-title","aiTitle":"hello world"}"#,
916            ],
917        );
918        write_session(
919            &a,
920            "session-bbb",
921            &[
922                r#"{"type":"user","uuid":"u2","timestamp":"2026-01-02T00:00:00Z","message":{"role":"user","content":"second"}}"#,
923            ],
924        );
925        // Project B: one session, with one malformed line we'll skip
926        let b = tmp.path().join("-private-tmp-projB");
927        fs::create_dir_all(&b).unwrap();
928        write_session(
929            &b,
930            "session-ccc",
931            &[
932                r#"{"type":"user","uuid":"u3","timestamp":"2026-02-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
933                r#"NOT VALID JSON"#,
934                r#"{"type":"assistant","uuid":"a3","timestamp":"2026-02-01T00:00:01Z","message":{"role":"assistant","content":"y"}}"#,
935            ],
936        );
937        tmp
938    }
939
940    #[test]
941    fn list_projects_returns_directories_sorted_by_slug() {
942        let tmp = fixture_root();
943        let root = HistoryRoot::at(tmp.path());
944        let projects = root.list_projects().expect("list projects");
945        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
946        assert_eq!(slugs, ["-Users-josh-Code-projA", "-private-tmp-projB"]);
947    }
948
949    #[test]
950    fn list_projects_counts_sessions() {
951        let tmp = fixture_root();
952        let root = HistoryRoot::at(tmp.path());
953        let projects = root.list_projects().expect("list");
954        let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
955        let b = projects.iter().find(|p| p.slug.contains("projB")).unwrap();
956        assert_eq!(a.session_count, 2);
957        assert_eq!(b.session_count, 1);
958    }
959
960    #[test]
961    fn list_projects_decodes_slug_to_filesystem_path() {
962        let tmp = fixture_root();
963        let root = HistoryRoot::at(tmp.path());
964        let projects = root.list_projects().expect("list");
965        let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
966        assert_eq!(a.decoded_path, PathBuf::from("/Users/josh/Code/projA"));
967    }
968
969    #[test]
970    fn list_projects_returns_empty_when_root_missing() {
971        let tmp = tempfile::tempdir().unwrap();
972        let root = HistoryRoot::at(tmp.path().join("does-not-exist"));
973        let projects = root.list_projects().expect("ok");
974        assert!(projects.is_empty());
975    }
976
977    #[test]
978    fn list_sessions_filtered_by_slug() {
979        let tmp = fixture_root();
980        let root = HistoryRoot::at(tmp.path());
981        let sessions = root
982            .list_sessions(Some("-Users-josh-Code-projA"))
983            .expect("list");
984        let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
985        assert_eq!(ids, ["session-aaa", "session-bbb"]);
986        assert!(
987            sessions
988                .iter()
989                .all(|s| s.project_slug == "-Users-josh-Code-projA")
990        );
991    }
992
993    #[test]
994    fn list_sessions_unfiltered_returns_union() {
995        let tmp = fixture_root();
996        let root = HistoryRoot::at(tmp.path());
997        let sessions = root.list_sessions(None).expect("list");
998        assert_eq!(sessions.len(), 3);
999    }
1000
1001    #[test]
1002    fn session_summary_counts_only_user_and_assistant() {
1003        let tmp = fixture_root();
1004        let root = HistoryRoot::at(tmp.path());
1005        let sessions = root.list_sessions(Some("-Users-josh-Code-projA")).unwrap();
1006        let aaa = sessions
1007            .iter()
1008            .find(|s| s.session_id == "session-aaa")
1009            .unwrap();
1010        // 2 message entries (user + assistant); queue-operation and ai-title don't count.
1011        assert_eq!(aaa.message_count, 2);
1012        assert_eq!(aaa.title.as_deref(), Some("hello world"));
1013        assert_eq!(aaa.first_timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
1014    }
1015
1016    #[test]
1017    fn read_session_returns_typed_entries_and_skips_malformed_lines() {
1018        let tmp = fixture_root();
1019        let root = HistoryRoot::at(tmp.path());
1020        let log = root.read_session("session-ccc").expect("read");
1021        assert_eq!(log.session_id, "session-ccc");
1022        assert_eq!(log.project_slug, "-private-tmp-projB");
1023        // 3 lines in the file; 1 is malformed; expect 2 entries.
1024        assert_eq!(log.entries.len(), 2);
1025        assert!(matches!(log.entries[0], HistoryEntry::User { .. }));
1026        assert!(matches!(log.entries[1], HistoryEntry::Assistant { .. }));
1027    }
1028
1029    #[test]
1030    fn read_session_user_entry_carries_metadata() {
1031        let tmp = fixture_root();
1032        let root = HistoryRoot::at(tmp.path());
1033        let log = root.read_session("session-aaa").expect("read");
1034        match &log.entries[0] {
1035            HistoryEntry::User {
1036                uuid,
1037                timestamp,
1038                cwd,
1039                git_branch,
1040                ..
1041            } => {
1042                assert_eq!(uuid.as_deref(), Some("u1"));
1043                assert_eq!(timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
1044                assert_eq!(cwd.as_deref(), Some("/Users/josh/Code/projA"));
1045                assert_eq!(git_branch.as_deref(), Some("main"));
1046            }
1047            other => panic!("expected User entry, got {other:?}"),
1048        }
1049    }
1050
1051    #[test]
1052    fn read_session_other_entry_preserves_type_tag_and_raw() {
1053        let tmp = fixture_root();
1054        let root = HistoryRoot::at(tmp.path());
1055        let log = root.read_session("session-aaa").expect("read");
1056        // Find the queue-operation entry.
1057        let queue_op = log
1058            .entries
1059            .iter()
1060            .find(|e| matches!(e, HistoryEntry::Other { type_tag, .. } if type_tag == "queue-operation"))
1061            .expect("queue-operation entry");
1062        if let HistoryEntry::Other { raw, .. } = queue_op {
1063            assert_eq!(raw["operation"], "enqueue");
1064        }
1065    }
1066
1067    #[test]
1068    fn read_session_unknown_id_errors() {
1069        let tmp = fixture_root();
1070        let root = HistoryRoot::at(tmp.path());
1071        let err = root.read_session("not-a-real-session").unwrap_err();
1072        assert!(matches!(err, Error::History { .. }));
1073        assert!(format!("{err}").contains("no session with id"));
1074    }
1075
1076    #[test]
1077    fn find_session_returns_none_for_unknown_id() {
1078        let tmp = fixture_root();
1079        let root = HistoryRoot::at(tmp.path());
1080        let found = root.find_session("nope").expect("ok");
1081        assert!(found.is_none());
1082    }
1083
1084    #[test]
1085    fn find_session_locates_real_session() {
1086        let tmp = fixture_root();
1087        let root = HistoryRoot::at(tmp.path());
1088        let (path, slug) = root
1089            .find_session("session-ccc")
1090            .expect("ok")
1091            .expect("found");
1092        assert!(path.ends_with("session-ccc.jsonl"));
1093        assert_eq!(slug, "-private-tmp-projB");
1094    }
1095
1096    #[test]
1097    fn decode_slug_anchored_no_hyphens_in_components() {
1098        // Path with no literal hyphens -- both forms are structurally
1099        // identical at each boundary, so the algorithm picks the slash
1100        // (naive) form at each step. `is_decode_verified` depends on
1101        // whether /a/b/c/d exists; in CI it won't, so only assert shape.
1102        let (path, _verified) = decode_slug_anchored("-a-b-c-d");
1103        assert_eq!(path, PathBuf::from("/a/b/c/d"));
1104    }
1105
1106    #[test]
1107    fn decode_slug_anchored_single_hyphenated_segment() {
1108        // Build a real dir: tmp/foo-bar, then construct its slug.
1109        let tmp = tempfile::tempdir().unwrap();
1110        let dir = tmp.path().join("foo-bar");
1111        fs::create_dir_all(&dir).unwrap();
1112        let tmp_str = tmp.path().to_string_lossy();
1113        let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1114        let slug = format!("-{tmp_encoded}-foo-bar");
1115        let expected = tmp.path().join("foo-bar");
1116        let (decoded, is_verified) = decode_slug_anchored(&slug);
1117        assert_eq!(decoded, expected);
1118        assert!(is_verified);
1119    }
1120
1121    #[test]
1122    fn decode_slug_anchored_multiple_hyphenated_segments() {
1123        // Build: tmp/foo-bar/baz-qux
1124        let tmp = tempfile::tempdir().unwrap();
1125        let dir = tmp.path().join("foo-bar").join("baz-qux");
1126        fs::create_dir_all(&dir).unwrap();
1127        let tmp_str = tmp.path().to_string_lossy();
1128        let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1129        let slug = format!("-{tmp_encoded}-foo-bar-baz-qux");
1130        let expected = tmp.path().join("foo-bar").join("baz-qux");
1131        let (decoded, is_verified) = decode_slug_anchored(&slug);
1132        assert_eq!(decoded, expected);
1133        assert!(is_verified);
1134    }
1135
1136    #[test]
1137    fn decode_slug_anchored_fallback_when_nothing_exists() {
1138        // No filesystem paths exist for this slug -- falls back to naive.
1139        let (path, verified) = decode_slug_anchored("-nonexistent-xyz-abc-def");
1140        assert_eq!(path, PathBuf::from("/nonexistent/xyz/abc/def"));
1141        assert!(!verified);
1142    }
1143
1144    #[test]
1145    fn decode_slug_anchored_real_world_issue_example() {
1146        // The exact real-world shape from issue #607: a hyphenated leaf
1147        // directory (claude-wrapper) under a non-hyphenated parent. The
1148        // naive decode would split it into .../claude/wrapper; anchoring
1149        // on disk keeps it whole.
1150        let tmp = tempfile::tempdir().unwrap();
1151        let dir = tmp.path().join("rust").join("claude-wrapper");
1152        fs::create_dir_all(&dir).unwrap();
1153        let tmp_str = tmp.path().to_string_lossy();
1154        let tmp_encoded = tmp_str.trim_start_matches('/').replace('/', "-");
1155        let slug = format!("-{tmp_encoded}-rust-claude-wrapper");
1156        let expected = tmp.path().join("rust").join("claude-wrapper");
1157        let (decoded, is_verified) = decode_slug_anchored(&slug);
1158        assert_eq!(decoded, expected);
1159        assert!(is_verified);
1160    }
1161
1162    // -- ListOptions / pagination -----------------------------------
1163
1164    /// Build a fixture with five projects of varying activity so
1165    /// recency sort and pagination have meaningful inputs.
1166    fn paginated_fixture() -> tempfile::TempDir {
1167        let tmp = tempfile::tempdir().unwrap();
1168        // Two empty projects (no .jsonl files), three with one each.
1169        for stem in ["-zzz-empty1", "-aaa-empty2"] {
1170            fs::create_dir_all(tmp.path().join(stem)).unwrap();
1171        }
1172        for (stem, ts, mtime) in [
1173            ("-bbb-proj", "2026-03-01T00:00:00Z", 1_700_000_000),
1174            ("-ccc-proj", "2026-04-01T00:00:00Z", 1_700_001_000),
1175            ("-ddd-proj", "2026-05-01T00:00:00Z", 1_700_002_000),
1176        ] {
1177            let dir = tmp.path().join(stem);
1178            fs::create_dir_all(&dir).unwrap();
1179            let session_path = write_session(
1180                &dir,
1181                "s1",
1182                &[&format!(
1183                    r#"{{"type":"user","uuid":"u","timestamp":"{ts}","message":{{"role":"user","content":"x"}}}}"#
1184                )],
1185            );
1186            set_mtime(&session_path, mtime);
1187        }
1188        tmp
1189    }
1190
1191    #[test]
1192    fn list_projects_with_include_empty_false_filters_them_out() {
1193        let tmp = paginated_fixture();
1194        let root = HistoryRoot::at(tmp.path());
1195        let projects = root
1196            .list_projects_with(&ListOptions {
1197                include_empty: false,
1198                ..Default::default()
1199            })
1200            .expect("list");
1201        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1202        // Empty projects (-zzz-empty1 / -aaa-empty2) filtered out.
1203        assert_eq!(slugs, ["-bbb-proj", "-ccc-proj", "-ddd-proj"]);
1204    }
1205
1206    #[test]
1207    fn list_projects_with_default_includes_empty_for_bc() {
1208        // Default::default() must preserve legacy "include everything"
1209        // semantics so zero-arg list_projects() doesn't change behavior.
1210        let tmp = paginated_fixture();
1211        let root = HistoryRoot::at(tmp.path());
1212        let projects = root
1213            .list_projects_with(&ListOptions::default())
1214            .expect("list");
1215        assert_eq!(projects.len(), 5);
1216    }
1217
1218    #[test]
1219    fn list_projects_zero_arg_preserves_legacy_inclusion() {
1220        // The original list_projects() returned everything in slug order;
1221        // we must NOT regress that contract for existing callers.
1222        let tmp = paginated_fixture();
1223        let root = HistoryRoot::at(tmp.path());
1224        let projects = root.list_projects().expect("list");
1225        assert_eq!(projects.len(), 5);
1226        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1227        assert_eq!(
1228            slugs,
1229            [
1230                "-aaa-empty2",
1231                "-bbb-proj",
1232                "-ccc-proj",
1233                "-ddd-proj",
1234                "-zzz-empty1",
1235            ]
1236        );
1237    }
1238
1239    #[test]
1240    fn list_projects_with_limit_caps_results() {
1241        let tmp = paginated_fixture();
1242        let root = HistoryRoot::at(tmp.path());
1243        let projects = root
1244            .list_projects_with(&ListOptions {
1245                limit: Some(2),
1246                include_empty: true,
1247                ..Default::default()
1248            })
1249            .expect("list");
1250        assert_eq!(projects.len(), 2);
1251    }
1252
1253    #[test]
1254    fn list_projects_with_offset_skips() {
1255        let tmp = paginated_fixture();
1256        let root = HistoryRoot::at(tmp.path());
1257        let projects = root
1258            .list_projects_with(&ListOptions {
1259                offset: 3,
1260                include_empty: true,
1261                ..Default::default()
1262            })
1263            .expect("list");
1264        // NameAsc default; skipping 3 from [aaa, bbb, ccc, ddd, zzz]
1265        // leaves [ddd, zzz].
1266        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1267        assert_eq!(slugs, ["-ddd-proj", "-zzz-empty1"]);
1268    }
1269
1270    #[test]
1271    fn list_projects_with_offset_past_end_returns_empty() {
1272        let tmp = paginated_fixture();
1273        let root = HistoryRoot::at(tmp.path());
1274        let projects = root
1275            .list_projects_with(&ListOptions {
1276                offset: 99,
1277                include_empty: true,
1278                ..Default::default()
1279            })
1280            .expect("list");
1281        assert!(projects.is_empty());
1282    }
1283
1284    #[test]
1285    fn list_projects_with_recency_desc_sort() {
1286        let tmp = paginated_fixture();
1287        let root = HistoryRoot::at(tmp.path());
1288        // -ddd-proj has the newest session (May 2026), then -ccc, then -bbb.
1289        // The fixture writes them in order so filesystem mtimes also
1290        // progress. Filter empties so the tail isn't a no-mtime project.
1291        let projects = root
1292            .list_projects_with(&ListOptions {
1293                sort: ListSort::RecencyDesc,
1294                include_empty: false,
1295                ..Default::default()
1296            })
1297            .expect("list");
1298        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1299        assert_eq!(slugs, ["-ddd-proj", "-ccc-proj", "-bbb-proj"]);
1300    }
1301
1302    #[test]
1303    fn list_sessions_with_include_empty_false_filters_zero_message() {
1304        let tmp = tempfile::tempdir().unwrap();
1305        let dir = tmp.path().join("-proj");
1306        fs::create_dir_all(&dir).unwrap();
1307        // One real session.
1308        write_session(
1309            &dir,
1310            "real",
1311            &[
1312                r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1313            ],
1314        );
1315        // One orphan: just a queue-op, no user/assistant.
1316        write_session(
1317            &dir,
1318            "orphan",
1319            &[
1320                r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
1321            ],
1322        );
1323        let root = HistoryRoot::at(tmp.path());
1324        let sessions = root
1325            .list_sessions_with(
1326                Some("-proj"),
1327                &ListOptions {
1328                    include_empty: false,
1329                    ..Default::default()
1330                },
1331            )
1332            .expect("list");
1333        let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1334        assert_eq!(ids, ["real"]);
1335    }
1336
1337    #[test]
1338    fn list_sessions_with_default_returns_orphans_for_bc() {
1339        let tmp = tempfile::tempdir().unwrap();
1340        let dir = tmp.path().join("-proj");
1341        fs::create_dir_all(&dir).unwrap();
1342        write_session(
1343            &dir,
1344            "orphan",
1345            &[
1346                r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
1347            ],
1348        );
1349        let root = HistoryRoot::at(tmp.path());
1350        let sessions = root
1351            .list_sessions_with(Some("-proj"), &ListOptions::default())
1352            .expect("list");
1353        assert_eq!(sessions.len(), 1);
1354        assert_eq!(sessions[0].message_count, 0);
1355    }
1356
1357    #[test]
1358    fn list_sessions_with_recency_desc_sort() {
1359        let tmp = tempfile::tempdir().unwrap();
1360        let dir = tmp.path().join("-proj");
1361        fs::create_dir_all(&dir).unwrap();
1362        let old_p = write_session(
1363            &dir,
1364            "old",
1365            &[
1366                r#"{"type":"user","uuid":"u","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1367            ],
1368        );
1369        let new_p = write_session(
1370            &dir,
1371            "new",
1372            &[
1373                r#"{"type":"user","uuid":"u","timestamp":"2026-12-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1374            ],
1375        );
1376        let mid_p = write_session(
1377            &dir,
1378            "mid",
1379            &[
1380                r#"{"type":"user","uuid":"u","timestamp":"2026-06-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1381            ],
1382        );
1383        set_mtime(&old_p, 1_700_000_000);
1384        set_mtime(&mid_p, 1_700_001_000);
1385        set_mtime(&new_p, 1_700_002_000);
1386        let root = HistoryRoot::at(tmp.path());
1387        let sessions = root
1388            .list_sessions_with(
1389                Some("-proj"),
1390                &ListOptions {
1391                    sort: ListSort::RecencyDesc,
1392                    ..Default::default()
1393                },
1394            )
1395            .expect("list");
1396        let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1397        assert_eq!(ids, ["new", "mid", "old"]);
1398    }
1399
1400    #[test]
1401    fn list_sessions_with_limit_and_offset_combine() {
1402        let tmp = tempfile::tempdir().unwrap();
1403        let dir = tmp.path().join("-proj");
1404        fs::create_dir_all(&dir).unwrap();
1405        for i in 0..5 {
1406            write_session(
1407                &dir,
1408                &format!("s{i}"),
1409                &[&format!(
1410                    r#"{{"type":"user","uuid":"u","timestamp":"2026-01-0{i}T00:00:00Z","message":{{"role":"user","content":"x"}}}}"#
1411                )],
1412            );
1413        }
1414        let root = HistoryRoot::at(tmp.path());
1415        let sessions = root
1416            .list_sessions_with(
1417                Some("-proj"),
1418                &ListOptions {
1419                    offset: 1,
1420                    limit: Some(2),
1421                    ..Default::default()
1422                },
1423            )
1424            .expect("list");
1425        let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1426        // NameAsc default: ids are s0..s4; skip 1, take 2 → ["s1","s2"].
1427        assert_eq!(ids, ["s1", "s2"]);
1428    }
1429
1430    // -- aiTitle parsing bug fix ---------------------------------------
1431
1432    #[test]
1433    fn session_summary_parses_ai_title_camelcase() {
1434        // Real claude-code writes the title under `aiTitle`, not
1435        // `title`. Regression test for the field-name bug.
1436        let tmp = tempfile::tempdir().unwrap();
1437        let dir = tmp.path().join("-proj");
1438        fs::create_dir_all(&dir).unwrap();
1439        write_session(
1440            &dir,
1441            "real-shape",
1442            &[
1443                r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1444                r#"{"type":"ai-title","aiTitle":"My Session","sessionId":"real-shape"}"#,
1445            ],
1446        );
1447        let root = HistoryRoot::at(tmp.path());
1448        let sessions = root.list_sessions(Some("-proj")).expect("list");
1449        let s = sessions
1450            .iter()
1451            .find(|s| s.session_id == "real-shape")
1452            .unwrap();
1453        assert_eq!(s.title.as_deref(), Some("My Session"));
1454    }
1455
1456    #[test]
1457    fn session_summary_legacy_title_field_still_works() {
1458        // Older fixtures used `title`; we still accept it as a fallback.
1459        let tmp = tempfile::tempdir().unwrap();
1460        let dir = tmp.path().join("-proj");
1461        fs::create_dir_all(&dir).unwrap();
1462        write_session(
1463            &dir,
1464            "legacy",
1465            &[
1466                r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1467                r#"{"type":"ai-title","title":"Legacy Form"}"#,
1468            ],
1469        );
1470        let root = HistoryRoot::at(tmp.path());
1471        let sessions = root.list_sessions(Some("-proj")).expect("list");
1472        let s = sessions.iter().find(|s| s.session_id == "legacy").unwrap();
1473        assert_eq!(s.title.as_deref(), Some("Legacy Form"));
1474    }
1475
1476    // -- forward slug derivation / sessions_for_path (#642) ----------
1477
1478    #[test]
1479    fn encode_path_slug_encodes_slash_and_dot() {
1480        assert_eq!(
1481            encode_path_slug("/Users/josh/Code/projA"),
1482            "-Users-josh-Code-projA"
1483        );
1484        // The #642 gap: a `.` in a path segment is encoded too.
1485        assert_eq!(
1486            encode_path_slug("/private/var/folders/T/tmp.AbC"),
1487            "-private-var-folders-T-tmp-AbC"
1488        );
1489        // The #649 gap: every non-alphanumeric char is encoded,
1490        // including `_`, spaces, and other separators -- matching the
1491        // CLI's project-dir naming.
1492        assert_eq!(
1493            encode_path_slug("/Users/me/genagent/claude_wrapper_ex"),
1494            "-Users-me-genagent-claude-wrapper-ex"
1495        );
1496        assert_eq!(
1497            encode_path_slug("/Users/me/My Project (v2)"),
1498            "-Users-me-My-Project--v2-"
1499        );
1500    }
1501
1502    #[test]
1503    fn project_slug_canonicalizes_and_encodes_dot() {
1504        let work = tempfile::tempdir().unwrap();
1505        let cwd = work.path().join("my.proj");
1506        fs::create_dir_all(&cwd).unwrap();
1507
1508        let slug = HistoryRoot::project_slug(&cwd);
1509        assert!(
1510            slug.contains("my-proj"),
1511            "dotted segment must encode '.' -> '-', got {slug}"
1512        );
1513        assert!(
1514            !slug.contains('.'),
1515            "no '.' may survive in the slug: {slug}"
1516        );
1517        assert!(
1518            !slug.contains('/'),
1519            "no '/' may survive in the slug: {slug}"
1520        );
1521    }
1522
1523    #[test]
1524    fn project_slug_canonicalizes_and_encodes_underscore() {
1525        // #649: an `_` in a path segment must encode to `-`, matching
1526        // the CLI's project-dir naming.
1527        let work = tempfile::tempdir().unwrap();
1528        let cwd = work.path().join("claude_wrapper_ex");
1529        fs::create_dir_all(&cwd).unwrap();
1530
1531        let slug = HistoryRoot::project_slug(&cwd);
1532        assert!(
1533            slug.contains("claude-wrapper-ex"),
1534            "underscored segment must encode '_' -> '-', got {slug}"
1535        );
1536        assert!(
1537            !slug.contains('_'),
1538            "no '_' may survive in the slug: {slug}"
1539        );
1540    }
1541
1542    #[test]
1543    fn sessions_for_path_finds_session_under_dotted_symlinked_cwd() {
1544        // Repro for #642. On macOS tempdirs live under /var -> /private/var
1545        // (a symlink), and the cwd here also has a '.' segment. claude
1546        // writes the session under the canonicalized, dot-encoded slug;
1547        // sessions_for_path must derive the same slug and find it.
1548        let projects = tempfile::tempdir().unwrap();
1549        let work = tempfile::tempdir().unwrap();
1550        let cwd = work.path().join("tmp.XYZ");
1551        fs::create_dir_all(&cwd).unwrap();
1552
1553        // Build the project dir using claude's derivation directly
1554        // (canonicalize + encode), independent of the method under test,
1555        // so a project_slug that skipped either step would find nothing.
1556        let canonical = fs::canonicalize(&cwd).unwrap();
1557        let expected_slug = encode_path_slug(&canonical.to_string_lossy());
1558        let proj_dir = projects.path().join(&expected_slug);
1559        fs::create_dir_all(&proj_dir).unwrap();
1560        write_session(
1561            &proj_dir,
1562            "sess-dot",
1563            &[
1564                r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"x","message":{"role":"user","content":"hi"}}"#,
1565            ],
1566        );
1567
1568        let root = HistoryRoot::at(projects.path());
1569        let sessions = root.sessions_for_path(&cwd).expect("enumerate");
1570        assert_eq!(
1571            sessions.len(),
1572            1,
1573            "should find the session for the dotted/symlinked cwd"
1574        );
1575        assert_eq!(sessions[0].session_id, "sess-dot");
1576    }
1577}