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//! # Session subdirectories
21//!
22//! Next to a session's `.jsonl`, Claude Code may write a directory
23//! named after the session id holding sidechain state:
24//!
25//! ```text
26//! projects/<slug>/<session_id>/
27//!   subagents/agent-<id>.jsonl       subagent transcript
28//!   subagents/agent-<id>.meta.json   per-agent metadata
29//!   workflows/wf_<id>.json           workflow journal
30//!   workflows/scripts/<name>-wf_<id>.js
31//!   tool-results/<tool_use_id>.txt   spilled large tool output
32//! ```
33//!
34//! [`HistoryRoot::list_subagents`] / [`HistoryRoot::read_subagent`]
35//! parse sidechain transcripts with the same entry machinery as
36//! [`HistoryRoot::read_session`]; [`HistoryRoot::list_tool_results`],
37//! [`HistoryRoot::read_tool_result`], [`HistoryRoot::list_workflows`],
38//! and [`HistoryRoot::read_workflow`] expose the rest. A session
39//! without the subdirectory is the common case: the list methods
40//! return empty vectors, not errors. This layout is undocumented
41//! Claude Code internal state (observed against CLI 2.1.219) and can
42//! change across CLI versions.
43//!
44//! # Global prompt history
45//!
46//! Separate from per-session transcripts, Claude Code appends one
47//! record per typed prompt (across all projects) to
48//! `~/.claude/history.jsonl`, a sibling of the projects root.
49//! [`HistoryRoot::prompt_history`] reads it as typed
50//! [`PromptHistoryEntry`] values: a pre-filtered stream of human
51//! input with project and session join keys. Its retention differs
52//! from session transcripts; transcripts remain the ground truth
53//! for attribution.
54//!
55//! # Liberal parsing
56//!
57//! Each line is parsed independently; malformed lines are skipped
58//! (with a tracing warning) rather than failing the whole session.
59//! Unknown entry types come through as [`HistoryEntry::Other`]
60//! carrying the raw [`serde_json::Value`] so callers can inspect
61//! them. The shape Claude Code writes today includes at least
62//! `user`, `assistant`, `queue-operation`, `attachment`, `ai-title`,
63//! `last-prompt` -- only `user` and `assistant` get typed variants;
64//! the rest land in [`HistoryEntry::Other`].
65//!
66//! # Slug encoding
67//!
68//! Project directory names are filesystem-safe encodings of an
69//! absolute path: every non-alphanumeric character becomes `-`, so
70//! `/Users/josh/Code/foo` becomes `-Users-josh-Code-foo`. The
71//! encoding is lossy -- a `-` in a slug could have been a `/`
72//! boundary, a literal hyphen, a `.`, a `_`, or a space.
73//! [`ProjectSummary::decoded_path`] is a best-effort inverse that
74//! anchors on the live filesystem to pick between the
75//! possibilities, with [`ProjectSummary::is_decode_verified`]
76//! reporting whether every component matched a real directory
77//! entry. For exact project attribution prefer the recorded `cwd`
78//! on [`HistoryEntry::User`]: it is written by Claude Code rather
79//! than reconstructed, and survives the project directory being
80//! renamed or deleted.
81//!
82//! # Example
83//!
84//! ```no_run
85//! use claude_wrapper::history::HistoryRoot;
86//!
87//! # fn example() -> claude_wrapper::Result<()> {
88//! let root = HistoryRoot::home()?;
89//! for project in root.list_projects()? {
90//!     println!("{}: {} sessions", project.slug, project.session_count);
91//!     for session in root.list_sessions(Some(&project.slug))? {
92//!         println!("  {} ({} msgs)", session.session_id, session.message_count);
93//!     }
94//! }
95//! # Ok(()) }
96//! ```
97
98use std::fs;
99use std::io::{BufRead, BufReader};
100use std::path::{Path, PathBuf};
101use std::time::SystemTime;
102
103use serde::Serialize;
104use serde_json::Value;
105
106use crate::error::{Error, Result};
107
108/// Root directory of Claude Code's on-disk history. Defaults to
109/// `~/.claude/projects`; override with [`HistoryRoot::at`] for
110/// tests or non-default installs.
111#[derive(Debug, Clone)]
112pub struct HistoryRoot {
113    path: PathBuf,
114}
115
116/// Sort order for [`HistoryRoot::list_projects_with`] /
117/// [`HistoryRoot::list_sessions_with`].
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
119pub enum ListSort {
120    /// Sort by the on-disk identifier alphabetically: slug for
121    /// projects, session id for sessions. This is the default for
122    /// the zero-arg [`HistoryRoot::list_projects`] /
123    /// [`HistoryRoot::list_sessions`] methods to preserve the
124    /// historical behavior of the pre-pagination API.
125    #[default]
126    NameAsc,
127    /// Sort by most-recent activity, descending. For projects this
128    /// is `last_modified` (filesystem mtime). For sessions this is
129    /// `last_timestamp` (the last JSONL entry's `timestamp` field,
130    /// compared lexicographically -- which matches chronological
131    /// order for the ISO-8601 UTC strings Claude Code writes).
132    /// Items with `None` last-time end up at the tail.
133    RecencyDesc,
134}
135
136/// Filter + sort + paginate options for the listing methods.
137///
138/// `Default::default()` preserves the historical zero-arg behavior:
139/// no limit, no offset, name-ascending sort, and **`include_empty
140/// = true`** (everything is returned). Callers wanting paginated
141/// or filtered output -- the typical case for the new `_with`
142/// methods -- override the relevant fields explicitly.
143#[derive(Debug, Clone)]
144pub struct ListOptions {
145    /// Max items to return after sorting + offset. `None` = no cap.
146    pub limit: Option<usize>,
147    /// Skip the first N items after sorting. Used with `limit` for
148    /// pagination. `0` means "start from the first item."
149    pub offset: usize,
150    /// When `false`, drop entries with no real activity -- for
151    /// projects, `session_count == 0`; for sessions, `message_count
152    /// == 0` (the orphan stub files Claude Code sometimes leaves
153    /// behind when a session never produced a turn). Default `true`
154    /// so the zero-arg [`HistoryRoot::list_projects`] /
155    /// [`HistoryRoot::list_sessions`] methods preserve their
156    /// pre-pagination "include everything" behavior. New paginated
157    /// callers (e.g. an MCP tool layer) should set this to `false`
158    /// to hide orphan stub sessions and empty project directories.
159    pub include_empty: bool,
160    /// Sort order. See [`ListSort`].
161    pub sort: ListSort,
162}
163
164impl Default for ListOptions {
165    fn default() -> Self {
166        Self {
167            limit: None,
168            offset: 0,
169            include_empty: true,
170            sort: ListSort::default(),
171        }
172    }
173}
174
175impl HistoryRoot {
176    /// Resolve the default `~/.claude/projects`. Errors if `$HOME`
177    /// (or the platform-specific user home) cannot be determined.
178    pub fn home() -> Result<Self> {
179        let home = home_dir().ok_or_else(|| Error::History {
180            message: "could not determine user home directory".to_string(),
181        })?;
182        Ok(Self {
183            path: home.join(".claude").join("projects"),
184        })
185    }
186
187    /// Use a specific path as the projects root. Useful for tests
188    /// (point at a tempdir) and for non-default installs.
189    pub fn at(path: impl Into<PathBuf>) -> Self {
190        Self { path: path.into() }
191    }
192
193    /// The configured root directory.
194    pub fn path(&self) -> &Path {
195        &self.path
196    }
197
198    /// List every project directory at the root, sorted by slug.
199    ///
200    /// Convenience wrapper around [`Self::list_projects_with`] with
201    /// [`ListOptions::default`] (no limit, no offset, name-ascending
202    /// sort, includes empty projects). Existing callers keep their
203    /// behavior; new callers wanting pagination or recency sort
204    /// should use [`Self::list_projects_with`].
205    ///
206    /// Returns an empty vec if the root directory doesn't exist.
207    pub fn list_projects(&self) -> Result<Vec<ProjectSummary>> {
208        self.list_projects_with(&ListOptions::default())
209    }
210
211    /// List project directories with filter / sort / pagination.
212    ///
213    /// Reads every direct child directory of the root, summarizes
214    /// each, then applies (in order):
215    ///
216    /// 1. Filter out empty projects (`session_count == 0`) when
217    ///    `opts.include_empty` is `false`.
218    /// 2. Sort by `opts.sort` ([`ListSort::NameAsc`] by default,
219    ///    [`ListSort::RecencyDesc`] for "most recent first").
220    /// 3. Skip the first `opts.offset` items.
221    /// 4. Truncate to `opts.limit` items.
222    ///
223    /// Returns an empty vec if the root directory doesn't exist.
224    pub fn list_projects_with(&self, opts: &ListOptions) -> Result<Vec<ProjectSummary>> {
225        let entries = match fs::read_dir(&self.path) {
226            Ok(it) => it,
227            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
228            Err(e) => return Err(e.into()),
229        };
230
231        let mut out = Vec::new();
232        for entry in entries.flatten() {
233            let ft = match entry.file_type() {
234                Ok(ft) => ft,
235                Err(_) => continue,
236            };
237            if !ft.is_dir() {
238                continue;
239            }
240            let slug = entry.file_name().to_string_lossy().into_owned();
241            let summary = summarize_project(&entry.path(), slug);
242            if !opts.include_empty && summary.session_count == 0 {
243                continue;
244            }
245            out.push(summary);
246        }
247        match opts.sort {
248            ListSort::NameAsc => out.sort_by(|a, b| a.slug.cmp(&b.slug)),
249            ListSort::RecencyDesc => out.sort_by(|a, b| {
250                // None at the tail.
251                match (a.last_modified, b.last_modified) {
252                    (Some(am), Some(bm)) => bm.cmp(&am),
253                    (Some(_), None) => std::cmp::Ordering::Less,
254                    (None, Some(_)) => std::cmp::Ordering::Greater,
255                    (None, None) => a.slug.cmp(&b.slug),
256                }
257            }),
258        }
259        apply_offset_limit(&mut out, opts);
260        Ok(out)
261    }
262
263    /// List sessions, optionally filtered to one project's `slug`,
264    /// sorted by session id.
265    ///
266    /// Convenience wrapper around [`Self::list_sessions_with`] with
267    /// [`ListOptions::default`].
268    pub fn list_sessions(&self, slug: Option<&str>) -> Result<Vec<SessionSummary>> {
269        self.list_sessions_with(slug, &ListOptions::default())
270    }
271
272    /// List sessions with filter / sort / pagination.
273    ///
274    /// When `slug` is `Some`, only that project is walked. When
275    /// `None`, every project directory is unioned. The options
276    /// pipeline is the same as [`Self::list_projects_with`]:
277    /// filter empty (`message_count == 0`) sessions unless
278    /// `opts.include_empty`, sort, then offset + limit.
279    pub fn list_sessions_with(
280        &self,
281        slug: Option<&str>,
282        opts: &ListOptions,
283    ) -> Result<Vec<SessionSummary>> {
284        // Project enumeration here always wants every project (no
285        // pagination), because we'll paginate the merged sessions.
286        let enumerate_opts = ListOptions {
287            include_empty: true,
288            ..ListOptions::default()
289        };
290        let project_dirs = match slug {
291            Some(s) => vec![self.path.join(s)],
292            None => self
293                .list_projects_with(&enumerate_opts)?
294                .into_iter()
295                .map(|p| self.path.join(&p.slug))
296                .collect(),
297        };
298
299        let mut out = Vec::new();
300        for dir in project_dirs {
301            let project_slug = dir
302                .file_name()
303                .map(|n| n.to_string_lossy().into_owned())
304                .unwrap_or_default();
305            let entries = match fs::read_dir(&dir) {
306                Ok(it) => it,
307                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
308                Err(e) => return Err(e.into()),
309            };
310            for entry in entries.flatten() {
311                let path = entry.path();
312                if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
313                    continue;
314                }
315                let Some(session_id) = path
316                    .file_stem()
317                    .and_then(|s| s.to_str())
318                    .map(str::to_string)
319                else {
320                    continue;
321                };
322                if let Some(summary) = summarize_session(&path, session_id, project_slug.clone()) {
323                    if !opts.include_empty && summary.message_count == 0 {
324                        continue;
325                    }
326                    out.push(summary);
327                }
328            }
329        }
330        match opts.sort {
331            ListSort::NameAsc => out.sort_by(|a, b| a.session_id.cmp(&b.session_id)),
332            ListSort::RecencyDesc => out.sort_by(|a, b| {
333                // ISO 8601 UTC strings sort lexicographically by time.
334                // None at the tail.
335                match (a.last_timestamp.as_deref(), b.last_timestamp.as_deref()) {
336                    (Some(at), Some(bt)) => bt.cmp(at),
337                    (Some(_), None) => std::cmp::Ordering::Less,
338                    (None, Some(_)) => std::cmp::Ordering::Greater,
339                    (None, None) => a.session_id.cmp(&b.session_id),
340                }
341            }),
342        }
343        apply_offset_limit(&mut out, opts);
344        Ok(out)
345    }
346
347    /// Derive claude's project-directory slug for a filesystem path,
348    /// matching the CLI exactly: the path is **canonicalized**
349    /// (resolving symlinks -- e.g. `/var` -> `/private/var` on macOS,
350    /// `/tmp` on Linux) and then every `/` and `.` is encoded as `-`.
351    ///
352    /// This is the forward complement of
353    /// [`ProjectSummary::decoded_path`] and the reliable way to locate
354    /// the project directory for a working directory -- see
355    /// [`Self::sessions_for_path`]. Without the canonicalization and
356    /// the `.`-encoding, a cwd under a symlinked root, or containing a
357    /// `.` in a path segment, derives a slug that doesn't match what
358    /// claude wrote, so enumeration finds nothing.
359    ///
360    /// Falls back to the path as given when it cannot be canonicalized
361    /// (e.g. it does not exist on disk).
362    #[must_use]
363    pub fn project_slug(path: impl AsRef<Path>) -> String {
364        let path = path.as_ref();
365        let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
366        encode_path_slug(&canonical.to_string_lossy())
367    }
368
369    /// List sessions for a specific working directory, deriving its
370    /// project slug via [`Self::project_slug`].
371    ///
372    /// This is the current-project enumeration entry point: it
373    /// canonicalizes and encodes the cwd exactly as claude does, so
374    /// sessions written from symlinked roots (`/tmp`, `/var`) or dotted
375    /// path segments are found. Convenience over
376    /// `list_sessions(Some(&HistoryRoot::project_slug(cwd)))`.
377    pub fn sessions_for_path(&self, cwd: impl AsRef<Path>) -> Result<Vec<SessionSummary>> {
378        self.sessions_for_path_with(cwd, &ListOptions::default())
379    }
380
381    /// [`Self::sessions_for_path`] with explicit [`ListOptions`].
382    pub fn sessions_for_path_with(
383        &self,
384        cwd: impl AsRef<Path>,
385        opts: &ListOptions,
386    ) -> Result<Vec<SessionSummary>> {
387        let slug = Self::project_slug(cwd);
388        self.list_sessions_with(Some(&slug), opts)
389    }
390
391    /// Read one session's full entry log.
392    ///
393    /// Walks every project directory looking for `<session_id>.jsonl`.
394    /// Errors with [`Error::History`] if no session file matches.
395    /// Malformed lines are skipped with a tracing warning.
396    pub fn read_session(&self, session_id: &str) -> Result<SessionLog> {
397        let (path, project_slug) =
398            self.find_session(session_id)?
399                .ok_or_else(|| Error::History {
400                    message: format!(
401                        "no session with id `{session_id}` under {}",
402                        self.path.display()
403                    ),
404                })?;
405        parse_session(&path, session_id.to_string(), project_slug)
406    }
407
408    /// Locate the on-disk path for a session id, plus its project
409    /// slug. Returns `Ok(None)` if no such session exists. Useful
410    /// when a caller wants to read with non-default semantics
411    /// (streaming, tailing, etc.) without going through
412    /// [`Self::read_session`].
413    pub fn find_session(&self, session_id: &str) -> Result<Option<(PathBuf, String)>> {
414        for project in self.list_projects()? {
415            let candidate = self
416                .path
417                .join(&project.slug)
418                .join(format!("{session_id}.jsonl"));
419            if candidate.is_file() {
420                return Ok(Some((candidate, project.slug)));
421            }
422        }
423        Ok(None)
424    }
425
426    /// List the subagent (sidechain) transcripts recorded under a
427    /// session's subdirectory, sorted by agent id.
428    ///
429    /// Returns an empty vector when the session has no subdirectory
430    /// or no `subagents/` entries -- the common case. Errors with
431    /// [`Error::History`] if the session id itself is unknown.
432    pub fn list_subagents(&self, session_id: &str) -> Result<Vec<SubagentSummary>> {
433        let Some(dir) = self.session_dir(session_id)? else {
434            return Ok(Vec::new());
435        };
436        let mut out = Vec::new();
437        for path in list_files_with_extension(&dir.join("subagents"), "jsonl") {
438            // `agent-<id>.jsonl`; tolerate files without the prefix.
439            let stem = match path.file_stem().and_then(|s| s.to_str()) {
440                Some(s) => s,
441                None => continue,
442            };
443            let agent_id = stem.strip_prefix("agent-").unwrap_or(stem).to_string();
444            let meta = read_subagent_meta(&path);
445            out.push(SubagentSummary {
446                agent_id,
447                path,
448                meta,
449            });
450        }
451        out.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
452        Ok(out)
453    }
454
455    /// Parse one subagent transcript into typed [`HistoryEntry`]
456    /// values, with the same liberal line-level parsing as
457    /// [`Self::read_session`]. Subagent records carry the extra
458    /// fields `agentId` and `isSidechain: true`, reachable through
459    /// [`HistoryEntry::field`] / [`HistoryEntry::is_sidechain`].
460    ///
461    /// Errors with [`Error::History`] if the session id or agent id
462    /// is unknown.
463    pub fn read_subagent(&self, session_id: &str, agent_id: &str) -> Result<Vec<HistoryEntry>> {
464        let found = self
465            .list_subagents(session_id)?
466            .into_iter()
467            .find(|s| s.agent_id == agent_id)
468            .ok_or_else(|| Error::History {
469                message: format!("no subagent with id `{agent_id}` for session `{session_id}`"),
470            })?;
471        parse_jsonl_entries(&found.path)
472    }
473
474    /// List the spilled tool-result files recorded under a session's
475    /// subdirectory, sorted by tool-use id.
476    ///
477    /// Claude Code spills large tool outputs to
478    /// `tool-results/<tool_use_id>.txt` and references them from the
479    /// transcript. Returns an empty vector when there are none.
480    /// Errors with [`Error::History`] if the session id is unknown.
481    pub fn list_tool_results(&self, session_id: &str) -> Result<Vec<ToolResultSummary>> {
482        let Some(dir) = self.session_dir(session_id)? else {
483            return Ok(Vec::new());
484        };
485        let mut out = Vec::new();
486        for path in list_files_with_extension(&dir.join("tool-results"), "txt") {
487            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
488                continue;
489            };
490            let size_bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
491            out.push(ToolResultSummary {
492                tool_use_id: stem.to_string(),
493                path,
494                size_bytes,
495            });
496        }
497        out.sort_by(|a, b| a.tool_use_id.cmp(&b.tool_use_id));
498        Ok(out)
499    }
500
501    /// Read one spilled tool result's content.
502    ///
503    /// Errors with [`Error::History`] if the session id or tool-use
504    /// id is unknown.
505    pub fn read_tool_result(&self, session_id: &str, tool_use_id: &str) -> Result<String> {
506        let found = self
507            .list_tool_results(session_id)?
508            .into_iter()
509            .find(|t| t.tool_use_id == tool_use_id)
510            .ok_or_else(|| Error::History {
511                message: format!(
512                    "no tool result with id `{tool_use_id}` for session `{session_id}`"
513                ),
514            })?;
515        Ok(fs::read_to_string(&found.path)?)
516    }
517
518    /// List the workflow journals recorded under a session's
519    /// subdirectory, sorted by workflow id.
520    ///
521    /// Each journal is a `workflows/wf_<id>.json` file; when a
522    /// matching script (`workflows/scripts/<name>-wf_<id>.js`)
523    /// exists its path is included. Returns an empty vector when
524    /// there are none. Errors with [`Error::History`] if the
525    /// session id is unknown.
526    pub fn list_workflows(&self, session_id: &str) -> Result<Vec<WorkflowSummary>> {
527        let Some(dir) = self.session_dir(session_id)? else {
528            return Ok(Vec::new());
529        };
530        let workflows_dir = dir.join("workflows");
531        let scripts: Vec<PathBuf> = list_files_with_extension(&workflows_dir.join("scripts"), "js");
532        let mut out = Vec::new();
533        for path in list_files_with_extension(&workflows_dir, "json") {
534            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
535                continue;
536            };
537            let workflow_id = stem.to_string();
538            let script_suffix = format!("-{workflow_id}.js");
539            let script_path = scripts
540                .iter()
541                .find(|p| {
542                    p.file_name()
543                        .and_then(|n| n.to_str())
544                        .is_some_and(|n| n.ends_with(&script_suffix))
545                })
546                .cloned();
547            let size_bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
548            out.push(WorkflowSummary {
549                workflow_id,
550                path,
551                script_path,
552                size_bytes,
553            });
554        }
555        out.sort_by(|a, b| a.workflow_id.cmp(&b.workflow_id));
556        Ok(out)
557    }
558
559    /// Read one workflow journal as raw JSON.
560    ///
561    /// The journal shape is Claude Code internal state; it is
562    /// returned as a [`serde_json::Value`] rather than typed.
563    /// Errors with [`Error::History`] if the session id or workflow
564    /// id is unknown.
565    pub fn read_workflow(&self, session_id: &str, workflow_id: &str) -> Result<Value> {
566        let found = self
567            .list_workflows(session_id)?
568            .into_iter()
569            .find(|w| w.workflow_id == workflow_id)
570            .ok_or_else(|| Error::History {
571                message: format!("no workflow with id `{workflow_id}` for session `{session_id}`"),
572            })?;
573        let content = fs::read_to_string(&found.path)?;
574        serde_json::from_str(&content).map_err(|e| Error::History {
575            message: format!(
576                "workflow journal `{}` is not valid JSON: {e}",
577                found.path.display()
578            ),
579        })
580    }
581
582    /// Read the global prompt history: one entry per typed prompt,
583    /// across all projects, in file (chronological) order.
584    ///
585    /// Claude Code appends to `~/.claude/history.jsonl`, a sibling
586    /// of the projects root, each time the user submits a typed
587    /// prompt. Only typed prompts land there, so this is a
588    /// pre-filtered human-input stream with project and session
589    /// join keys. Retention differs from session transcripts, and
590    /// transcripts remain the ground truth for attribution.
591    ///
592    /// Returns an empty vector when the file does not exist.
593    pub fn prompt_history(&self) -> Result<Vec<PromptHistoryEntry>> {
594        self.prompt_history_with(&ListOptions::default())
595    }
596
597    /// [`Self::prompt_history`] with pagination. Only `offset` and
598    /// `limit` apply; the other [`ListOptions`] fields are ignored.
599    /// Malformed lines are skipped with a tracing warning.
600    pub fn prompt_history_with(&self, opts: &ListOptions) -> Result<Vec<PromptHistoryEntry>> {
601        let Some(parent) = self.path.parent() else {
602            return Ok(Vec::new());
603        };
604        let file_path = parent.join("history.jsonl");
605        if !file_path.is_file() {
606            return Ok(Vec::new());
607        }
608        let file = fs::File::open(&file_path)?;
609        let reader = BufReader::new(file);
610        let mut entries = Vec::new();
611        for (lineno, line) in reader.lines().enumerate() {
612            let line = match line {
613                Ok(l) => l,
614                Err(e) => {
615                    tracing::warn!(
616                        path = %file_path.display(),
617                        line = lineno + 1,
618                        error = %e,
619                        "history: skipping unreadable prompt-history line",
620                    );
621                    continue;
622                }
623            };
624            let trimmed = line.trim();
625            if trimmed.is_empty() {
626                continue;
627            }
628            match parse_prompt_history_line(trimmed) {
629                Ok(entry) => entries.push(entry),
630                Err(e) => {
631                    tracing::warn!(
632                        path = %file_path.display(),
633                        line = lineno + 1,
634                        error = %e,
635                        "history: skipping malformed prompt-history line",
636                    );
637                }
638            }
639        }
640        apply_offset_limit(&mut entries, opts);
641        Ok(entries)
642    }
643
644    /// The session subdirectory (`projects/<slug>/<session_id>/`),
645    /// or `None` when the session exists but has no subdirectory.
646    /// Errors with [`Error::History`] when the session id is
647    /// unknown, so subdirectory lookups distinguish "session has no
648    /// sidechain state" from "no such session".
649    fn session_dir(&self, session_id: &str) -> Result<Option<PathBuf>> {
650        let (jsonl_path, _slug) = self
651            .find_session(session_id)?
652            .ok_or_else(|| Error::History {
653                message: format!(
654                    "no session with id `{session_id}` under {}",
655                    self.path.display()
656                ),
657            })?;
658        let dir = match jsonl_path.parent() {
659            Some(parent) => parent.join(session_id),
660            None => return Ok(None),
661        };
662        Ok(dir.is_dir().then_some(dir))
663    }
664}
665
666/// Summary of one project directory.
667#[derive(Debug, Clone, Serialize)]
668pub struct ProjectSummary {
669    /// On-disk directory name (the encoded path).
670    pub slug: String,
671    /// Best-effort decode of the slug back to a filesystem path,
672    /// anchored on the live filesystem. See module docs for caveats.
673    ///
674    /// Reconstructed, not recorded: for exact project attribution
675    /// prefer the `cwd` field on [`HistoryEntry::User`], which
676    /// survives the project directory being renamed or deleted.
677    pub decoded_path: PathBuf,
678    /// Whether `decoded_path` was verified against the real filesystem.
679    ///
680    /// `true` when every component, including the leaf, matched a real
681    /// directory entry during decoding. `false` when at least one
682    /// component matched nothing on disk -- a deleted project
683    /// directory is the common case -- and fell back to the naive
684    /// `-`-to-`/` replacement for that component.
685    ///
686    /// # Example
687    ///
688    /// ```rust
689    /// # use claude_wrapper::history::ProjectSummary;
690    /// // A real project: slug round-trips via filesystem check
691    /// // is_decode_verified == true when the actual directory exists
692    /// // is_decode_verified == false when decoding a slug for a path
693    /// //   that no longer exists on disk
694    /// ```
695    pub is_decode_verified: bool,
696    /// Number of `*.jsonl` files in the directory.
697    pub session_count: usize,
698    /// Latest filesystem modification time across the project's
699    /// session files. None if the directory is empty or stats fail.
700    pub last_modified: Option<SystemTime>,
701}
702
703/// Summary of one session's `.jsonl` file.
704#[derive(Debug, Clone, Serialize)]
705pub struct SessionSummary {
706    /// Filename stem -- the session UUID Claude Code assigned.
707    pub session_id: String,
708    /// The owning project's slug (directory name).
709    pub project_slug: String,
710    /// Count of `user` + `assistant` entries (excludes
711    /// queue-operation, attachment, ai-title, last-prompt, etc.).
712    pub message_count: usize,
713    /// First timestamp seen in the file (any entry type), as the
714    /// raw string Claude Code wrote.
715    pub first_timestamp: Option<String>,
716    /// Last timestamp seen.
717    pub last_timestamp: Option<String>,
718    /// Auto-generated title if Claude Code emitted an `ai-title`
719    /// entry; None otherwise.
720    pub title: Option<String>,
721    /// First ~160 chars of the first user message's text content,
722    /// flattened to a single line. Useful as a fallback display name
723    /// when `title` is None (which is most sessions today since
724    /// claude-code only writes ai-titles intermittently). None when
725    /// the session has no readable user message.
726    pub first_user_preview: Option<String>,
727    /// Sum of `message.usage.total_cost_usd` across every assistant
728    /// entry. Always None on current claude-code (the field is written
729    /// as `null`); kept in the shape so we can plumb it through if the
730    /// upstream behavior changes. Use `total_tokens` for a usage proxy.
731    pub total_cost_usd: Option<f64>,
732    /// Sum of input + output + cache tokens across every assistant
733    /// entry. None when the session has no assistant entries. Cheap to
734    /// derive from `message.usage`, which claude-code DOES write.
735    pub total_tokens: Option<u64>,
736    /// File size in bytes.
737    pub size_bytes: u64,
738}
739
740/// Full parsed session.
741#[derive(Debug, Clone, Serialize)]
742pub struct SessionLog {
743    /// The session id (the `.jsonl` file stem).
744    pub session_id: String,
745    /// Slug of the project the session belongs to.
746    pub project_slug: String,
747    /// Every parsed entry, in file order.
748    pub entries: Vec<HistoryEntry>,
749}
750
751/// One subagent (sidechain) transcript found under a session's
752/// subdirectory.
753#[derive(Debug, Clone, Serialize)]
754pub struct SubagentSummary {
755    /// Agent id (the file stem with its `agent-` prefix stripped).
756    /// Matches the `agentId` field on the transcript's records.
757    pub agent_id: String,
758    /// Path to the transcript `.jsonl`.
759    pub path: PathBuf,
760    /// Parsed sibling `.meta.json`, when present and parseable.
761    pub meta: Option<SubagentMeta>,
762}
763
764/// Metadata from a subagent's `.meta.json`.
765#[derive(Debug, Clone, Serialize)]
766pub struct SubagentMeta {
767    /// Agent type (e.g. `general-purpose`), when present.
768    pub agent_type: Option<String>,
769    /// Human-readable task description, when present.
770    pub description: Option<String>,
771    /// The tool-use id of the spawning Agent/Task call, when present.
772    pub tool_use_id: Option<String>,
773    /// Nesting depth of the spawn, when present.
774    pub spawn_depth: Option<u64>,
775    /// Any additional fields, keyed as Claude Code wrote them.
776    #[serde(flatten)]
777    pub rest: serde_json::Map<String, Value>,
778}
779
780/// One spilled tool-result file under a session's subdirectory.
781#[derive(Debug, Clone, Serialize)]
782pub struct ToolResultSummary {
783    /// The tool-use id (the file stem, e.g. `toolu_01...`), matching
784    /// `tool_use_id` references in the transcript.
785    pub tool_use_id: String,
786    /// Path to the spill file.
787    pub path: PathBuf,
788    /// File size in bytes.
789    pub size_bytes: u64,
790}
791
792/// One workflow journal under a session's subdirectory.
793#[derive(Debug, Clone, Serialize)]
794pub struct WorkflowSummary {
795    /// The workflow run id (the journal file stem, e.g.
796    /// `wf_a9a2673f-c5e`).
797    pub workflow_id: String,
798    /// Path to the journal `.json`.
799    pub path: PathBuf,
800    /// Path to the matching script under `workflows/scripts/`, when
801    /// one exists.
802    pub script_path: Option<PathBuf>,
803    /// Journal file size in bytes.
804    pub size_bytes: u64,
805}
806
807/// One record from the global prompt history file
808/// (`~/.claude/history.jsonl`): a prompt the user typed, in any
809/// project. See [`HistoryRoot::prompt_history`].
810#[derive(Debug, Clone, Serialize)]
811pub struct PromptHistoryEntry {
812    /// The prompt text as displayed.
813    pub display: Option<String>,
814    /// Epoch milliseconds when the prompt was submitted. Note this
815    /// file uses numeric timestamps, unlike the ISO-8601 strings in
816    /// session transcripts.
817    pub timestamp_ms: Option<u64>,
818    /// Absolute path of the project the prompt was typed in.
819    pub project: Option<String>,
820    /// Session the prompt belongs to.
821    pub session_id: Option<String>,
822    /// Any additional fields (e.g. `pastedContents`), keyed as
823    /// Claude Code wrote them.
824    #[serde(flatten)]
825    pub rest: serde_json::Map<String, Value>,
826}
827
828/// One parsed line from a session `.jsonl`.
829///
830/// Only `user` and `assistant` entry types get typed variants;
831/// everything else (`queue-operation`, `attachment`, `ai-title`,
832/// `last-prompt`, future types) lands in [`Self::Other`] with the
833/// raw JSON for caller inspection.
834#[derive(Debug, Clone, Serialize)]
835#[serde(tag = "kind", rename_all = "snake_case")]
836pub enum HistoryEntry {
837    /// A `user` entry: a prompt turn written by the user.
838    User {
839        /// Entry uuid, when present.
840        uuid: Option<String>,
841        /// ISO-8601 timestamp, when present.
842        timestamp: Option<String>,
843        /// Working directory recorded for the turn, when present.
844        cwd: Option<String>,
845        /// Git branch recorded for the turn, when present.
846        git_branch: Option<String>,
847        /// The raw `message` payload as Claude Code wrote it.
848        message: Value,
849        /// Every remaining field of the record, keyed as Claude Code
850        /// wrote it (camelCase, e.g. `promptSource`). See
851        /// [`HistoryEntry::field`] and the typed accessors for common
852        /// lookups.
853        #[serde(flatten)]
854        rest: serde_json::Map<String, Value>,
855    },
856    /// An `assistant` entry: a model response turn.
857    Assistant {
858        /// Entry uuid, when present.
859        uuid: Option<String>,
860        /// ISO-8601 timestamp, when present.
861        timestamp: Option<String>,
862        /// The raw `message` payload as Claude Code wrote it.
863        message: Value,
864        /// Every remaining field of the record, keyed as Claude Code
865        /// wrote it (camelCase, e.g. `requestId`). See
866        /// [`HistoryEntry::field`] and the typed accessors for common
867        /// lookups.
868        #[serde(flatten)]
869        rest: serde_json::Map<String, Value>,
870    },
871    /// Any other entry type, carried as raw JSON for caller inspection.
872    Other {
873        /// The `type` field as Claude Code wrote it.
874        type_tag: String,
875        /// The full raw entry.
876        raw: Value,
877    },
878}
879
880impl HistoryEntry {
881    /// Parse a single transcript line into a typed entry.
882    ///
883    /// The per-line counterpart to [`HistoryRoot::read_session`]: use
884    /// this when you already hold the line -- typically a consumer
885    /// tailing append-only transcript files by byte offset, reading
886    /// only the new bytes on each pass instead of re-reading whole
887    /// session files.
888    ///
889    /// Parsing is liberal, matching `read_session`: entry types other
890    /// than `user` and `assistant` come through as [`Self::Other`]
891    /// carrying the raw value. A line that is not valid JSON is the
892    /// one `Err` case -- the same lines `read_session` skips with a
893    /// warning, left to the caller here.
894    ///
895    /// ```
896    /// use claude_wrapper::history::HistoryEntry;
897    ///
898    /// let line = r#"{"type":"user","uuid":"u1","cwd":"/work/repo","promptSource":"typed","message":{"role":"user"}}"#;
899    /// let entry = HistoryEntry::from_line(line)?;
900    /// match &entry {
901    ///     HistoryEntry::User { cwd, .. } => {
902    ///         assert_eq!(cwd.as_deref(), Some("/work/repo"));
903    ///     }
904    ///     _ => unreachable!(),
905    /// }
906    /// assert_eq!(entry.prompt_source(), Some("typed"));
907    /// # Ok::<(), serde_json::Error>(())
908    /// ```
909    pub fn from_line(line: &str) -> std::result::Result<Self, serde_json::Error> {
910        parse_entry(line)
911    }
912
913    /// Raw access to a field by its name as Claude Code wrote it
914    /// (camelCase, e.g. `"permissionMode"`).
915    ///
916    /// For [`Self::User`] and [`Self::Assistant`] this resolves
917    /// against `rest`, so the fields promoted to struct fields
918    /// (`uuid`, `timestamp`, `cwd`, `gitBranch`, `message`) are not
919    /// reachable here. For [`Self::Other`] it resolves against the
920    /// full raw entry.
921    ///
922    /// These records are Claude Code's data, not this crate's: a CLI
923    /// update can add, drop, or retype any field, which is why this
924    /// returns raw JSON and the typed accessors below are all
925    /// `Option`-shaped.
926    pub fn field(&self, key: &str) -> Option<&Value> {
927        match self {
928            Self::User { rest, .. } | Self::Assistant { rest, .. } => rest.get(key),
929            Self::Other { raw, .. } => raw.get(key),
930        }
931    }
932
933    /// How the prompt reached the session (`typed`, `queued`, `sdk`,
934    /// `system`, `suggestion_accepted`), when recorded. Only user
935    /// entries carrying an actual prompt have this; tool-result user
936    /// turns generally do not.
937    pub fn prompt_source(&self) -> Option<&str> {
938        self.field("promptSource").and_then(Value::as_str)
939    }
940
941    /// The surface the session was driven from (`cli`,
942    /// `claude-desktop`, `sdk-cli`), when recorded.
943    pub fn entrypoint(&self) -> Option<&str> {
944        self.field("entrypoint").and_then(Value::as_str)
945    }
946
947    /// Whether the entry is harness-injected context rather than a
948    /// real turn. `None` when the field is absent.
949    pub fn is_meta(&self) -> Option<bool> {
950        self.field("isMeta").and_then(Value::as_bool)
951    }
952
953    /// Whether the entry belongs to a subagent sidechain. `None`
954    /// when the field is absent.
955    pub fn is_sidechain(&self) -> Option<bool> {
956        self.field("isSidechain").and_then(Value::as_bool)
957    }
958
959    /// The session id recorded on the entry, when present.
960    pub fn session_id(&self) -> Option<&str> {
961        self.field("sessionId").and_then(Value::as_str)
962    }
963
964    /// The uuid of the parent entry, when present. Root entries carry
965    /// `parentUuid: null`, which also reads as `None` here.
966    pub fn parent_uuid(&self) -> Option<&str> {
967        self.field("parentUuid").and_then(Value::as_str)
968    }
969}
970
971// -- helpers --------------------------------------------------------
972
973/// Apply offset + limit in-place to a sorted vec. Pulled out so the
974/// project and session list paths share the same pagination logic.
975fn apply_offset_limit<T>(items: &mut Vec<T>, opts: &ListOptions) {
976    if opts.offset >= items.len() {
977        items.clear();
978        return;
979    }
980    if opts.offset > 0 {
981        items.drain(..opts.offset);
982    }
983    if let Some(lim) = opts.limit
984        && items.len() > lim
985    {
986        items.truncate(lim);
987    }
988}
989
990fn summarize_project(dir: &Path, slug: String) -> ProjectSummary {
991    let mut session_count = 0usize;
992    let mut last_modified: Option<SystemTime> = None;
993    if let Ok(entries) = fs::read_dir(dir) {
994        for entry in entries.flatten() {
995            let path = entry.path();
996            if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
997                session_count += 1;
998                if let Ok(meta) = entry.metadata()
999                    && let Ok(mtime) = meta.modified()
1000                {
1001                    last_modified = Some(match last_modified {
1002                        Some(prev) if prev > mtime => prev,
1003                        _ => mtime,
1004                    });
1005                }
1006            }
1007        }
1008    }
1009    let (decoded_path, is_decode_verified) = decode_slug_anchored(&slug);
1010    ProjectSummary {
1011        decoded_path,
1012        is_decode_verified,
1013        slug,
1014        session_count,
1015        last_modified,
1016    }
1017}
1018
1019fn summarize_session(
1020    path: &Path,
1021    session_id: String,
1022    project_slug: String,
1023) -> Option<SessionSummary> {
1024    let meta = fs::metadata(path).ok()?;
1025    let size_bytes = meta.len();
1026
1027    let file = fs::File::open(path).ok()?;
1028    let reader = BufReader::new(file);
1029
1030    let mut message_count = 0usize;
1031    let mut first_timestamp = None;
1032    let mut last_timestamp = None;
1033    let mut title = None;
1034    let mut first_user_preview: Option<String> = None;
1035    let mut total_cost_usd: Option<f64> = None;
1036    let mut total_tokens: Option<u64> = None;
1037
1038    for line in reader.lines().map_while(std::io::Result::ok) {
1039        let trimmed = line.trim();
1040        if trimmed.is_empty() {
1041            continue;
1042        }
1043        let v: Value = match serde_json::from_str(trimmed) {
1044            Ok(v) => v,
1045            Err(_) => continue,
1046        };
1047        let ty = v.get("type").and_then(Value::as_str).unwrap_or("");
1048        match ty {
1049            "user" => {
1050                message_count += 1;
1051                if first_user_preview.is_none()
1052                    && let Some(p) = extract_user_text_preview(&v, 160)
1053                {
1054                    first_user_preview = Some(p);
1055                }
1056            }
1057            "assistant" => {
1058                message_count += 1;
1059                if let Some(c) = v
1060                    .get("message")
1061                    .and_then(|m| m.get("usage"))
1062                    .and_then(|u| u.get("total_cost_usd"))
1063                    .and_then(Value::as_f64)
1064                {
1065                    *total_cost_usd.get_or_insert(0.0) += c;
1066                }
1067                if let Some(usage) = v.get("message").and_then(|m| m.get("usage")) {
1068                    // Sum every token bucket so cache + non-cache both count.
1069                    let mut t = 0u64;
1070                    for k in [
1071                        "input_tokens",
1072                        "output_tokens",
1073                        "cache_creation_input_tokens",
1074                        "cache_read_input_tokens",
1075                    ] {
1076                        if let Some(n) = usage.get(k).and_then(Value::as_u64) {
1077                            t += n;
1078                        }
1079                    }
1080                    if t > 0 {
1081                        *total_tokens.get_or_insert(0) += t;
1082                    }
1083                }
1084            }
1085            "ai-title" => {
1086                // Claude Code writes this field as `aiTitle` (camelCase),
1087                // not `title`. Read both for resilience against future
1088                // renames -- whichever is present and non-empty wins.
1089                let candidate = v
1090                    .get("aiTitle")
1091                    .and_then(Value::as_str)
1092                    .or_else(|| v.get("title").and_then(Value::as_str));
1093                if let Some(t) = candidate
1094                    && !t.is_empty()
1095                {
1096                    title = Some(t.to_string());
1097                }
1098            }
1099            _ => {}
1100        }
1101        if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
1102            if first_timestamp.is_none() {
1103                first_timestamp = Some(ts.to_string());
1104            }
1105            last_timestamp = Some(ts.to_string());
1106        }
1107    }
1108
1109    Some(SessionSummary {
1110        session_id,
1111        project_slug,
1112        message_count,
1113        first_timestamp,
1114        last_timestamp,
1115        title,
1116        first_user_preview,
1117        total_cost_usd,
1118        total_tokens,
1119        size_bytes,
1120    })
1121}
1122
1123/// Pull a single-line, truncated preview out of a user-entry JSON.
1124/// Accepts both `message.content: "string"` and the structured form
1125/// `message.content: [{type:"text", text:"..."}, ...]`. Skips entries
1126/// where the first user "message" is actually a tool_result (those
1127/// happen when claude-code resumes a session that was mid-tool).
1128fn extract_user_text_preview(entry: &Value, max_chars: usize) -> Option<String> {
1129    let content = entry.get("message")?.get("content")?;
1130    let raw = if let Some(s) = content.as_str() {
1131        s.to_string()
1132    } else {
1133        let arr = content.as_array()?;
1134        let mut buf = String::new();
1135        for block in arr {
1136            let ty = block.get("type").and_then(Value::as_str).unwrap_or("");
1137            if ty == "text"
1138                && let Some(t) = block.get("text").and_then(Value::as_str)
1139            {
1140                if !buf.is_empty() {
1141                    buf.push(' ');
1142                }
1143                buf.push_str(t);
1144            }
1145        }
1146        buf
1147    };
1148    let one_line = raw
1149        .split('\n')
1150        .map(str::trim)
1151        .filter(|l| !l.is_empty())
1152        .collect::<Vec<_>>()
1153        .join(" ");
1154    if one_line.is_empty() {
1155        return None;
1156    }
1157    let truncated: String = one_line.chars().take(max_chars).collect();
1158    if truncated.len() < one_line.len() {
1159        Some(format!("{truncated}..."))
1160    } else {
1161        Some(truncated)
1162    }
1163}
1164
1165fn parse_session(path: &Path, session_id: String, project_slug: String) -> Result<SessionLog> {
1166    let entries = parse_jsonl_entries(path)?;
1167    Ok(SessionLog {
1168        session_id,
1169        project_slug,
1170        entries,
1171    })
1172}
1173
1174/// Parse a transcript `.jsonl` (mainline or subagent) into entries,
1175/// skipping unreadable and malformed lines with a tracing warning.
1176fn parse_jsonl_entries(path: &Path) -> Result<Vec<HistoryEntry>> {
1177    let file = fs::File::open(path)?;
1178    let reader = BufReader::new(file);
1179
1180    let mut entries = Vec::new();
1181    for (lineno, line) in reader.lines().enumerate() {
1182        let line = match line {
1183            Ok(l) => l,
1184            Err(e) => {
1185                tracing::warn!(
1186                    path = %path.display(),
1187                    line = lineno + 1,
1188                    error = %e,
1189                    "history: skipping unreadable line",
1190                );
1191                continue;
1192            }
1193        };
1194        let trimmed = line.trim();
1195        if trimmed.is_empty() {
1196            continue;
1197        }
1198        match parse_entry(trimmed) {
1199            Ok(entry) => entries.push(entry),
1200            Err(e) => {
1201                tracing::warn!(
1202                    path = %path.display(),
1203                    line = lineno + 1,
1204                    error = %e,
1205                    "history: skipping malformed line",
1206                );
1207            }
1208        }
1209    }
1210    Ok(entries)
1211}
1212
1213/// Non-recursive listing of files in `dir` with the given extension.
1214/// A missing or unreadable directory yields an empty list.
1215fn list_files_with_extension(dir: &Path, ext: &str) -> Vec<PathBuf> {
1216    let mut out = Vec::new();
1217    if let Ok(entries) = fs::read_dir(dir) {
1218        for entry in entries.flatten() {
1219            let path = entry.path();
1220            if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some(ext) {
1221                out.push(path);
1222            }
1223        }
1224    }
1225    out
1226}
1227
1228fn parse_prompt_history_line(
1229    line: &str,
1230) -> std::result::Result<PromptHistoryEntry, serde_json::Error> {
1231    let value: Value = serde_json::from_str(line)?;
1232    let mut rest = into_map(value);
1233    let timestamp_ms = match rest.remove("timestamp") {
1234        Some(v) => {
1235            let n = v.as_u64();
1236            if n.is_none() {
1237                rest.insert("timestamp".to_string(), v);
1238            }
1239            n
1240        }
1241        None => None,
1242    };
1243    Ok(PromptHistoryEntry {
1244        display: take_string(&mut rest, "display"),
1245        timestamp_ms,
1246        project: take_string(&mut rest, "project"),
1247        session_id: take_string(&mut rest, "sessionId"),
1248        rest,
1249    })
1250}
1251
1252/// Read and parse the `.meta.json` next to a subagent transcript.
1253/// Absent or malformed metadata is `None`, not an error: the
1254/// transcript is the primary artifact.
1255fn read_subagent_meta(transcript_path: &Path) -> Option<SubagentMeta> {
1256    let meta_path = transcript_path.with_extension("meta.json");
1257    let content = fs::read_to_string(&meta_path).ok()?;
1258    let value: Value = serde_json::from_str(&content).ok()?;
1259    let mut rest = into_map(value);
1260    let spawn_depth = match rest.remove("spawnDepth") {
1261        Some(v) => {
1262            let n = v.as_u64();
1263            if n.is_none() {
1264                rest.insert("spawnDepth".to_string(), v);
1265            }
1266            n
1267        }
1268        None => None,
1269    };
1270    Some(SubagentMeta {
1271        agent_type: take_string(&mut rest, "agentType"),
1272        description: take_string(&mut rest, "description"),
1273        tool_use_id: take_string(&mut rest, "toolUseId"),
1274        spawn_depth,
1275        rest,
1276    })
1277}
1278
1279fn parse_entry(line: &str) -> std::result::Result<HistoryEntry, serde_json::Error> {
1280    let value: Value = serde_json::from_str(line)?;
1281    let ty = value
1282        .get("type")
1283        .and_then(Value::as_str)
1284        .unwrap_or("")
1285        .to_string();
1286    match ty.as_str() {
1287        "user" => {
1288            let mut rest = into_map(value);
1289            rest.remove("type");
1290            Ok(HistoryEntry::User {
1291                uuid: take_string(&mut rest, "uuid"),
1292                timestamp: take_string(&mut rest, "timestamp"),
1293                cwd: take_string(&mut rest, "cwd"),
1294                git_branch: take_string(&mut rest, "gitBranch"),
1295                message: rest.remove("message").unwrap_or(Value::Null),
1296                rest,
1297            })
1298        }
1299        "assistant" => {
1300            let mut rest = into_map(value);
1301            rest.remove("type");
1302            Ok(HistoryEntry::Assistant {
1303                uuid: take_string(&mut rest, "uuid"),
1304                timestamp: take_string(&mut rest, "timestamp"),
1305                message: rest.remove("message").unwrap_or(Value::Null),
1306                rest,
1307            })
1308        }
1309        other => Ok(HistoryEntry::Other {
1310            type_tag: other.to_string(),
1311            raw: value,
1312        }),
1313    }
1314}
1315
1316/// Unwrap a JSON value into its object map. The typed arms of
1317/// [`parse_entry`] only see objects (a non-object has no `type`
1318/// field and lands in `Other`), but stay defensive anyway.
1319fn into_map(value: Value) -> serde_json::Map<String, Value> {
1320    match value {
1321        Value::Object(map) => map,
1322        _ => serde_json::Map::new(),
1323    }
1324}
1325
1326/// Remove `key` from the map when it holds a string. A value of any
1327/// other type is left in place so it surfaces through `rest` instead
1328/// of being silently dropped.
1329fn take_string(map: &mut serde_json::Map<String, Value>, key: &str) -> Option<String> {
1330    match map.remove(key) {
1331        Some(Value::String(s)) => Some(s),
1332        Some(other) => {
1333            map.insert(key.to_string(), other);
1334            None
1335        }
1336        None => None,
1337    }
1338}
1339
1340/// Decode a project slug back to a filesystem path, anchoring on the
1341/// real filesystem to disambiguate the encoding.
1342///
1343/// Claude Code encodes an absolute path by replacing each
1344/// non-alphanumeric character with `-` (see [`encode_path_slug`]), so a
1345/// `-` in the slug could have been a `/` boundary, a literal hyphen, or
1346/// any other encoded character (`.`, `_`, space, ...). The naive
1347/// inverse (replace every `-` with `/`) is wrong for all of those but
1348/// the boundary.
1349///
1350/// This walks the slug left to right. At each step it enumerates the
1351/// directory built so far and looks for an entry whose encoded name
1352/// matches the next one or more slug fragments; the real entry name is
1353/// pushed, so encoded characters decode back exactly (`github-com`
1354/// recovers `github.com`). The longest match wins, keeping components
1355/// whole (`redismodule-rs` beats `redismodule`); among equal-length
1356/// matches the literal hyphen name wins, then the lexicographically
1357/// smallest, so `read_dir` order cannot leak into the result.
1358///
1359/// When no entry matches -- the directory was deleted, or cannot be
1360/// read -- the walk takes the naive split for that one component and
1361/// marks the decode unverified. Later steps enumerate again from
1362/// whatever was built, so a single miss does not stop the rest of the
1363/// path from anchoring when the naive guess lands on a real directory.
1364///
1365/// Returns `(decoded_path, is_decode_verified)`. `is_decode_verified`
1366/// is `true` only when every component, including the leaf, matched a
1367/// real directory entry. Even then the decode is a reconstruction --
1368/// the encoding is not injective -- so callers doing project
1369/// attribution should prefer the recorded `cwd` on
1370/// [`HistoryEntry::User`], which is exact.
1371fn decode_slug_anchored(slug: &str) -> (PathBuf, bool) {
1372    let body = slug.strip_prefix('-').unwrap_or(slug);
1373    // The slug for `/` itself is a bare `-`: no components to match,
1374    // nothing to verify.
1375    if body.is_empty() {
1376        return (PathBuf::from("/"), true);
1377    }
1378    let fragments: Vec<&str> = body.split('-').collect();
1379    let mut built_path = PathBuf::from("/");
1380    let mut is_decode_verified = true;
1381
1382    let mut i = 0;
1383    while i < fragments.len() {
1384        match longest_entry_match(&built_path, &fragments[i..]) {
1385            Some((name, consumed)) => {
1386                built_path.push(name);
1387                i += consumed;
1388            }
1389            None => {
1390                is_decode_verified = false;
1391                built_path.push(fragments[i]);
1392                i += 1;
1393            }
1394        }
1395    }
1396    (built_path, is_decode_verified)
1397}
1398
1399/// Search `parent` for the directory entry whose encoded name matches
1400/// the longest run of leading `fragments`; see
1401/// [`decode_slug_anchored`] for the tiebreak between equal-length
1402/// matches. Returns the real entry name and the number of fragments it
1403/// consumed, or `None` when nothing matches (including when `parent`
1404/// cannot be read).
1405fn longest_entry_match(parent: &Path, fragments: &[&str]) -> Option<(String, usize)> {
1406    let entries = fs::read_dir(parent).ok()?;
1407    let mut best: Option<(String, usize)> = None;
1408    for entry in entries.flatten() {
1409        // A non-UTF-8 name has no faithful encoded form to match.
1410        let Ok(name) = entry.file_name().into_string() else {
1411            continue;
1412        };
1413        let Some(consumed) = encoded_fragment_span(&name, fragments) else {
1414            continue;
1415        };
1416        let replace = match &best {
1417            None => true,
1418            Some((_, best_consumed)) if consumed > *best_consumed => true,
1419            Some((_, best_consumed)) if consumed < *best_consumed => false,
1420            Some((best_name, _)) => {
1421                let literal = fragments[..consumed].join("-");
1422                *best_name != literal && (name == literal || name < *best_name)
1423            }
1424        };
1425        if replace {
1426            best = Some((name, consumed));
1427        }
1428    }
1429    best
1430}
1431
1432/// If [`encode_path_slug`] applied to `name` equals the first `k`
1433/// fragments joined by `-`, return `k`. Fragments contain no `-` (the
1434/// slug was split on it), so joined length grows strictly with `k` and
1435/// at most one `k` can match.
1436fn encoded_fragment_span(name: &str, fragments: &[&str]) -> Option<usize> {
1437    let encoded = encode_path_slug(name);
1438    let mut joined = String::with_capacity(encoded.len());
1439    for (idx, fragment) in fragments.iter().enumerate() {
1440        if idx > 0 {
1441            joined.push('-');
1442        }
1443        joined.push_str(fragment);
1444        if joined.len() == encoded.len() {
1445            return (joined == encoded).then_some(idx + 1);
1446        }
1447        if joined.len() > encoded.len() {
1448            return None;
1449        }
1450    }
1451    None
1452}
1453
1454/// Encode an absolute filesystem path into claude's project-directory
1455/// slug: every non-alphanumeric character becomes `-` (so
1456/// `/private/var/T/tmp.X` becomes `-private-var-T-tmp-X`, and
1457/// `/Users/me/claude_wrapper` becomes `-Users-me-claude-wrapper`; the
1458/// leading `/` yields the leading `-`). This matches the Claude Code
1459/// CLI, which replaces every non-alphanumeric char -- including `_`,
1460/// spaces, and other separators -- when building the project-directory
1461/// name under `~/.claude/projects/`. Does not canonicalize -- see
1462/// [`HistoryRoot::project_slug`], which canonicalizes first.
1463fn encode_path_slug(path: &str) -> String {
1464    path.chars()
1465        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
1466        .collect()
1467}
1468
1469fn home_dir() -> Option<PathBuf> {
1470    // Avoid pulling the home crate just for this. $HOME on Unix,
1471    // %USERPROFILE% on Windows -- both honored by std::env::var.
1472    if let Ok(h) = std::env::var("HOME")
1473        && !h.is_empty()
1474    {
1475        return Some(PathBuf::from(h));
1476    }
1477    if let Ok(h) = std::env::var("USERPROFILE")
1478        && !h.is_empty()
1479    {
1480        return Some(PathBuf::from(h));
1481    }
1482    None
1483}
1484
1485#[cfg(test)]
1486mod tests {
1487    use super::*;
1488    use std::io::Write;
1489
1490    fn write_session(dir: &Path, session_id: &str, lines: &[&str]) -> PathBuf {
1491        let path = dir.join(format!("{session_id}.jsonl"));
1492        let mut f = fs::File::create(&path).expect("create jsonl");
1493        for line in lines {
1494            writeln!(f, "{line}").unwrap();
1495        }
1496        path
1497    }
1498
1499    // Set the file mtime explicitly so recency-sort tests don't depend
1500    // on filesystem mtime granularity (Linux ext4 ticks at 1s by
1501    // default, so fixtures written back-to-back end up with identical
1502    // mtimes and the sort is non-deterministic).
1503    fn set_mtime(path: &Path, secs_since_epoch: u64) {
1504        let f = fs::OpenOptions::new()
1505            .write(true)
1506            .open(path)
1507            .expect("reopen for mtime");
1508        let when = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs_since_epoch);
1509        f.set_modified(when).expect("set mtime");
1510    }
1511
1512    fn fixture_root() -> tempfile::TempDir {
1513        let tmp = tempfile::tempdir().expect("tempdir");
1514        // Project A: two sessions
1515        let a = tmp.path().join("-Users-josh-Code-projA");
1516        fs::create_dir_all(&a).unwrap();
1517        write_session(
1518            &a,
1519            "session-aaa",
1520            &[
1521                r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"/Users/josh/Code/projA","gitBranch":"main","message":{"role":"user","content":"hello"}}"#,
1522                r#"{"type":"assistant","uuid":"a1","timestamp":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":"hi"}}"#,
1523                r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-01-01T00:00:02Z"}"#,
1524                r#"{"type":"ai-title","aiTitle":"hello world"}"#,
1525            ],
1526        );
1527        write_session(
1528            &a,
1529            "session-bbb",
1530            &[
1531                r#"{"type":"user","uuid":"u2","timestamp":"2026-01-02T00:00:00Z","message":{"role":"user","content":"second"}}"#,
1532            ],
1533        );
1534        // session-aaa also has a session subdirectory with sidechain
1535        // state; session-bbb deliberately has none.
1536        let sub = a.join("session-aaa");
1537        fs::create_dir_all(sub.join("subagents")).unwrap();
1538        write_session(
1539            &sub.join("subagents"),
1540            "agent-abc123",
1541            &[
1542                r#"{"type":"user","uuid":"su1","agentId":"abc123","isSidechain":true,"message":{"role":"user","content":"subtask"}}"#,
1543                r#"{"type":"assistant","uuid":"sa1","agentId":"abc123","isSidechain":true,"message":{"role":"assistant","content":"done"}}"#,
1544            ],
1545        );
1546        fs::write(
1547            sub.join("subagents").join("agent-abc123.meta.json"),
1548            r#"{"agentType":"general-purpose","description":"audit the crate","toolUseId":"toolu_spawn1","spawnDepth":1,"futureField":"kept"}"#,
1549        )
1550        .unwrap();
1551        fs::create_dir_all(sub.join("tool-results")).unwrap();
1552        fs::write(
1553            sub.join("tool-results").join("toolu_r1.txt"),
1554            "spilled tool output",
1555        )
1556        .unwrap();
1557        fs::create_dir_all(sub.join("workflows").join("scripts")).unwrap();
1558        fs::write(
1559            sub.join("workflows").join("wf_run1.json"),
1560            r#"{"runId":"wf_run1","script":"export const meta = {}"}"#,
1561        )
1562        .unwrap();
1563        fs::write(
1564            sub.join("workflows")
1565                .join("scripts")
1566                .join("my-task-wf_run1.js"),
1567            "export const meta = {}",
1568        )
1569        .unwrap();
1570        // Project B: one session, with one malformed line we'll skip
1571        let b = tmp.path().join("-private-tmp-projB");
1572        fs::create_dir_all(&b).unwrap();
1573        write_session(
1574            &b,
1575            "session-ccc",
1576            &[
1577                r#"{"type":"user","uuid":"u3","timestamp":"2026-02-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1578                r#"NOT VALID JSON"#,
1579                r#"{"type":"assistant","uuid":"a3","timestamp":"2026-02-01T00:00:01Z","message":{"role":"assistant","content":"y"}}"#,
1580            ],
1581        );
1582        tmp
1583    }
1584
1585    #[test]
1586    fn list_projects_returns_directories_sorted_by_slug() {
1587        let tmp = fixture_root();
1588        let root = HistoryRoot::at(tmp.path());
1589        let projects = root.list_projects().expect("list projects");
1590        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1591        assert_eq!(slugs, ["-Users-josh-Code-projA", "-private-tmp-projB"]);
1592    }
1593
1594    #[test]
1595    fn list_projects_counts_sessions() {
1596        let tmp = fixture_root();
1597        let root = HistoryRoot::at(tmp.path());
1598        let projects = root.list_projects().expect("list");
1599        let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
1600        let b = projects.iter().find(|p| p.slug.contains("projB")).unwrap();
1601        assert_eq!(a.session_count, 2);
1602        assert_eq!(b.session_count, 1);
1603    }
1604
1605    #[test]
1606    fn list_projects_decodes_slug_to_filesystem_path() {
1607        let tmp = fixture_root();
1608        let root = HistoryRoot::at(tmp.path());
1609        let projects = root.list_projects().expect("list");
1610        let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
1611        assert_eq!(a.decoded_path, PathBuf::from("/Users/josh/Code/projA"));
1612    }
1613
1614    #[test]
1615    fn list_projects_returns_empty_when_root_missing() {
1616        let tmp = tempfile::tempdir().unwrap();
1617        let root = HistoryRoot::at(tmp.path().join("does-not-exist"));
1618        let projects = root.list_projects().expect("ok");
1619        assert!(projects.is_empty());
1620    }
1621
1622    #[test]
1623    fn list_sessions_filtered_by_slug() {
1624        let tmp = fixture_root();
1625        let root = HistoryRoot::at(tmp.path());
1626        let sessions = root
1627            .list_sessions(Some("-Users-josh-Code-projA"))
1628            .expect("list");
1629        let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1630        assert_eq!(ids, ["session-aaa", "session-bbb"]);
1631        assert!(
1632            sessions
1633                .iter()
1634                .all(|s| s.project_slug == "-Users-josh-Code-projA")
1635        );
1636    }
1637
1638    #[test]
1639    fn list_sessions_unfiltered_returns_union() {
1640        let tmp = fixture_root();
1641        let root = HistoryRoot::at(tmp.path());
1642        let sessions = root.list_sessions(None).expect("list");
1643        assert_eq!(sessions.len(), 3);
1644    }
1645
1646    #[test]
1647    fn session_summary_counts_only_user_and_assistant() {
1648        let tmp = fixture_root();
1649        let root = HistoryRoot::at(tmp.path());
1650        let sessions = root.list_sessions(Some("-Users-josh-Code-projA")).unwrap();
1651        let aaa = sessions
1652            .iter()
1653            .find(|s| s.session_id == "session-aaa")
1654            .unwrap();
1655        // 2 message entries (user + assistant); queue-operation and ai-title don't count.
1656        assert_eq!(aaa.message_count, 2);
1657        assert_eq!(aaa.title.as_deref(), Some("hello world"));
1658        assert_eq!(aaa.first_timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
1659    }
1660
1661    #[test]
1662    fn read_session_returns_typed_entries_and_skips_malformed_lines() {
1663        let tmp = fixture_root();
1664        let root = HistoryRoot::at(tmp.path());
1665        let log = root.read_session("session-ccc").expect("read");
1666        assert_eq!(log.session_id, "session-ccc");
1667        assert_eq!(log.project_slug, "-private-tmp-projB");
1668        // 3 lines in the file; 1 is malformed; expect 2 entries.
1669        assert_eq!(log.entries.len(), 2);
1670        assert!(matches!(log.entries[0], HistoryEntry::User { .. }));
1671        assert!(matches!(log.entries[1], HistoryEntry::Assistant { .. }));
1672    }
1673
1674    #[test]
1675    fn read_session_user_entry_carries_metadata() {
1676        let tmp = fixture_root();
1677        let root = HistoryRoot::at(tmp.path());
1678        let log = root.read_session("session-aaa").expect("read");
1679        match &log.entries[0] {
1680            HistoryEntry::User {
1681                uuid,
1682                timestamp,
1683                cwd,
1684                git_branch,
1685                ..
1686            } => {
1687                assert_eq!(uuid.as_deref(), Some("u1"));
1688                assert_eq!(timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
1689                assert_eq!(cwd.as_deref(), Some("/Users/josh/Code/projA"));
1690                assert_eq!(git_branch.as_deref(), Some("main"));
1691            }
1692            other => panic!("expected User entry, got {other:?}"),
1693        }
1694    }
1695
1696    #[test]
1697    fn parse_entry_populates_rest_with_unmodeled_fields() {
1698        let line = r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"/w","gitBranch":"main","message":{"role":"user","content":"hi"},"promptSource":"typed","entrypoint":"cli","isSidechain":false,"sessionId":"s1","parentUuid":null,"permissionMode":"default","version":"2.1.0"}"#;
1699        let entry = parse_entry(line).expect("parse");
1700        match &entry {
1701            HistoryEntry::User { rest, .. } => {
1702                assert_eq!(rest["promptSource"], "typed");
1703                assert_eq!(rest["permissionMode"], "default");
1704                assert_eq!(rest["version"], "2.1.0");
1705                // Consumed fields must not reappear in rest.
1706                for consumed in ["type", "uuid", "timestamp", "cwd", "gitBranch", "message"] {
1707                    assert!(!rest.contains_key(consumed), "{consumed} leaked into rest");
1708                }
1709            }
1710            other => panic!("expected User entry, got {other:?}"),
1711        }
1712    }
1713
1714    #[test]
1715    fn parse_entry_keeps_mistyped_field_in_rest() {
1716        // A `uuid` that isn't a string can't fill the typed field, but
1717        // it must survive in rest rather than being dropped.
1718        let line = r#"{"type":"assistant","uuid":42,"message":{"role":"assistant","content":"y"}}"#;
1719        let entry = parse_entry(line).expect("parse");
1720        match &entry {
1721            HistoryEntry::Assistant { uuid, rest, .. } => {
1722                assert_eq!(uuid.as_deref(), None);
1723                assert_eq!(rest["uuid"], 42);
1724            }
1725            other => panic!("expected Assistant entry, got {other:?}"),
1726        }
1727    }
1728
1729    #[test]
1730    fn from_line_is_the_public_face_of_parse_entry() {
1731        // Typed, unknown-type, and malformed lines all behave exactly
1732        // as they do through read_session's internal parser.
1733        let user = r#"{"type":"user","uuid":"u1","cwd":"/w","message":{"role":"user"}}"#;
1734        match HistoryEntry::from_line(user).expect("parse") {
1735            HistoryEntry::User { uuid, cwd, .. } => {
1736                assert_eq!(uuid.as_deref(), Some("u1"));
1737                assert_eq!(cwd.as_deref(), Some("/w"));
1738            }
1739            other => panic!("expected User entry, got {other:?}"),
1740        }
1741
1742        let unknown = r#"{"type":"ai-title","title":"t"}"#;
1743        match HistoryEntry::from_line(unknown).expect("parse") {
1744            HistoryEntry::Other { type_tag, raw } => {
1745                assert_eq!(type_tag, "ai-title");
1746                assert_eq!(raw["title"], "t");
1747            }
1748            other => panic!("expected Other entry, got {other:?}"),
1749        }
1750
1751        assert!(HistoryEntry::from_line("not json").is_err());
1752    }
1753
1754    #[test]
1755    fn typed_accessors_resolve_from_rest() {
1756        let line = r#"{"type":"user","uuid":"u1","message":{},"promptSource":"sdk","entrypoint":"sdk-cli","isSidechain":true,"isMeta":true,"sessionId":"s1","parentUuid":"p1"}"#;
1757        let entry = parse_entry(line).expect("parse");
1758        assert_eq!(entry.prompt_source(), Some("sdk"));
1759        assert_eq!(entry.entrypoint(), Some("sdk-cli"));
1760        assert_eq!(entry.is_sidechain(), Some(true));
1761        assert_eq!(entry.is_meta(), Some(true));
1762        assert_eq!(entry.session_id(), Some("s1"));
1763        assert_eq!(entry.parent_uuid(), Some("p1"));
1764        assert_eq!(
1765            entry.field("promptSource").and_then(Value::as_str),
1766            Some("sdk")
1767        );
1768    }
1769
1770    #[test]
1771    fn typed_accessors_return_none_when_fields_absent() {
1772        let line = r#"{"type":"assistant","uuid":"a1","message":{},"parentUuid":null}"#;
1773        let entry = parse_entry(line).expect("parse");
1774        assert_eq!(entry.prompt_source(), None);
1775        assert_eq!(entry.entrypoint(), None);
1776        assert_eq!(entry.is_meta(), None);
1777        assert_eq!(entry.is_sidechain(), None);
1778        assert_eq!(entry.session_id(), None);
1779        // parentUuid: null reads as None, same as absent.
1780        assert_eq!(entry.parent_uuid(), None);
1781        assert_eq!(entry.field("noSuchField"), None);
1782    }
1783
1784    #[test]
1785    fn field_and_accessors_resolve_on_other_variant() {
1786        let line = r#"{"type":"queue-operation","operation":"enqueue","sessionId":"s9"}"#;
1787        let entry = parse_entry(line).expect("parse");
1788        assert_eq!(
1789            entry.field("operation").and_then(Value::as_str),
1790            Some("enqueue")
1791        );
1792        assert_eq!(entry.session_id(), Some("s9"));
1793        assert_eq!(entry.prompt_source(), None);
1794    }
1795
1796    #[test]
1797    fn serialized_entry_reemits_rest_fields_at_top_level() {
1798        let line = r#"{"type":"user","uuid":"u1","message":{},"promptSource":"typed"}"#;
1799        let entry = parse_entry(line).expect("parse");
1800        let v = serde_json::to_value(&entry).expect("serialize");
1801        assert_eq!(v["kind"], "user");
1802        assert_eq!(v["promptSource"], "typed");
1803    }
1804
1805    // Prompt-history fixture: `<tmp>/projects` is the root and
1806    // `<tmp>/history.jsonl` sits next to it, mirroring ~/.claude.
1807    fn prompt_history_fixture(lines: &[&str]) -> (tempfile::TempDir, HistoryRoot) {
1808        let tmp = tempfile::tempdir().expect("tempdir");
1809        let projects = tmp.path().join("projects");
1810        fs::create_dir_all(&projects).unwrap();
1811        let mut f = fs::File::create(tmp.path().join("history.jsonl")).unwrap();
1812        for line in lines {
1813            writeln!(f, "{line}").unwrap();
1814        }
1815        let root = HistoryRoot::at(projects);
1816        (tmp, root)
1817    }
1818
1819    #[test]
1820    fn prompt_history_parses_fields_and_keeps_rest() {
1821        let (_tmp, root) = prompt_history_fixture(&[
1822            r#"{"display":"fix the tests","pastedContents":{"1":"snippet"},"timestamp":1781804723955,"project":"/Users/me/Code/projA","sessionId":"s1","futureField":true}"#,
1823        ]);
1824        let entries = root.prompt_history().expect("read");
1825        assert_eq!(entries.len(), 1);
1826        let e = &entries[0];
1827        assert_eq!(e.display.as_deref(), Some("fix the tests"));
1828        assert_eq!(e.timestamp_ms, Some(1781804723955));
1829        assert_eq!(e.project.as_deref(), Some("/Users/me/Code/projA"));
1830        assert_eq!(e.session_id.as_deref(), Some("s1"));
1831        assert_eq!(e.rest["pastedContents"]["1"], "snippet");
1832        assert_eq!(e.rest["futureField"], true);
1833        for consumed in ["display", "timestamp", "project", "sessionId"] {
1834            assert!(
1835                !e.rest.contains_key(consumed),
1836                "{consumed} leaked into rest"
1837            );
1838        }
1839    }
1840
1841    #[test]
1842    fn prompt_history_keeps_mistyped_timestamp_in_rest() {
1843        let (_tmp, root) =
1844            prompt_history_fixture(&[r#"{"display":"x","timestamp":"not-a-number"}"#]);
1845        let entries = root.prompt_history().expect("read");
1846        assert_eq!(entries[0].timestamp_ms, None);
1847        assert_eq!(entries[0].rest["timestamp"], "not-a-number");
1848    }
1849
1850    #[test]
1851    fn prompt_history_skips_malformed_lines_and_paginates() {
1852        let (_tmp, root) = prompt_history_fixture(&[
1853            r#"{"display":"one","timestamp":1}"#,
1854            r#"NOT JSON"#,
1855            r#"{"display":"two","timestamp":2}"#,
1856            r#"{"display":"three","timestamp":3}"#,
1857        ]);
1858        let all = root.prompt_history().expect("read");
1859        assert_eq!(all.len(), 3);
1860        // File (chronological) order preserved.
1861        assert_eq!(all[0].display.as_deref(), Some("one"));
1862        let page = root
1863            .prompt_history_with(&ListOptions {
1864                offset: 1,
1865                limit: Some(1),
1866                ..ListOptions::default()
1867            })
1868            .expect("read");
1869        assert_eq!(page.len(), 1);
1870        assert_eq!(page[0].display.as_deref(), Some("two"));
1871    }
1872
1873    #[test]
1874    fn prompt_history_missing_file_is_empty() {
1875        let tmp = tempfile::tempdir().unwrap();
1876        let projects = tmp.path().join("projects");
1877        fs::create_dir_all(&projects).unwrap();
1878        // No history.jsonl next to the projects root.
1879        let root = HistoryRoot::at(projects);
1880        assert!(root.prompt_history().expect("ok").is_empty());
1881    }
1882
1883    #[test]
1884    fn list_subagents_parses_ids_and_meta() {
1885        let tmp = fixture_root();
1886        let root = HistoryRoot::at(tmp.path());
1887        let subs = root.list_subagents("session-aaa").expect("list");
1888        assert_eq!(subs.len(), 1);
1889        let sub = &subs[0];
1890        assert_eq!(sub.agent_id, "abc123");
1891        let meta = sub.meta.as_ref().expect("meta parsed");
1892        assert_eq!(meta.agent_type.as_deref(), Some("general-purpose"));
1893        assert_eq!(meta.description.as_deref(), Some("audit the crate"));
1894        assert_eq!(meta.tool_use_id.as_deref(), Some("toolu_spawn1"));
1895        assert_eq!(meta.spawn_depth, Some(1));
1896        assert_eq!(meta.rest["futureField"], "kept");
1897    }
1898
1899    #[test]
1900    fn read_subagent_entries_carry_sidechain_attribution() {
1901        let tmp = fixture_root();
1902        let root = HistoryRoot::at(tmp.path());
1903        let entries = root.read_subagent("session-aaa", "abc123").expect("read");
1904        assert_eq!(entries.len(), 2);
1905        assert!(matches!(entries[0], HistoryEntry::User { .. }));
1906        assert_eq!(entries[0].is_sidechain(), Some(true));
1907        assert_eq!(
1908            entries[0].field("agentId").and_then(Value::as_str),
1909            Some("abc123")
1910        );
1911    }
1912
1913    #[test]
1914    fn read_subagent_unknown_agent_errors() {
1915        let tmp = fixture_root();
1916        let root = HistoryRoot::at(tmp.path());
1917        let err = root.read_subagent("session-aaa", "nope").unwrap_err();
1918        assert!(format!("{err}").contains("no subagent with id"));
1919    }
1920
1921    #[test]
1922    fn session_without_subdirectory_lists_empty() {
1923        let tmp = fixture_root();
1924        let root = HistoryRoot::at(tmp.path());
1925        assert!(root.list_subagents("session-bbb").expect("ok").is_empty());
1926        assert!(
1927            root.list_tool_results("session-bbb")
1928                .expect("ok")
1929                .is_empty()
1930        );
1931        assert!(root.list_workflows("session-bbb").expect("ok").is_empty());
1932    }
1933
1934    #[test]
1935    fn subdirectory_lookups_error_on_unknown_session() {
1936        let tmp = fixture_root();
1937        let root = HistoryRoot::at(tmp.path());
1938        let err = root.list_subagents("not-a-session").unwrap_err();
1939        assert!(matches!(err, Error::History { .. }));
1940        assert!(format!("{err}").contains("no session with id"));
1941    }
1942
1943    #[test]
1944    fn tool_result_list_and_read_round_trip() {
1945        let tmp = fixture_root();
1946        let root = HistoryRoot::at(tmp.path());
1947        let results = root.list_tool_results("session-aaa").expect("list");
1948        assert_eq!(results.len(), 1);
1949        assert_eq!(results[0].tool_use_id, "toolu_r1");
1950        assert_eq!(results[0].size_bytes, "spilled tool output".len() as u64);
1951        let content = root
1952            .read_tool_result("session-aaa", "toolu_r1")
1953            .expect("read");
1954        assert_eq!(content, "spilled tool output");
1955        let err = root
1956            .read_tool_result("session-aaa", "toolu_nope")
1957            .unwrap_err();
1958        assert!(format!("{err}").contains("no tool result with id"));
1959    }
1960
1961    #[test]
1962    fn workflow_list_links_script_and_read_parses_json() {
1963        let tmp = fixture_root();
1964        let root = HistoryRoot::at(tmp.path());
1965        let flows = root.list_workflows("session-aaa").expect("list");
1966        assert_eq!(flows.len(), 1);
1967        assert_eq!(flows[0].workflow_id, "wf_run1");
1968        let script = flows[0].script_path.as_ref().expect("script linked");
1969        assert!(script.ends_with("my-task-wf_run1.js"));
1970        let journal = root.read_workflow("session-aaa", "wf_run1").expect("read");
1971        assert_eq!(journal["runId"], "wf_run1");
1972        let err = root.read_workflow("session-aaa", "wf_nope").unwrap_err();
1973        assert!(format!("{err}").contains("no workflow with id"));
1974    }
1975
1976    #[test]
1977    fn read_session_other_entry_preserves_type_tag_and_raw() {
1978        let tmp = fixture_root();
1979        let root = HistoryRoot::at(tmp.path());
1980        let log = root.read_session("session-aaa").expect("read");
1981        // Find the queue-operation entry.
1982        let queue_op = log
1983            .entries
1984            .iter()
1985            .find(|e| matches!(e, HistoryEntry::Other { type_tag, .. } if type_tag == "queue-operation"))
1986            .expect("queue-operation entry");
1987        if let HistoryEntry::Other { raw, .. } = queue_op {
1988            assert_eq!(raw["operation"], "enqueue");
1989        }
1990    }
1991
1992    #[test]
1993    fn read_session_unknown_id_errors() {
1994        let tmp = fixture_root();
1995        let root = HistoryRoot::at(tmp.path());
1996        let err = root.read_session("not-a-real-session").unwrap_err();
1997        assert!(matches!(err, Error::History { .. }));
1998        assert!(format!("{err}").contains("no session with id"));
1999    }
2000
2001    #[test]
2002    fn find_session_returns_none_for_unknown_id() {
2003        let tmp = fixture_root();
2004        let root = HistoryRoot::at(tmp.path());
2005        let found = root.find_session("nope").expect("ok");
2006        assert!(found.is_none());
2007    }
2008
2009    #[test]
2010    fn find_session_locates_real_session() {
2011        let tmp = fixture_root();
2012        let root = HistoryRoot::at(tmp.path());
2013        let (path, slug) = root
2014            .find_session("session-ccc")
2015            .expect("ok")
2016            .expect("found");
2017        assert!(path.ends_with("session-ccc.jsonl"));
2018        assert_eq!(slug, "-private-tmp-projB");
2019    }
2020
2021    #[test]
2022    fn decode_slug_anchored_no_hyphens_in_components() {
2023        // Path with no literal hyphens -- both forms are structurally
2024        // identical at each boundary, so the algorithm picks the slash
2025        // (naive) form at each step. `is_decode_verified` depends on
2026        // whether /a/b/c/d exists; in CI it won't, so only assert shape.
2027        let (path, _verified) = decode_slug_anchored("-a-b-c-d");
2028        assert_eq!(path, PathBuf::from("/a/b/c/d"));
2029    }
2030
2031    /// Faithful slug for a real path, exactly as Claude Code encodes
2032    /// it. The anchored tests build fixtures under a tempdir whose own
2033    /// path contains encoded characters (macOS: `.tmpXXXX`, `/var`
2034    /// symlink components), so they exercise the walk from `/` down
2035    /// through real encoded components for free.
2036    #[cfg(unix)]
2037    fn slug_for(path: &Path) -> String {
2038        encode_path_slug(&path.to_string_lossy())
2039    }
2040
2041    #[cfg(unix)]
2042    #[test]
2043    fn decode_slug_anchored_single_hyphenated_segment() {
2044        // Build a real dir: tmp/foo-bar, then construct its slug.
2045        let tmp = tempfile::tempdir().unwrap();
2046        let dir = tmp.path().join("foo-bar");
2047        fs::create_dir_all(&dir).unwrap();
2048        let (decoded, is_verified) = decode_slug_anchored(&slug_for(&dir));
2049        assert_eq!(decoded, dir);
2050        assert!(is_verified);
2051    }
2052
2053    #[cfg(unix)]
2054    #[test]
2055    fn decode_slug_anchored_multiple_hyphenated_segments() {
2056        // Build: tmp/foo-bar/baz-qux
2057        let tmp = tempfile::tempdir().unwrap();
2058        let dir = tmp.path().join("foo-bar").join("baz-qux");
2059        fs::create_dir_all(&dir).unwrap();
2060        let (decoded, is_verified) = decode_slug_anchored(&slug_for(&dir));
2061        assert_eq!(decoded, dir);
2062        assert!(is_verified);
2063    }
2064
2065    #[cfg(unix)]
2066    #[test]
2067    fn decode_slug_anchored_dotted_component() {
2068        // The issue #738 shape: `github.com` encodes to `github-com`,
2069        // and the old walk had no branch for `.` -- it pushed `github`,
2070        // lost its anchor, and everything below degraded to the naive
2071        // split (`redismodule-rs` came out as `redismodule/rs`).
2072        let tmp = tempfile::tempdir().unwrap();
2073        let dir = tmp
2074            .path()
2075            .join("github.com")
2076            .join("owner")
2077            .join("redismodule-rs");
2078        fs::create_dir_all(&dir).unwrap();
2079        let (decoded, is_verified) = decode_slug_anchored(&slug_for(&dir));
2080        assert_eq!(decoded, dir);
2081        assert!(is_verified);
2082    }
2083
2084    #[cfg(unix)]
2085    #[test]
2086    fn decode_slug_anchored_underscore_and_space_components() {
2087        let tmp = tempfile::tempdir().unwrap();
2088        let dir = tmp.path().join("my_project").join("a b");
2089        fs::create_dir_all(&dir).unwrap();
2090        let (decoded, is_verified) = decode_slug_anchored(&slug_for(&dir));
2091        assert_eq!(decoded, dir);
2092        assert!(is_verified);
2093    }
2094
2095    #[cfg(unix)]
2096    #[test]
2097    fn decode_slug_anchored_prefers_literal_hyphen_on_collision() {
2098        // `foo-bar` and `foo.bar` encode identically. When both exist
2099        // the literal hyphen name wins, matching the old tiebreak and
2100        // keeping the result independent of read_dir order.
2101        let tmp = tempfile::tempdir().unwrap();
2102        fs::create_dir_all(tmp.path().join("foo-bar")).unwrap();
2103        fs::create_dir_all(tmp.path().join("foo.bar")).unwrap();
2104        let expected = tmp.path().join("foo-bar");
2105        let (decoded, is_verified) = decode_slug_anchored(&slug_for(&expected));
2106        assert_eq!(decoded, expected);
2107        assert!(is_verified);
2108    }
2109
2110    #[cfg(unix)]
2111    #[test]
2112    fn decode_slug_anchored_deleted_leaf_is_unverified() {
2113        // The leaf has to match a real entry too. The old walk pushed
2114        // the final component without any check, so a slug whose leaf
2115        // directory was deleted could still report verified.
2116        let tmp = tempfile::tempdir().unwrap();
2117        let gone = tmp.path().join("gone");
2118        let (decoded, is_verified) = decode_slug_anchored(&slug_for(&gone));
2119        assert_eq!(decoded, gone);
2120        assert!(!is_verified);
2121    }
2122
2123    #[cfg(unix)]
2124    #[test]
2125    fn decode_slug_anchored_miss_does_not_poison_the_remainder() {
2126        // A component the walk cannot enumerate (here: a directory
2127        // with search-only permissions) falls back to the naive split
2128        // for that component alone. The walk keeps enumerating below
2129        // it, so the hyphenated leaf still anchors -- under the old
2130        // algorithm one miss degraded everything after it.
2131        use std::os::unix::fs::PermissionsExt;
2132
2133        let tmp = tempfile::tempdir().unwrap();
2134        let locked = tmp.path().join("locked");
2135        let dir = locked.join("repo").join("claude-wrapper");
2136        fs::create_dir_all(&dir).unwrap();
2137        let slug = slug_for(&dir);
2138
2139        fs::set_permissions(&locked, fs::Permissions::from_mode(0o311)).unwrap();
2140        let result = decode_slug_anchored(&slug);
2141        // Restore before asserting so the tempdir can be cleaned up
2142        // even on assertion failure ordering changes.
2143        fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap();
2144
2145        let (decoded, is_verified) = result;
2146        assert_eq!(decoded, dir);
2147        assert!(!is_verified);
2148    }
2149
2150    #[test]
2151    fn decode_slug_anchored_root_slug_is_verified() {
2152        // `-` is the slug for `/` itself: nothing to match, nothing
2153        // to fail.
2154        let (path, verified) = decode_slug_anchored("-");
2155        assert_eq!(path, PathBuf::from("/"));
2156        assert!(verified);
2157    }
2158
2159    #[test]
2160    fn decode_slug_anchored_fallback_when_nothing_exists() {
2161        // No filesystem paths exist for this slug -- falls back to naive.
2162        let (path, verified) = decode_slug_anchored("-nonexistent-xyz-abc-def");
2163        assert_eq!(path, PathBuf::from("/nonexistent/xyz/abc/def"));
2164        assert!(!verified);
2165    }
2166
2167    #[cfg(unix)]
2168    #[test]
2169    fn decode_slug_anchored_real_world_issue_example() {
2170        // The exact real-world shape from issue #607: a hyphenated leaf
2171        // directory (claude-wrapper) under a non-hyphenated parent. The
2172        // naive decode would split it into .../claude/wrapper; anchoring
2173        // on disk keeps it whole.
2174        let tmp = tempfile::tempdir().unwrap();
2175        let dir = tmp.path().join("rust").join("claude-wrapper");
2176        fs::create_dir_all(&dir).unwrap();
2177        let (decoded, is_verified) = decode_slug_anchored(&slug_for(&dir));
2178        assert_eq!(decoded, dir);
2179        assert!(is_verified);
2180    }
2181
2182    // -- ListOptions / pagination -----------------------------------
2183
2184    /// Build a fixture with five projects of varying activity so
2185    /// recency sort and pagination have meaningful inputs.
2186    fn paginated_fixture() -> tempfile::TempDir {
2187        let tmp = tempfile::tempdir().unwrap();
2188        // Two empty projects (no .jsonl files), three with one each.
2189        for stem in ["-zzz-empty1", "-aaa-empty2"] {
2190            fs::create_dir_all(tmp.path().join(stem)).unwrap();
2191        }
2192        for (stem, ts, mtime) in [
2193            ("-bbb-proj", "2026-03-01T00:00:00Z", 1_700_000_000),
2194            ("-ccc-proj", "2026-04-01T00:00:00Z", 1_700_001_000),
2195            ("-ddd-proj", "2026-05-01T00:00:00Z", 1_700_002_000),
2196        ] {
2197            let dir = tmp.path().join(stem);
2198            fs::create_dir_all(&dir).unwrap();
2199            let session_path = write_session(
2200                &dir,
2201                "s1",
2202                &[&format!(
2203                    r#"{{"type":"user","uuid":"u","timestamp":"{ts}","message":{{"role":"user","content":"x"}}}}"#
2204                )],
2205            );
2206            set_mtime(&session_path, mtime);
2207        }
2208        tmp
2209    }
2210
2211    #[test]
2212    fn list_projects_with_include_empty_false_filters_them_out() {
2213        let tmp = paginated_fixture();
2214        let root = HistoryRoot::at(tmp.path());
2215        let projects = root
2216            .list_projects_with(&ListOptions {
2217                include_empty: false,
2218                ..Default::default()
2219            })
2220            .expect("list");
2221        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
2222        // Empty projects (-zzz-empty1 / -aaa-empty2) filtered out.
2223        assert_eq!(slugs, ["-bbb-proj", "-ccc-proj", "-ddd-proj"]);
2224    }
2225
2226    #[test]
2227    fn list_projects_with_default_includes_empty_for_bc() {
2228        // Default::default() must preserve legacy "include everything"
2229        // semantics so zero-arg list_projects() doesn't change behavior.
2230        let tmp = paginated_fixture();
2231        let root = HistoryRoot::at(tmp.path());
2232        let projects = root
2233            .list_projects_with(&ListOptions::default())
2234            .expect("list");
2235        assert_eq!(projects.len(), 5);
2236    }
2237
2238    #[test]
2239    fn list_projects_zero_arg_preserves_legacy_inclusion() {
2240        // The original list_projects() returned everything in slug order;
2241        // we must NOT regress that contract for existing callers.
2242        let tmp = paginated_fixture();
2243        let root = HistoryRoot::at(tmp.path());
2244        let projects = root.list_projects().expect("list");
2245        assert_eq!(projects.len(), 5);
2246        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
2247        assert_eq!(
2248            slugs,
2249            [
2250                "-aaa-empty2",
2251                "-bbb-proj",
2252                "-ccc-proj",
2253                "-ddd-proj",
2254                "-zzz-empty1",
2255            ]
2256        );
2257    }
2258
2259    #[test]
2260    fn list_projects_with_limit_caps_results() {
2261        let tmp = paginated_fixture();
2262        let root = HistoryRoot::at(tmp.path());
2263        let projects = root
2264            .list_projects_with(&ListOptions {
2265                limit: Some(2),
2266                include_empty: true,
2267                ..Default::default()
2268            })
2269            .expect("list");
2270        assert_eq!(projects.len(), 2);
2271    }
2272
2273    #[test]
2274    fn list_projects_with_offset_skips() {
2275        let tmp = paginated_fixture();
2276        let root = HistoryRoot::at(tmp.path());
2277        let projects = root
2278            .list_projects_with(&ListOptions {
2279                offset: 3,
2280                include_empty: true,
2281                ..Default::default()
2282            })
2283            .expect("list");
2284        // NameAsc default; skipping 3 from [aaa, bbb, ccc, ddd, zzz]
2285        // leaves [ddd, zzz].
2286        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
2287        assert_eq!(slugs, ["-ddd-proj", "-zzz-empty1"]);
2288    }
2289
2290    #[test]
2291    fn list_projects_with_offset_past_end_returns_empty() {
2292        let tmp = paginated_fixture();
2293        let root = HistoryRoot::at(tmp.path());
2294        let projects = root
2295            .list_projects_with(&ListOptions {
2296                offset: 99,
2297                include_empty: true,
2298                ..Default::default()
2299            })
2300            .expect("list");
2301        assert!(projects.is_empty());
2302    }
2303
2304    #[test]
2305    fn list_projects_with_recency_desc_sort() {
2306        let tmp = paginated_fixture();
2307        let root = HistoryRoot::at(tmp.path());
2308        // -ddd-proj has the newest session (May 2026), then -ccc, then -bbb.
2309        // The fixture writes them in order so filesystem mtimes also
2310        // progress. Filter empties so the tail isn't a no-mtime project.
2311        let projects = root
2312            .list_projects_with(&ListOptions {
2313                sort: ListSort::RecencyDesc,
2314                include_empty: false,
2315                ..Default::default()
2316            })
2317            .expect("list");
2318        let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
2319        assert_eq!(slugs, ["-ddd-proj", "-ccc-proj", "-bbb-proj"]);
2320    }
2321
2322    #[test]
2323    fn list_sessions_with_include_empty_false_filters_zero_message() {
2324        let tmp = tempfile::tempdir().unwrap();
2325        let dir = tmp.path().join("-proj");
2326        fs::create_dir_all(&dir).unwrap();
2327        // One real session.
2328        write_session(
2329            &dir,
2330            "real",
2331            &[
2332                r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2333            ],
2334        );
2335        // One orphan: just a queue-op, no user/assistant.
2336        write_session(
2337            &dir,
2338            "orphan",
2339            &[
2340                r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
2341            ],
2342        );
2343        let root = HistoryRoot::at(tmp.path());
2344        let sessions = root
2345            .list_sessions_with(
2346                Some("-proj"),
2347                &ListOptions {
2348                    include_empty: false,
2349                    ..Default::default()
2350                },
2351            )
2352            .expect("list");
2353        let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
2354        assert_eq!(ids, ["real"]);
2355    }
2356
2357    #[test]
2358    fn list_sessions_with_default_returns_orphans_for_bc() {
2359        let tmp = tempfile::tempdir().unwrap();
2360        let dir = tmp.path().join("-proj");
2361        fs::create_dir_all(&dir).unwrap();
2362        write_session(
2363            &dir,
2364            "orphan",
2365            &[
2366                r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
2367            ],
2368        );
2369        let root = HistoryRoot::at(tmp.path());
2370        let sessions = root
2371            .list_sessions_with(Some("-proj"), &ListOptions::default())
2372            .expect("list");
2373        assert_eq!(sessions.len(), 1);
2374        assert_eq!(sessions[0].message_count, 0);
2375    }
2376
2377    #[test]
2378    fn list_sessions_with_recency_desc_sort() {
2379        let tmp = tempfile::tempdir().unwrap();
2380        let dir = tmp.path().join("-proj");
2381        fs::create_dir_all(&dir).unwrap();
2382        let old_p = write_session(
2383            &dir,
2384            "old",
2385            &[
2386                r#"{"type":"user","uuid":"u","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2387            ],
2388        );
2389        let new_p = write_session(
2390            &dir,
2391            "new",
2392            &[
2393                r#"{"type":"user","uuid":"u","timestamp":"2026-12-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2394            ],
2395        );
2396        let mid_p = write_session(
2397            &dir,
2398            "mid",
2399            &[
2400                r#"{"type":"user","uuid":"u","timestamp":"2026-06-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2401            ],
2402        );
2403        set_mtime(&old_p, 1_700_000_000);
2404        set_mtime(&mid_p, 1_700_001_000);
2405        set_mtime(&new_p, 1_700_002_000);
2406        let root = HistoryRoot::at(tmp.path());
2407        let sessions = root
2408            .list_sessions_with(
2409                Some("-proj"),
2410                &ListOptions {
2411                    sort: ListSort::RecencyDesc,
2412                    ..Default::default()
2413                },
2414            )
2415            .expect("list");
2416        let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
2417        assert_eq!(ids, ["new", "mid", "old"]);
2418    }
2419
2420    #[test]
2421    fn list_sessions_with_limit_and_offset_combine() {
2422        let tmp = tempfile::tempdir().unwrap();
2423        let dir = tmp.path().join("-proj");
2424        fs::create_dir_all(&dir).unwrap();
2425        for i in 0..5 {
2426            write_session(
2427                &dir,
2428                &format!("s{i}"),
2429                &[&format!(
2430                    r#"{{"type":"user","uuid":"u","timestamp":"2026-01-0{i}T00:00:00Z","message":{{"role":"user","content":"x"}}}}"#
2431                )],
2432            );
2433        }
2434        let root = HistoryRoot::at(tmp.path());
2435        let sessions = root
2436            .list_sessions_with(
2437                Some("-proj"),
2438                &ListOptions {
2439                    offset: 1,
2440                    limit: Some(2),
2441                    ..Default::default()
2442                },
2443            )
2444            .expect("list");
2445        let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
2446        // NameAsc default: ids are s0..s4; skip 1, take 2 → ["s1","s2"].
2447        assert_eq!(ids, ["s1", "s2"]);
2448    }
2449
2450    // -- aiTitle parsing bug fix ---------------------------------------
2451
2452    #[test]
2453    fn session_summary_parses_ai_title_camelcase() {
2454        // Real claude-code writes the title under `aiTitle`, not
2455        // `title`. Regression test for the field-name bug.
2456        let tmp = tempfile::tempdir().unwrap();
2457        let dir = tmp.path().join("-proj");
2458        fs::create_dir_all(&dir).unwrap();
2459        write_session(
2460            &dir,
2461            "real-shape",
2462            &[
2463                r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2464                r#"{"type":"ai-title","aiTitle":"My Session","sessionId":"real-shape"}"#,
2465            ],
2466        );
2467        let root = HistoryRoot::at(tmp.path());
2468        let sessions = root.list_sessions(Some("-proj")).expect("list");
2469        let s = sessions
2470            .iter()
2471            .find(|s| s.session_id == "real-shape")
2472            .unwrap();
2473        assert_eq!(s.title.as_deref(), Some("My Session"));
2474    }
2475
2476    #[test]
2477    fn session_summary_legacy_title_field_still_works() {
2478        // Older fixtures used `title`; we still accept it as a fallback.
2479        let tmp = tempfile::tempdir().unwrap();
2480        let dir = tmp.path().join("-proj");
2481        fs::create_dir_all(&dir).unwrap();
2482        write_session(
2483            &dir,
2484            "legacy",
2485            &[
2486                r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2487                r#"{"type":"ai-title","title":"Legacy Form"}"#,
2488            ],
2489        );
2490        let root = HistoryRoot::at(tmp.path());
2491        let sessions = root.list_sessions(Some("-proj")).expect("list");
2492        let s = sessions.iter().find(|s| s.session_id == "legacy").unwrap();
2493        assert_eq!(s.title.as_deref(), Some("Legacy Form"));
2494    }
2495
2496    // -- forward slug derivation / sessions_for_path (#642) ----------
2497
2498    #[test]
2499    fn encode_path_slug_encodes_slash_and_dot() {
2500        assert_eq!(
2501            encode_path_slug("/Users/josh/Code/projA"),
2502            "-Users-josh-Code-projA"
2503        );
2504        // The #642 gap: a `.` in a path segment is encoded too.
2505        assert_eq!(
2506            encode_path_slug("/private/var/folders/T/tmp.AbC"),
2507            "-private-var-folders-T-tmp-AbC"
2508        );
2509        // The #649 gap: every non-alphanumeric char is encoded,
2510        // including `_`, spaces, and other separators -- matching the
2511        // CLI's project-dir naming.
2512        assert_eq!(
2513            encode_path_slug("/Users/me/genagent/claude_wrapper_ex"),
2514            "-Users-me-genagent-claude-wrapper-ex"
2515        );
2516        assert_eq!(
2517            encode_path_slug("/Users/me/My Project (v2)"),
2518            "-Users-me-My-Project--v2-"
2519        );
2520    }
2521
2522    #[test]
2523    fn project_slug_canonicalizes_and_encodes_dot() {
2524        let work = tempfile::tempdir().unwrap();
2525        let cwd = work.path().join("my.proj");
2526        fs::create_dir_all(&cwd).unwrap();
2527
2528        let slug = HistoryRoot::project_slug(&cwd);
2529        assert!(
2530            slug.contains("my-proj"),
2531            "dotted segment must encode '.' -> '-', got {slug}"
2532        );
2533        assert!(
2534            !slug.contains('.'),
2535            "no '.' may survive in the slug: {slug}"
2536        );
2537        assert!(
2538            !slug.contains('/'),
2539            "no '/' may survive in the slug: {slug}"
2540        );
2541    }
2542
2543    #[test]
2544    fn project_slug_canonicalizes_and_encodes_underscore() {
2545        // #649: an `_` in a path segment must encode to `-`, matching
2546        // the CLI's project-dir naming.
2547        let work = tempfile::tempdir().unwrap();
2548        let cwd = work.path().join("claude_wrapper_ex");
2549        fs::create_dir_all(&cwd).unwrap();
2550
2551        let slug = HistoryRoot::project_slug(&cwd);
2552        assert!(
2553            slug.contains("claude-wrapper-ex"),
2554            "underscored segment must encode '_' -> '-', got {slug}"
2555        );
2556        assert!(
2557            !slug.contains('_'),
2558            "no '_' may survive in the slug: {slug}"
2559        );
2560    }
2561
2562    #[test]
2563    fn sessions_for_path_finds_session_under_dotted_symlinked_cwd() {
2564        // Repro for #642. On macOS tempdirs live under /var -> /private/var
2565        // (a symlink), and the cwd here also has a '.' segment. claude
2566        // writes the session under the canonicalized, dot-encoded slug;
2567        // sessions_for_path must derive the same slug and find it.
2568        let projects = tempfile::tempdir().unwrap();
2569        let work = tempfile::tempdir().unwrap();
2570        let cwd = work.path().join("tmp.XYZ");
2571        fs::create_dir_all(&cwd).unwrap();
2572
2573        // Build the project dir using claude's derivation directly
2574        // (canonicalize + encode), independent of the method under test,
2575        // so a project_slug that skipped either step would find nothing.
2576        let canonical = fs::canonicalize(&cwd).unwrap();
2577        let expected_slug = encode_path_slug(&canonical.to_string_lossy());
2578        let proj_dir = projects.path().join(&expected_slug);
2579        fs::create_dir_all(&proj_dir).unwrap();
2580        write_session(
2581            &proj_dir,
2582            "sess-dot",
2583            &[
2584                r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"x","message":{"role":"user","content":"hi"}}"#,
2585            ],
2586        );
2587
2588        let root = HistoryRoot::at(projects.path());
2589        let sessions = root.sessions_for_path(&cwd).expect("enumerate");
2590        assert_eq!(
2591            sessions.len(),
2592            1,
2593            "should find the session for the dotted/symlinked cwd"
2594        );
2595        assert_eq!(sessions[0].session_id, "sess-dot");
2596    }
2597}