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