# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
`csess` is a Rust CLI that lists Claude Code sessions for a folder and its subprojects by reading the session `.jsonl` logs under `~/.claude/projects`.
## Commands
```bash
cargo build # debug build
cargo build --release # release (strip + lto, see Cargo.toml)
cargo test # unit tests (in-module) + integration tests (tests/cli.rs)
cargo test parse_session # run tests matching a name
cargo test --test cli # only the integration tests
cargo fmt # rustfmt (config: rustfmt.toml)
cargo clippy
cargo run -- <args> # e.g. cargo run -- ~/my-works --period today --json
```
## Definition of done
Before considering any change complete, run all of these and ensure they pass:
```bash
cargo fmt --check
cargo clippy -- -D warnings
cargo test
```
Treat clippy warnings as errors — fix them, don't silence them with `#[allow(...)]` unless there's a documented reason. Don't leave `dbg!`, stray `println!`/`eprintln!` (outside intended output paths), or commented-out code behind. "It compiles" is not done; the three checks above are.
## Updating the user's installed binary
The user runs `csess` from `~/.cargo/bin/csess`, which is a **separate copy** from `target/`. Rebuilding (`cargo build`) does NOT update it — after a fix, the user keeps seeing old behavior until reinstalled. To update the binary they actually run:
```bash
cargo install --path . --force
```
## Cutting a release
Releases are built by `.github/workflows/release.yml`, triggered **only by pushing a `v*` tag** (musl + gnu binaries attached to a GitHub Release). There is no other release path — building locally does not publish anything. To cut one:
1. Bump `version` in `Cargo.toml` (semver) and add a matching `## [x.y.z] - YYYY-MM-DD` section to `CHANGELOG.md`.
Also update the "verified with Claude Code X.Y.Z" literal in `src/cli.rs` (the `concat!` in `#[command(version = ...)]`) to the `version` field seen in recent session `.jsonl` files — this is what `csess --version` reports, so format drift in a future Claude Code release can be traced to the version it was last tested against.
2. Commit (`git commit -am "release: vX.Y.Z"`), ensure `main` is up to date, push.
3. Tag and push the tag — this is what triggers the build:
```bash
git tag vX.Y.Z
git push origin vX.Y.Z
```
4. Verify the run in GitHub Actions and the resulting Release.
Remote is the **personal** account: `git@github-personal:sibincbaby/csess.git`.
### Publishing to crates.io
As of the `publish` job in `release.yml`, pushing a `v*` tag now publishes to crates.io automatically (needs the repo secret `CARGO_REGISTRY_TOKEN`). The manual `cargo publish` below is the fallback / first-time path. The `Cargo.toml` already carries the required metadata (`description`, `repository`, `homepage`, `license`, `readme`, `keywords`, `categories`) and a `LICENSE` file exists. To publish a new version after bumping:
```bash
cargo publish --dry-run # verify packaging
cargo publish # uploads to crates.io (irreversible — only yankable, never deletable)
```
Credentials live in `~/.cargo/credentials.toml` (set once via `cargo login`). Published as `csess` → users install with `cargo install csess`.
## Architecture
The whole pipeline is the numbered steps in `run()` in `src/main.rs`; each module owns one stage.
**The core trick (`discovery.rs`):** Claude stores each project's sessions in a directory named by encoding the real cwd with `/` → `-` (e.g. `/home/sibin/my-works` → `-home-sibin-my-works`). This encoding is lossy/ambiguous — a real dir named `my-works-backup` and a subdir `my-works/backup` can collide. So discovery is **two-phase**:
1. `dir_matches` — loose, fast pre-filter on the encoded dir name. Deliberately *over-includes* dashed siblings.
2. `path_under` — strict, component-wise check against each session's real `cwd` (read from inside the `.jsonl`) to drop the false positives.
Never collapse these into one step or "fix" the over-inclusion in phase 1 — the encoded name genuinely can't distinguish the cases; only the real cwd inside the file can. The `path_under_excludes_dashed_sibling` test guards this.
**Parsing (`session.rs`):** `parse_session` scans a `.jsonl` line by line, tolerating malformed lines (skips them). It pulls `cwd`/`gitBranch`/`version`/first `timestamp` from whichever line has them first, counts user+assistant messages, and derives the display name as `summary` → else first real user prompt → else `(no name)`. `last_active` is the file mtime (not parsed from content). Files with no usable content return `Ok(None)`. `is_meta_text` skips `<command-*>`/`<bash-*>`/`Caveat:` lines so session names come from genuine prompts.
**Filtering/sorting (`filter.rs`):** Time bounds resolve as period preset first, then explicit `--since`/`--until` override (`parse_when` accepts `30m`/`24h`/`7d` or `YYYY-MM-DD`). Search is fuzzy (skim matcher) over `name + cwd`, returning score-ranked results; when `--search` is active and no explicit `--sort` is given, score order is preserved instead of re-sorting.
**Output (`output.rs`):** `--json` emits the full `Session` structs (serde); default is a `comfy-table` view with relative times and a trailing count. `--show` renders one session's transcript (human or `--json`) via `collect_messages`, which `-n` tails to the last N and `--before <uuid>` pages backwards (drop that message and everything after it, then apply `-n`) — a drift-free scroll-up cursor for lazy-loading chat UIs. The session *list* has the same idea via `--after <id>` (step 8 in `main.rs`): drop everything up to and including that session in the current sort order, then apply `-n` — keyed by id, not offset, so a session bumping to the top between pages won't dup or skip a row. Unknown id is a no-op.
**Synthetic / meta entries:** On non-interactive resume Claude Code writes two placeholder turns — an `isMeta:true` "Continue from where you left off." user line and a `model:"<synthetic>"` "No response requested." assistant reply. `session::is_synthetic_entry` drops **only the `<synthetic>` reply** (matched on that model sentinel — reliable and language-independent, unlike matching the reply text), from both `message_count` (`session.rs`) and the `--show` transcript (`collect_messages` in `output.rs`). Keep both call sites using that one predicate. Everything with `isMeta:true` — the "Continue…" line included, plus skill-load / image-read / attachment echoes and `/loop` heartbeats — is **surfaced**, tagged `is_meta:true`, so consumers classify/suppress it themselves. See the verbatim passthrough fields on `Message` (`is_meta`/`is_sidechain`/`user_type`/`entry_type`/`tool_use_result`). The resume user line and its `<synthetic>` reply are also paired by `parentUuid` (reply.parentUuid == userline.uuid), if a future change ever needs to re-pair them structurally.
Parsing runs in parallel across files via `rayon` (`par_iter` in `main.rs`); keep `parse_session` free of shared mutable state.
## Conventions
- **Errors:** use `anyhow::Result`; `main` prints `error: {e:#}` and exits non-zero. Per-file parse failures warn to stderr and are skipped, never fatal. Add context at boundaries with `.context(...)`/`.with_context(...)` rather than bubbling bare errors.
- **No `unwrap`/`expect` outside `#[cfg(test)]`.** In non-test code, propagate with `?` or contextualize via `anyhow`. `expect` is acceptable only for genuine invariants that cannot fail, and must carry a message explaining why.
- **Dependencies:** prefer what's already here (`rayon`, `serde`, `comfy-table`, the skim fuzzy matcher, `assert_cmd`, `anyhow`) and the standard library. Don't add a new crate without a clear reason; if you do, note why.
- **Toolchain:** edition and MSRV are whatever `Cargo.toml` declares — match them and don't use features newer than the pinned edition/MSRV. <!-- set/confirm the exact edition + MSRV here -->
- `--projects-dir` is a hidden flag that overrides `~/.claude/projects` — the integration tests in `tests/cli.rs` rely on it to seed a temp root. Don't change or remove flags that tests depend on without updating the tests.
- Each `src/*.rs` module keeps its own `#[cfg(test)]` unit tests; end-to-end behavior is covered in `tests/cli.rs` via `assert_cmd`.
<!--
Keep this file high-signal: it loads into context on every turn. When you refactor a
module, update its paragraph above so the guidance never drifts from the code — stale
instructions are worse than none.
-->