# cxpak v0.4.0 Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add three features: parse caching for faster re-runs, a `diff` command combining git diff with trace-style context, and a `clean` subcommand. Homebrew tap setup is a separate repo task.

**Architecture:** Caching slots into the existing Scanner→Parser pipeline — after scanning, check cache before parsing each file. The diff command reuses Scanner, Parser, Index, DependencyGraph, and Output, adding a new git diff layer. The clean command is trivial (delete `.cxpak/`). Stale cleanup in overview is updated to preserve `.cxpak/cache/`.

**Tech Stack:** Rust, serde/serde_json (already deps), git2 (already a dep), tree-sitter, clap

---

### Task 1: Cache data structures and serialization

**Files:**
- Create: `src/cache/mod.rs`
- Modify: `src/main.rs` (add `pub mod cache;`)
- Modify: `src/lib.rs` (add `pub mod cache;`)

**Step 1: Write the failing test**

In `src/cache/mod.rs`:

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_cache_roundtrip() {
        let entry = CacheEntry {
            relative_path: "src/main.rs".to_string(),
            mtime: 1234567890,
            size_bytes: 42,
            language: Some("rust".to_string()),
            token_count: 100,
            parse_result: None,
        };
        let cache = FileCache {
            version: CACHE_VERSION,
            entries: vec![entry],
        };
        let json = serde_json::to_string(&cache).unwrap();
        let deserialized: FileCache = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.entries.len(), 1);
        assert_eq!(deserialized.entries[0].relative_path, "src/main.rs");
        assert_eq!(deserialized.entries[0].mtime, 1234567890);
        assert_eq!(deserialized.entries[0].size_bytes, 42);
        assert_eq!(deserialized.entries[0].token_count, 100);
    }

    #[test]
    fn test_cache_with_parse_result() {
        use crate::parser::language::*;
        let pr = ParseResult {
            symbols: vec![Symbol {
                name: "compute".to_string(),
                kind: SymbolKind::Function,
                visibility: Visibility::Public,
                signature: "pub fn compute(x: i32) -> i32".to_string(),
                body: "x * 2".to_string(),
                start_line: 1,
                end_line: 3,
            }],
            imports: vec![Import {
                source: "std::io".to_string(),
                names: vec!["Read".to_string()],
            }],
            exports: vec![],
        };
        let entry = CacheEntry {
            relative_path: "src/lib.rs".to_string(),
            mtime: 999,
            size_bytes: 10,
            language: Some("rust".to_string()),
            token_count: 50,
            parse_result: Some(pr),
        };
        let cache = FileCache {
            version: CACHE_VERSION,
            entries: vec![entry],
        };
        let json = serde_json::to_string(&cache).unwrap();
        let deserialized: FileCache = serde_json::from_str(&json).unwrap();
        let pr = deserialized.entries[0].parse_result.as_ref().unwrap();
        assert_eq!(pr.symbols[0].name, "compute");
        assert_eq!(pr.imports[0].source, "std::io");
    }

    #[test]
    fn test_cache_version_mismatch_returns_empty() {
        let json = r#"{"version":0,"entries":[]}"#;
        let loaded: FileCache = serde_json::from_str(json).unwrap();
        // Version 0 doesn't match CACHE_VERSION, so caller should discard
        assert_ne!(loaded.version, CACHE_VERSION);
    }
}
```

**Step 2: Run test to verify it fails**

Run: `cargo test cache::tests --verbose`
Expected: FAIL — `cache` module doesn't exist yet.

**Step 3: Write minimal implementation**

Create `src/cache/mod.rs`:

```rust
use crate::parser::language::{ParseResult, Symbol, SymbolKind, Visibility, Import, Export};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;

pub const CACHE_VERSION: u32 = 1;

#[derive(Debug, Serialize, Deserialize)]
pub struct FileCache {
    pub version: u32,
    pub entries: Vec<CacheEntry>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct CacheEntry {
    pub relative_path: String,
    pub mtime: i64,
    pub size_bytes: u64,
    pub language: Option<String>,
    pub token_count: usize,
    pub parse_result: Option<ParseResult>,
}

impl FileCache {
    pub fn new() -> Self {
        Self {
            version: CACHE_VERSION,
            entries: Vec::new(),
        }
    }

    /// Load cache from disk. Returns empty cache if file missing, corrupt, or version mismatch.
    pub fn load(cache_dir: &Path) -> Self {
        let cache_file = cache_dir.join("cache.json");
        let content = match std::fs::read_to_string(&cache_file) {
            Ok(c) => c,
            Err(_) => return Self::new(),
        };
        match serde_json::from_str::<FileCache>(&content) {
            Ok(cache) if cache.version == CACHE_VERSION => cache,
            _ => Self::new(),
        }
    }

    /// Save cache to disk. Creates cache_dir if needed.
    pub fn save(&self, cache_dir: &Path) -> std::io::Result<()> {
        std::fs::create_dir_all(cache_dir)?;
        let json = serde_json::to_string(self)?;
        std::fs::write(cache_dir.join("cache.json"), json)
    }

    /// Build a lookup map: relative_path -> &CacheEntry
    pub fn as_map(&self) -> HashMap<&str, &CacheEntry> {
        self.entries
            .iter()
            .map(|e| (e.relative_path.as_str(), e))
            .collect()
    }
}
```

This requires `Serialize` and `Deserialize` derives on `ParseResult`, `Symbol`, `SymbolKind`, `Visibility`, `Import`, and `Export`. Add `#[derive(Serialize, Deserialize)]` to each in `src/parser/language.rs`. The `serde` crate with `derive` feature is already in `Cargo.toml`.

Add to `src/parser/language.rs` — add `use serde::{Deserialize, Serialize};` at the top, and add `Serialize, Deserialize` to each derive block:
- `Symbol`: add `Serialize, Deserialize`
- `SymbolKind`: add `Serialize, Deserialize`
- `Visibility`: add `Serialize, Deserialize`
- `Import`: add `Serialize, Deserialize`
- `Export`: add `Serialize, Deserialize`
- `ParseResult`: add `Serialize, Deserialize`

Add `pub mod cache;` to both `src/main.rs` (after `pub mod util;`) and `src/lib.rs` (after `pub mod scanner;`).

**Step 4: Run test to verify it passes**

Run: `cargo test cache::tests --verbose`
Expected: PASS (3 tests)

**Step 5: Commit**

```bash
git add src/cache/mod.rs src/parser/language.rs src/main.rs src/lib.rs
git commit -m "feat: add cache data structures with serde serialization"
```

---

### Task 2: Cache load/save integration tests

**Files:**
- Modify: `src/cache/mod.rs` (add tests)

**Step 1: Write the failing tests**

Add to the `tests` module in `src/cache/mod.rs`:

```rust
    #[test]
    fn test_save_and_load_cache() {
        let dir = tempfile::TempDir::new().unwrap();
        let cache_dir = dir.path().join(".cxpak").join("cache");

        let mut cache = FileCache::new();
        cache.entries.push(CacheEntry {
            relative_path: "src/main.rs".to_string(),
            mtime: 1234567890,
            size_bytes: 100,
            language: Some("rust".to_string()),
            token_count: 50,
            parse_result: None,
        });

        cache.save(&cache_dir).unwrap();
        assert!(cache_dir.join("cache.json").exists());

        let loaded = FileCache::load(&cache_dir);
        assert_eq!(loaded.version, CACHE_VERSION);
        assert_eq!(loaded.entries.len(), 1);
        assert_eq!(loaded.entries[0].relative_path, "src/main.rs");
    }

    #[test]
    fn test_load_missing_returns_empty() {
        let dir = tempfile::TempDir::new().unwrap();
        let loaded = FileCache::load(dir.path());
        assert_eq!(loaded.version, CACHE_VERSION);
        assert!(loaded.entries.is_empty());
    }

    #[test]
    fn test_load_corrupt_returns_empty() {
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::create_dir_all(dir.path()).unwrap();
        std::fs::write(dir.path().join("cache.json"), "not json").unwrap();
        let loaded = FileCache::load(dir.path());
        assert_eq!(loaded.version, CACHE_VERSION);
        assert!(loaded.entries.is_empty());
    }

    #[test]
    fn test_as_map() {
        let mut cache = FileCache::new();
        cache.entries.push(CacheEntry {
            relative_path: "a.rs".to_string(),
            mtime: 1,
            size_bytes: 10,
            language: None,
            token_count: 5,
            parse_result: None,
        });
        cache.entries.push(CacheEntry {
            relative_path: "b.rs".to_string(),
            mtime: 2,
            size_bytes: 20,
            language: None,
            token_count: 10,
            parse_result: None,
        });
        let map = cache.as_map();
        assert!(map.contains_key("a.rs"));
        assert!(map.contains_key("b.rs"));
        assert_eq!(map["a.rs"].mtime, 1);
    }
```

**Step 2: Run test to verify it passes** (these should already pass since implementation was done in Task 1)

Run: `cargo test cache::tests --verbose`
Expected: PASS (7 tests total)

**Step 3: Commit**

```bash
git add src/cache/mod.rs
git commit -m "test: add cache load/save integration tests"
```

---

### Task 3: Wire cache into overview command

**Files:**
- Modify: `src/commands/overview.rs`

The parse loop (lines 58-71) currently reads+parses every file unconditionally. Change it to:
1. Load cache from `.cxpak/cache/`
2. For each file, check mtime+size against cache entry
3. On hit: use cached `ParseResult` and `token_count`
4. On miss: parse as before, store result in new cache
5. Save updated cache after all parsing

**Step 1: Write the failing test**

Add integration test in `tests/overview_test.rs`:

```rust
#[test]
fn test_cache_created_on_first_run() {
    let repo = make_temp_repo();

    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["overview", "--tokens", "50k"])
        .arg(repo.path())
        .assert()
        .success();

    let cache_file = repo.path().join(".cxpak").join("cache").join("cache.json");
    assert!(cache_file.exists(), "cache.json should be created after first run");

    let content = std::fs::read_to_string(&cache_file).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
    assert!(parsed["entries"].as_array().unwrap().len() > 0, "cache should have entries");
}

#[test]
fn test_cache_survives_stale_cleanup() {
    let repo = make_large_temp_repo();

    // First run creates cache + detail files
    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["overview", "--tokens", "500"])
        .arg(repo.path())
        .assert()
        .success();

    let cache_file = repo.path().join(".cxpak").join("cache").join("cache.json");
    assert!(cache_file.exists(), "cache should exist after first run");

    // Second run should clean detail files but preserve cache
    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["overview", "--tokens", "500"])
        .arg(repo.path())
        .assert()
        .success();

    assert!(cache_file.exists(), "cache should survive stale cleanup between runs");
}
```

**Step 2: Run test to verify it fails**

Run: `cargo test test_cache_created_on_first_run --verbose`
Expected: FAIL — no cache file created.

**Step 3: Write implementation**

In `src/commands/overview.rs`:

1. Add `use crate::cache::FileCache;` at top.

2. Change the stale cleanup block (lines 155-159) from:
```rust
    let cxpak_dir = path.join(".cxpak");
    if cxpak_dir.exists() {
        std::fs::remove_dir_all(&cxpak_dir)?;
    }
```
to:
```rust
    // Clean stale .cxpak/ output files but preserve cache/
    let cxpak_dir = path.join(".cxpak");
    if cxpak_dir.exists() {
        for entry in std::fs::read_dir(&cxpak_dir)? {
            let entry = entry?;
            let name = entry.file_name();
            if name != "cache" {
                let p = entry.path();
                if p.is_dir() {
                    std::fs::remove_dir_all(&p)?;
                } else {
                    std::fs::remove_file(&p)?;
                }
            }
        }
    }
```

3. Replace the parse loop (lines 55-75) with cache-aware version:
```rust
    // 2. Parse (with caching)
    if verbose {
        eprintln!("cxpak: parsing with tree-sitter");
    }
    let cache_dir = path.join(".cxpak").join("cache");
    let existing_cache = FileCache::load(&cache_dir);
    let cache_map = existing_cache.as_map();
    let registry = LanguageRegistry::new();
    let mut parse_results = HashMap::new();
    let mut new_cache = FileCache::new();

    for file in &files {
        // Check cache: mtime + size match?
        let metadata = std::fs::metadata(&file.absolute_path).ok();
        let mtime = metadata
            .as_ref()
            .and_then(|m| m.modified().ok())
            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
            .map(|d| d.as_secs() as i64)
            .unwrap_or(0);

        if let Some(cached) = cache_map.get(file.relative_path.as_str()) {
            if cached.mtime == mtime && cached.size_bytes == file.size_bytes {
                // Cache hit
                if let Some(pr) = &cached.parse_result {
                    parse_results.insert(file.relative_path.clone(), pr.clone());
                }
                new_cache.entries.push(crate::cache::CacheEntry {
                    relative_path: file.relative_path.clone(),
                    mtime,
                    size_bytes: file.size_bytes,
                    language: file.language.clone(),
                    token_count: cached.token_count,
                    parse_result: cached.parse_result.clone(),
                });
                continue;
            }
        }

        // Cache miss — parse
        if let Some(lang_name) = &file.language {
            if let Some(lang) = registry.get(lang_name) {
                let source = std::fs::read_to_string(&file.absolute_path).unwrap_or_default();
                let mut parser = tree_sitter::Parser::new();
                if parser.set_language(&lang.ts_language()).is_ok() {
                    if let Some(tree) = parser.parse(&source, None) {
                        let result = lang.extract(&source, &tree);
                        parse_results.insert(file.relative_path.clone(), result.clone());
                        new_cache.entries.push(crate::cache::CacheEntry {
                            relative_path: file.relative_path.clone(),
                            mtime,
                            size_bytes: file.size_bytes,
                            language: file.language.clone(),
                            token_count: counter.count_or_zero(&source),
                            parse_result: Some(result),
                        });
                        continue;
                    }
                }
            }
        }

        // Not parseable — cache entry without parse result
        new_cache.entries.push(crate::cache::CacheEntry {
            relative_path: file.relative_path.clone(),
            mtime,
            size_bytes: file.size_bytes,
            language: file.language.clone(),
            token_count: 0,
            parse_result: None,
        });
    }

    // Save updated cache
    if let Err(e) = new_cache.save(&cache_dir) {
        if verbose {
            eprintln!("cxpak: warning: failed to save cache: {e}");
        }
    }
```

**Step 4: Run tests to verify**

Run: `cargo test --verbose`
Expected: ALL PASS including the two new cache tests.

**Step 5: Commit**

```bash
git add src/commands/overview.rs tests/overview_test.rs
git commit -m "feat: wire parse cache into overview command"
```

---

### Task 4: Wire cache into trace command

**Files:**
- Modify: `src/commands/trace.rs`

Same pattern as Task 3 — replace the parse loop with cache-aware version. The trace command's parse loop is at lines 45-58, identical structure to overview's.

**Step 1: Write the failing test**

Add to `tests/trace_test.rs`:

```rust
#[test]
fn test_trace_creates_cache() {
    let repo = make_trace_repo();

    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["trace", "--tokens", "50k", "compute"])
        .current_dir(repo.path())
        .assert()
        .success();

    let cache_file = repo.path().join(".cxpak").join("cache").join("cache.json");
    assert!(cache_file.exists(), "cache.json should be created after trace");
}

#[test]
fn test_trace_second_run_uses_cache() {
    let repo = make_trace_repo();

    // First run
    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["trace", "--tokens", "50k", "compute"])
        .current_dir(repo.path())
        .assert()
        .success();

    // Second run should produce identical output
    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["trace", "--tokens", "50k", "compute"])
        .current_dir(repo.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("compute"));
}
```

**Step 2: Run to verify failure**

Run: `cargo test test_trace_creates_cache --verbose`
Expected: FAIL — no cache created by trace.

**Step 3: Apply same cache pattern to trace.rs**

In `src/commands/trace.rs`:

1. Add `use crate::cache::FileCache;` at top.

2. Replace the parse loop (lines 42-58) with the same cache-aware pattern used in overview (adapted — trace uses `path` from its argument, and has `current_dir` as `.` sometimes). Use `path` as the repo root for cache location.

The logic is identical to Task 3's parse loop. Copy the same pattern: load cache, check mtime+size, parse on miss, save cache. Cache dir is `path.join(".cxpak").join("cache")`.

**Step 4: Run tests**

Run: `cargo test --verbose`
Expected: ALL PASS

**Step 5: Commit**

```bash
git add src/commands/trace.rs tests/trace_test.rs
git commit -m "feat: wire parse cache into trace command"
```

---

### Task 5: Extract shared parse-with-cache function

**Files:**
- Create: `src/cache/parse.rs`
- Modify: `src/cache/mod.rs` (add `pub mod parse;`)
- Modify: `src/commands/overview.rs` (use shared function)
- Modify: `src/commands/trace.rs` (use shared function)

The parse+cache logic is duplicated between overview and trace. Extract it to a shared function.

**Step 1: Create the shared function**

Create `src/cache/parse.rs`:

```rust
use crate::budget::counter::TokenCounter;
use crate::cache::{CacheEntry, FileCache};
use crate::parser::language::ParseResult;
use crate::parser::LanguageRegistry;
use crate::scanner::ScannedFile;
use std::collections::HashMap;
use std::path::Path;

/// Parse all files with caching. Returns parse results map and saves updated cache.
pub fn parse_with_cache(
    files: &[ScannedFile],
    repo_root: &Path,
    counter: &TokenCounter,
    verbose: bool,
) -> HashMap<String, ParseResult> {
    let cache_dir = repo_root.join(".cxpak").join("cache");
    let existing_cache = FileCache::load(&cache_dir);
    let cache_map = existing_cache.as_map();
    let registry = LanguageRegistry::new();
    let mut parse_results = HashMap::new();
    let mut new_cache = FileCache::new();

    for file in files {
        let mtime = file_mtime(&file.absolute_path);

        if let Some(cached) = cache_map.get(file.relative_path.as_str()) {
            if cached.mtime == mtime && cached.size_bytes == file.size_bytes {
                if let Some(pr) = &cached.parse_result {
                    parse_results.insert(file.relative_path.clone(), pr.clone());
                }
                new_cache.entries.push(CacheEntry {
                    relative_path: file.relative_path.clone(),
                    mtime,
                    size_bytes: file.size_bytes,
                    language: file.language.clone(),
                    token_count: cached.token_count,
                    parse_result: cached.parse_result.clone(),
                });
                continue;
            }
        }

        if let Some(lang_name) = &file.language {
            if let Some(lang) = registry.get(lang_name) {
                let source = std::fs::read_to_string(&file.absolute_path).unwrap_or_default();
                let mut parser = tree_sitter::Parser::new();
                if parser.set_language(&lang.ts_language()).is_ok() {
                    if let Some(tree) = parser.parse(&source, None) {
                        let result = lang.extract(&source, &tree);
                        parse_results.insert(file.relative_path.clone(), result.clone());
                        new_cache.entries.push(CacheEntry {
                            relative_path: file.relative_path.clone(),
                            mtime,
                            size_bytes: file.size_bytes,
                            language: file.language.clone(),
                            token_count: counter.count_or_zero(&source),
                            parse_result: Some(result),
                        });
                        continue;
                    }
                }
            }
        }

        new_cache.entries.push(CacheEntry {
            relative_path: file.relative_path.clone(),
            mtime,
            size_bytes: file.size_bytes,
            language: file.language.clone(),
            token_count: 0,
            parse_result: None,
        });
    }

    if let Err(e) = new_cache.save(&cache_dir) {
        if verbose {
            eprintln!("cxpak: warning: failed to save cache: {e}");
        }
    }

    parse_results
}

fn file_mtime(path: &Path) -> i64 {
    std::fs::metadata(path)
        .ok()
        .and_then(|m| m.modified().ok())
        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}
```

**Step 2: Update overview.rs and trace.rs**

Replace the inline cache+parse blocks in both commands with:

```rust
    let parse_results = crate::cache::parse::parse_with_cache(&files, path, &counter, verbose);
```

Remove the now-unused `LanguageRegistry` import and the old parse loop from each command. Remove `use crate::parser::LanguageRegistry;` from both files.

**Step 3: Run all tests**

Run: `cargo test --verbose`
Expected: ALL PASS — behavior unchanged, code deduplicated.

**Step 4: Commit**

```bash
git add src/cache/mod.rs src/cache/parse.rs src/commands/overview.rs src/commands/trace.rs
git commit -m "refactor: extract shared parse_with_cache function"
```

---

### Task 6: `cxpak clean` subcommand

**Files:**
- Modify: `src/cli/mod.rs` (add `Clean` variant)
- Modify: `src/main.rs` (handle `Clean`)
- Create: `src/commands/clean.rs`
- Modify: `src/commands/mod.rs` (add `pub mod clean;`)

**Step 1: Write the failing test**

Add to `tests/overview_test.rs` (reuses `make_temp_repo`):

```rust
#[test]
fn test_clean_removes_cxpak_dir() {
    let repo = make_large_temp_repo();

    // First run to create .cxpak/
    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["overview", "--tokens", "500"])
        .arg(repo.path())
        .assert()
        .success();

    assert!(repo.path().join(".cxpak").exists());

    // Clean should remove it
    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["clean"])
        .arg(repo.path())
        .assert()
        .success();

    assert!(!repo.path().join(".cxpak").exists(), ".cxpak/ should be deleted by clean");
}

#[test]
fn test_clean_noop_when_no_cxpak() {
    let repo = make_temp_repo();

    // Should succeed even with no .cxpak/
    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["clean"])
        .arg(repo.path())
        .assert()
        .success();
}
```

**Step 2: Run to verify failure**

Run: `cargo test test_clean_removes --verbose`
Expected: FAIL — `clean` subcommand doesn't exist.

**Step 3: Implement**

In `src/cli/mod.rs`, add to the `Commands` enum:

```rust
    /// Remove .cxpak/ directory (cache + output files)
    Clean {
        #[arg(default_value = ".")]
        path: PathBuf,
    },
```

Create `src/commands/clean.rs`:

```rust
use std::path::Path;

pub fn run(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let cxpak_dir = path.join(".cxpak");
    if cxpak_dir.exists() {
        std::fs::remove_dir_all(&cxpak_dir)?;
        eprintln!("cxpak: removed {}", cxpak_dir.display());
    } else {
        eprintln!("cxpak: nothing to clean");
    }
    Ok(())
}
```

Add `pub mod clean;` to `src/commands/mod.rs`.

In `src/main.rs`, add the match arm:

```rust
        Commands::Clean { path } => commands::clean::run(path),
```

**Step 4: Run tests**

Run: `cargo test --verbose`
Expected: ALL PASS

**Step 5: Commit**

```bash
git add src/cli/mod.rs src/main.rs src/commands/clean.rs src/commands/mod.rs tests/overview_test.rs
git commit -m "feat: add cxpak clean subcommand"
```

---

### Task 7: Diff command — git diff layer

**Files:**
- Create: `src/commands/diff.rs`
- Modify: `src/commands/mod.rs` (add `pub mod diff;`)

This task implements the core git diff extraction: given a repo path and optional ref, use `git2` to produce a list of changed files with their diff hunks.

**Step 1: Write the failing test**

In `src/commands/diff.rs`:

```rust
#[cfg(test)]
mod tests {
    use super::*;

    fn make_diff_repo() -> tempfile::TempDir {
        let dir = tempfile::TempDir::new().unwrap();
        let repo = git2::Repository::init(dir.path()).unwrap();
        let sig = git2::Signature::now("Test", "test@test.com").unwrap();

        std::fs::create_dir_all(dir.path().join("src")).unwrap();
        std::fs::write(dir.path().join("src/main.rs"), "fn main() {}\n").unwrap();

        let mut index = repo.index().unwrap();
        index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None).unwrap();
        index.write().unwrap();
        let tree_id = index.write_tree().unwrap();
        let tree = repo.find_tree(tree_id).unwrap();
        repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]).unwrap();

        dir
    }

    #[test]
    fn test_extract_diff_no_changes() {
        let repo_dir = make_diff_repo();
        let changes = extract_changes(repo_dir.path(), None).unwrap();
        assert!(changes.is_empty(), "no changes should be detected");
    }

    #[test]
    fn test_extract_diff_modified_file() {
        let repo_dir = make_diff_repo();
        // Modify a file after commit
        std::fs::write(repo_dir.path().join("src/main.rs"), "fn main() { println!(\"hi\"); }\n").unwrap();

        let changes = extract_changes(repo_dir.path(), None).unwrap();
        assert_eq!(changes.len(), 1);
        assert_eq!(changes[0].path, "src/main.rs");
        assert!(!changes[0].diff_text.is_empty());
    }
}
```

**Step 2: Run to verify failure**

Run: `cargo test commands::diff --verbose`
Expected: FAIL — module doesn't exist.

**Step 3: Implement**

Create `src/commands/diff.rs`:

```rust
use std::path::Path;

pub struct FileChange {
    pub path: String,
    pub diff_text: String,
}

/// Extract changed files and their diffs.
/// If `git_ref` is None, diffs working tree against HEAD.
/// If `git_ref` is Some, diffs that ref against HEAD.
pub fn extract_changes(
    repo_path: &Path,
    git_ref: Option<&str>,
) -> Result<Vec<FileChange>, Box<dyn std::error::Error>> {
    let repo = git2::Repository::open(repo_path)?;

    let head_commit = repo.head()?.peel_to_commit()?;
    let head_tree = head_commit.tree()?;

    let diff = match git_ref {
        Some(refname) => {
            // Diff between ref and HEAD
            let obj = repo.revparse_single(refname)?;
            let ref_commit = obj.peel_to_commit()?;
            let ref_tree = ref_commit.tree()?;
            repo.diff_tree_to_tree(Some(&ref_tree), Some(&head_tree), None)?
        }
        None => {
            // Diff working tree against HEAD
            repo.diff_tree_to_workdir_with_index(Some(&head_tree), None)?
        }
    };

    let mut changes: Vec<FileChange> = Vec::new();

    diff.foreach(
        &mut |delta, _| {
            if let Some(path) = delta.new_file().path() {
                let path_str = path.to_string_lossy().to_string();
                // Only add if not already present (dedup)
                if !changes.iter().any(|c| c.path == path_str) {
                    changes.push(FileChange {
                        path: path_str,
                        diff_text: String::new(),
                    });
                }
            }
            true
        },
        None,
        None,
        Some(&mut |delta, _hunk, line| {
            if let Some(path) = delta.new_file().path() {
                let path_str = path.to_string_lossy().to_string();
                if let Some(change) = changes.iter_mut().find(|c| c.path == path_str) {
                    let prefix = match line.origin() {
                        '+' => "+",
                        '-' => "-",
                        ' ' => " ",
                        _ => "",
                    };
                    let content = std::str::from_utf8(line.content()).unwrap_or("");
                    change.diff_text.push_str(prefix);
                    change.diff_text.push_str(content);
                }
            }
            true
        }),
    )?;

    // Remove entries with empty diffs (e.g., binary files)
    changes.retain(|c| !c.diff_text.is_empty());

    Ok(changes)
}
```

Add `pub mod diff;` to `src/commands/mod.rs`.

**Step 4: Run tests**

Run: `cargo test commands::diff --verbose`
Expected: PASS

**Step 5: Commit**

```bash
git add src/commands/diff.rs src/commands/mod.rs
git commit -m "feat: add git diff extraction for diff command"
```

---

### Task 8: Diff command — full pipeline with context

**Files:**
- Modify: `src/commands/diff.rs` (add `run()` function)
- Modify: `src/cli/mod.rs` (add `Diff` variant)
- Modify: `src/main.rs` (handle `Diff`)

**Step 1: Write the failing integration test**

Create `tests/diff_test.rs`:

```rust
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::TempDir;

fn make_diff_repo() -> TempDir {
    let dir = TempDir::new().unwrap();
    let repo = git2::Repository::init(dir.path()).unwrap();
    let sig = git2::Signature::now("Test", "test@test.com").unwrap();

    let src_dir = dir.path().join("src");
    std::fs::create_dir_all(&src_dir).unwrap();
    std::fs::write(
        src_dir.join("lib.rs"),
        "pub fn compute(x: i32) -> i32 {\n    x * 2\n}\n",
    )
    .unwrap();
    std::fs::write(
        src_dir.join("main.rs"),
        "use crate::compute;\n\nfn main() {\n    let result = compute(21);\n    println!(\"{}\", result);\n}\n",
    )
    .unwrap();
    std::fs::write(
        dir.path().join("Cargo.toml"),
        "[package]\nname = \"diff_test\"\nversion = \"0.1.0\"\n",
    )
    .unwrap();

    let mut index = repo.index().unwrap();
    index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None).unwrap();
    index.write().unwrap();
    let tree_id = index.write_tree().unwrap();
    let tree = repo.find_tree(tree_id).unwrap();
    repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[]).unwrap();

    dir
}

#[test]
fn test_diff_no_changes() {
    let repo = make_diff_repo();

    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["diff", "--tokens", "50k"])
        .current_dir(repo.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("No changes"));
}

#[test]
fn test_diff_shows_changes() {
    let repo = make_diff_repo();

    // Modify a file
    std::fs::write(
        repo.path().join("src/lib.rs"),
        "pub fn compute(x: i32) -> i32 {\n    x * 3\n}\n",
    )
    .unwrap();

    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["diff", "--tokens", "50k"])
        .current_dir(repo.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("lib.rs"))
        .stdout(predicate::str::contains("x * 3"));
}

#[test]
fn test_diff_includes_context() {
    let repo = make_diff_repo();

    // Modify lib.rs — main.rs uses compute, so it should appear in context
    std::fs::write(
        repo.path().join("src/lib.rs"),
        "pub fn compute(x: i32) -> i32 {\n    x * 3\n}\n",
    )
    .unwrap();

    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["diff", "--tokens", "50k"])
        .current_dir(repo.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("Context"));
}

#[test]
fn test_diff_json_format() {
    let repo = make_diff_repo();

    std::fs::write(
        repo.path().join("src/lib.rs"),
        "pub fn compute(x: i32) -> i32 {\n    x * 3\n}\n",
    )
    .unwrap();

    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["diff", "--tokens", "50k", "--format", "json"])
        .current_dir(repo.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("\"metadata\""));
}

#[test]
fn test_diff_xml_format() {
    let repo = make_diff_repo();

    std::fs::write(
        repo.path().join("src/lib.rs"),
        "pub fn compute(x: i32) -> i32 {\n    x * 3\n}\n",
    )
    .unwrap();

    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["diff", "--tokens", "50k", "--format", "xml"])
        .current_dir(repo.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("<cxpak>"));
}

#[test]
fn test_diff_with_ref() {
    let repo = make_diff_repo();

    // Make a second commit
    std::fs::write(
        repo.path().join("src/lib.rs"),
        "pub fn compute(x: i32) -> i32 {\n    x * 3\n}\n",
    )
    .unwrap();
    let git_repo = git2::Repository::open(repo.path()).unwrap();
    let sig = git2::Signature::now("Test", "test@test.com").unwrap();
    let mut index = git_repo.index().unwrap();
    index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None).unwrap();
    index.write().unwrap();
    let tree_id = index.write_tree().unwrap();
    let tree = git_repo.find_tree(tree_id).unwrap();
    let head = git_repo.head().unwrap().peel_to_commit().unwrap();
    git_repo.commit(Some("HEAD"), &sig, &sig, "second commit", &tree, &[&head]).unwrap();

    // Diff HEAD~1 against HEAD should show the change
    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["diff", "--tokens", "50k", "--ref", "HEAD~1"])
        .current_dir(repo.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("lib.rs"));
}

#[test]
fn test_diff_out_flag() {
    let repo = make_diff_repo();
    let out_dir = TempDir::new().unwrap();
    let out_file = out_dir.path().join("diff.md");

    std::fs::write(
        repo.path().join("src/lib.rs"),
        "pub fn compute(x: i32) -> i32 {\n    x * 3\n}\n",
    )
    .unwrap();

    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["diff", "--tokens", "50k", "--out", out_file.to_str().unwrap()])
        .current_dir(repo.path())
        .assert()
        .success();

    let content = std::fs::read_to_string(&out_file).unwrap();
    assert!(content.contains("lib.rs"));
}

#[test]
fn test_diff_not_git_repo() {
    let dir = TempDir::new().unwrap();

    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["diff", "--tokens", "50k"])
        .current_dir(dir.path())
        .assert()
        .failure()
        .stderr(predicate::str::contains("not a git repository"));
}

#[test]
fn test_diff_verbose() {
    let repo = make_diff_repo();

    std::fs::write(
        repo.path().join("src/lib.rs"),
        "pub fn compute(x: i32) -> i32 {\n    x * 3\n}\n",
    )
    .unwrap();

    Command::new(assert_cmd::cargo_bin!("cxpak"))
        .args(["diff", "--tokens", "50k", "--verbose"])
        .current_dir(repo.path())
        .assert()
        .success()
        .stderr(predicate::str::contains("cxpak:"));
}
```

**Step 2: Run to verify failure**

Run: `cargo test diff_test --verbose`
Expected: FAIL — `diff` subcommand doesn't exist.

**Step 3: Implement the full diff pipeline**

Add to `src/cli/mod.rs` — new variant in `Commands` enum:

```rust
    /// Show token-budgeted change summary with dependency context
    Diff {
        #[arg(long)]
        tokens: String,
        #[arg(long)]
        out: Option<PathBuf>,
        #[arg(long, default_value = "markdown")]
        format: OutputFormat,
        #[arg(long)]
        verbose: bool,
        #[arg(long)]
        all: bool,
        /// Git ref to diff against (default: HEAD for working tree changes)
        #[arg(long, name = "ref")]
        git_ref: Option<String>,
        #[arg(default_value = ".")]
        path: PathBuf,
    },
```

Add the `run()` function in `src/commands/diff.rs`:

```rust
use crate::budget::counter::TokenCounter;
use crate::budget::degrader;
use crate::cli::OutputFormat;
use crate::index::graph::DependencyGraph;
use crate::index::CodebaseIndex;
use crate::output::{self, OutputSections};
use crate::scanner::Scanner;
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::Path;

pub fn run(
    path: &Path,
    token_budget: usize,
    format: &OutputFormat,
    out: Option<&Path>,
    verbose: bool,
    all: bool,
    git_ref: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
    let counter = TokenCounter::new();

    // 1. Extract git changes
    let changes = extract_changes(path, git_ref)?;

    if changes.is_empty() {
        let msg = "No changes detected.\n";
        match out {
            Some(p) => std::fs::write(p, msg)?,
            None => {
                let stdout = std::io::stdout();
                let mut handle = stdout.lock();
                handle.write_all(msg.as_bytes())?;
            }
        }
        return Ok(());
    }

    if verbose {
        eprintln!("cxpak: {} changed file(s)", changes.len());
    }

    // 2. Scan + parse (for context gathering)
    if verbose {
        eprintln!("cxpak: scanning {}", path.display());
    }
    let scanner = Scanner::new(path)?;
    let files = scanner.scan()?;
    if verbose {
        eprintln!("cxpak: found {} files", files.len());
    }

    let parse_results = crate::cache::parse::parse_with_cache(&files, path, &counter, verbose);

    if verbose {
        eprintln!("cxpak: parsed {} files", parse_results.len());
    }

    let index = CodebaseIndex::build(files, parse_results, &counter);

    // 3. Build dependency graph, find context files
    let graph = crate::commands::trace::build_dependency_graph(&index);
    let changed_paths: HashSet<&str> = changes.iter().map(|c| c.path.as_str()).collect();

    let context_paths: HashSet<String> = if all {
        let start: Vec<&str> = changed_paths.iter().copied().collect();
        graph.reachable_from(&start)
    } else {
        let mut one_hop: HashSet<String> = changed_paths.iter().map(|&s| s.to_string()).collect();
        for &file in &changed_paths {
            if let Some(deps) = graph.dependencies(file) {
                one_hop.extend(deps.iter().cloned());
            }
            for dep in graph.dependents(file) {
                one_hop.insert(dep.to_string());
            }
        }
        one_hop
    };

    // Exclude changed files from context (they're shown in the diff)
    let context_only: HashSet<String> = context_paths
        .into_iter()
        .filter(|p| !changed_paths.contains(p.as_str()))
        .collect();

    if verbose {
        eprintln!(
            "cxpak: {} context file(s) from dependency graph",
            context_only.len()
        );
    }

    // 4. Budget allocation: diff first, remaining goes to context
    let mut diff_section = String::new();
    let ref_display = git_ref.unwrap_or("HEAD (working tree)");
    diff_section.push_str(&format!("Changes vs {}:\n\n", ref_display));

    for change in &changes {
        diff_section.push_str(&format!("### {}\n\n```diff\n{}\n```\n\n", change.path, change.diff_text));
    }

    let diff_tokens = counter.count(&diff_section);
    let context_budget = token_budget.saturating_sub(diff_tokens).saturating_sub(200);

    // Truncate diff if it exceeds budget
    let (diff_output, _, _) = degrader::truncate_to_budget(
        &diff_section,
        token_budget.saturating_sub(200),
        &counter,
        "diff",
    );

    // 5. Render context
    let mut context_section = String::new();
    if context_budget > 0 && !context_only.is_empty() {
        for file in &index.files {
            if !context_only.contains(&file.relative_path) {
                continue;
            }
            if let Some(pr) = &file.parse_result {
                let public_syms: Vec<_> = pr.symbols.iter()
                    .filter(|s| s.visibility == crate::parser::language::Visibility::Public)
                    .collect();
                if !public_syms.is_empty() {
                    context_section.push_str(&format!("### {}\n\n", file.relative_path));
                    for sym in public_syms {
                        context_section.push_str(&format!("```\n{}\n```\n\n", sym.signature));
                    }
                }
            }
        }

        let (truncated, _, _) = degrader::truncate_to_budget(
            &context_section,
            context_budget,
            &counter,
            "context",
        );
        context_section = truncated;
    }

    // 6. Build metadata
    let mut metadata = String::new();
    metadata.push_str(&format!("- **Comparing against:** {}\n", ref_display));
    metadata.push_str(&format!("- **Changed files:** {}\n", changes.len()));
    for change in &changes {
        metadata.push_str(&format!("  - `{}`\n", change.path));
    }
    metadata.push_str(&format!("- **Context files:** {}\n", context_only.len()));

    // 7. Assemble into OutputSections and render
    let sections = OutputSections {
        metadata,
        directory_tree: String::new(),
        module_map: String::new(),
        dependency_graph: String::new(),
        key_files: diff_output,       // reuse key_files slot for diff content
        signatures: context_section,   // reuse signatures slot for context
        git_context: String::new(),
    };

    let rendered = output::render(&sections, format);

    match out {
        Some(out_path) => {
            std::fs::write(out_path, &rendered)?;
            if verbose {
                eprintln!("cxpak: written to {}", out_path.display());
            }
        }
        None => {
            let stdout = std::io::stdout();
            let mut handle = stdout.lock();
            handle.write_all(rendered.as_bytes())?;
        }
    }

    Ok(())
}
```

**Important:** The `build_dependency_graph` function is currently private in `trace.rs`. Make it `pub` so `diff.rs` can call it:

In `src/commands/trace.rs`, change:
```rust
fn build_dependency_graph(index: &CodebaseIndex) -> DependencyGraph {
```
to:
```rust
pub fn build_dependency_graph(index: &CodebaseIndex) -> DependencyGraph {
```

In `src/main.rs`, add the `Diff` match arm:

```rust
        Commands::Diff {
            tokens,
            out,
            format,
            verbose,
            all,
            git_ref,
            path,
        } => {
            let token_budget = match parse_token_count(tokens) {
                Ok(0) => {
                    eprintln!("Error: --tokens must be greater than 0");
                    std::process::exit(1);
                }
                Ok(n) => n,
                Err(e) => {
                    eprintln!("Error: {e}");
                    std::process::exit(1);
                }
            };
            commands::diff::run(
                path,
                token_budget,
                format,
                out.as_deref(),
                *verbose,
                *all,
                git_ref.as_deref(),
            )
        }
```

**Step 4: Run all tests**

Run: `cargo test --verbose`
Expected: ALL PASS

**Step 5: Commit**

```bash
git add src/commands/diff.rs src/commands/trace.rs src/commands/mod.rs src/cli/mod.rs src/main.rs tests/diff_test.rs
git commit -m "feat: add diff command with dependency context"
```

---

### Task 9: Homebrew tap setup

**Files:**
- This is a separate repository task, not in the cxpak codebase.

**Step 1: Create the tap repository**

Create `lyubomir-bozhinov/homebrew-tap` on GitHub with a single file `Formula/cxpak.rb` containing the formula template from the design doc (use current version 0.3.0 as placeholder — the release workflow will update it).

**Step 2: Add the update-homebrew job to release.yml**

Modify `.github/workflows/release.yml` — add a new job after `release`:

```yaml
  update-homebrew:
    needs: release
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Download all artifacts
        uses: actions/download-artifact@v4
        with:
          path: artifacts
          merge-multiple: true

      - name: Compute SHAs and update formula
        env:
          TAP_TOKEN: ${{ secrets.TAP_TOKEN }}
        run: |
          VERSION="${GITHUB_REF_NAME#v}"

          SHA_LINUX_X86=$(sha256sum artifacts/cxpak-x86_64-unknown-linux-gnu.tar.gz | cut -d' ' -f1)
          SHA_LINUX_ARM=$(sha256sum artifacts/cxpak-aarch64-unknown-linux-gnu.tar.gz | cut -d' ' -f1)
          SHA_MAC_X86=$(sha256sum artifacts/cxpak-x86_64-apple-darwin.tar.gz | cut -d' ' -f1)
          SHA_MAC_ARM=$(sha256sum artifacts/cxpak-aarch64-apple-darwin.tar.gz | cut -d' ' -f1)

          git clone https://x-access-token:${TAP_TOKEN}@github.com/lyubomir-bozhinov/homebrew-tap.git tap
          cd tap

          cat > Formula/cxpak.rb << FORMULA
          class Cxpak < Formula
            desc "Token-budgeted codebase context for LLMs"
            homepage "https://github.com/lyubomir-bozhinov/cxpak"
            version "${VERSION}"

            on_macos do
              if Hardware::CPU.arm?
                url "https://github.com/lyubomir-bozhinov/cxpak/releases/download/v${VERSION}/cxpak-aarch64-apple-darwin.tar.gz"
                sha256 "${SHA_MAC_ARM}"
              else
                url "https://github.com/lyubomir-bozhinov/cxpak/releases/download/v${VERSION}/cxpak-x86_64-apple-darwin.tar.gz"
                sha256 "${SHA_MAC_X86}"
              end
            end

            on_linux do
              if Hardware::CPU.arm?
                url "https://github.com/lyubomir-bozhinov/cxpak/releases/download/v${VERSION}/cxpak-aarch64-unknown-linux-gnu.tar.gz"
                sha256 "${SHA_LINUX_ARM}"
              else
                url "https://github.com/lyubomir-bozhinov/cxpak/releases/download/v${VERSION}/cxpak-x86_64-unknown-linux-gnu.tar.gz"
                sha256 "${SHA_LINUX_X86}"
              end
            end

            def install
              bin.install "cxpak"
            end

            test do
              system "#{bin}/cxpak", "--help"
            end
          end
          FORMULA

          git config user.name "GitHub Actions"
          git config user.email "actions@github.com"
          git add Formula/cxpak.rb
          git commit -m "Update cxpak to ${VERSION}"
          git push
```

**Step 3: Create TAP_TOKEN secret**

Create a GitHub PAT with `repo` scope for the tap repository and add it as `TAP_TOKEN` secret in the cxpak repository settings.

**Step 4: Commit**

```bash
git add .github/workflows/release.yml
git commit -m "feat: add Homebrew tap auto-update to release workflow"
```

---

### Task 10: Update README, version bump, fmt + clippy

**Files:**
- Modify: `README.md`
- Modify: `Cargo.toml` (version bump to 0.4.0)

**Step 1: Update README**

Add diff command examples after the trace examples:

```markdown
# Show changes with dependency context
cxpak diff --tokens 50k .

# Diff against a specific ref
cxpak diff --tokens 50k --ref main .

# Full dependency graph context
cxpak diff --tokens 50k --all .
```

Add a section about caching:

```markdown
## Caching

cxpak caches parse results in `.cxpak/cache/` to speed up re-runs. The cache is keyed on file modification time and size — when a file changes, it's automatically re-parsed.

To clear the cache and all output files:

```bash
cxpak clean .
```
```

Add Homebrew installation option:

```markdown
## Installation

```bash
# Via Homebrew (macOS/Linux)
brew tap lyubomir-bozhinov/tap
brew install cxpak

# Via cargo
cargo install cxpak
```
```

**Step 2: Bump version**

In `Cargo.toml`, change `version = "0.3.0"` to `version = "0.4.0"`.

**Step 3: Run fmt + clippy + tests**

```bash
cargo fmt -- --check
cargo clippy --all-targets -- -D warnings
cargo test --verbose
```

Fix any issues.

**Step 4: Commit**

```bash
git add README.md Cargo.toml
git commit -m "chore: update README with diff/caching/homebrew, bump to v0.4.0"
```

---

## Task Summary

| Task | Description | New files | Key tests |
|------|-------------|-----------|-----------|
| 1 | Cache data structures + serde | `src/cache/mod.rs` | roundtrip, parse result serialization |
| 2 | Cache load/save tests | — | save+load, missing, corrupt, as_map |
| 3 | Wire cache into overview | — | cache created, survives cleanup |
| 4 | Wire cache into trace | — | cache created, second run works |
| 5 | Extract shared parse_with_cache | `src/cache/parse.rs` | all existing tests still pass |
| 6 | `cxpak clean` subcommand | `src/commands/clean.rs` | removes .cxpak/, noop when missing |
| 7 | Diff: git diff layer | `src/commands/diff.rs` | no changes, modified file |
| 8 | Diff: full pipeline + CLI | `tests/diff_test.rs` | 9 integration tests |
| 9 | Homebrew tap setup | `.github/workflows/release.yml` | manual brew install test |
| 10 | README + version bump | — | fmt, clippy, all tests |
