# 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.

**Synthetic resume entries:** Claude Code writes its own placeholder turns to the `.jsonl` on non-interactive resume — an `isMeta:true` "Continue from where you left off." user line and a `model:"<synthetic>"` "No response requested." assistant reply. The UI hides these; `session::is_synthetic_entry` detects them and they're dropped from both `message_count` (`session.rs`) and the `--show` transcript (`collect_messages` in `output.rs`) so output matches the UI. Keep both call sites using that one predicate.

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.
-->