claude_storage 1.0.0

CLI tool for exploring Claude Code filesystem storage
Documentation
//! Shared test utilities for `claude_storage` integration tests.
//!
//! Provides pre-compiled binary resolution to avoid cargo compilation
//! during test execution. Each `cargo run` inside a test triggers a full
//! compilation cycle (300s+), causing timeouts under workspace-wide runs.
//!
//! Fix(issue-claude-storage-timeout)
//! Root cause: 85 `Command::new("cargo").args(["run", ...])` calls across
//! 13 test files each triggered cargo compilation during test execution.
//! Under workspace-wide nextest runs, the 300s timeout was exceeded.
//! Pitfall: Never use `cargo run` or `cargo build` inside tests — use
//! `cargo_bin!()` to get the pre-compiled binary path from nextest.
//!
//! # Binary Name Coupling
//!
//! The helper below and the `[[bin]]` entries in `Cargo.toml` are tightly
//! coupled:
//!
//! | Binary           | `[[bin]] name`   | helper fn  | location        |
//! |------------------|-----------------|------------|-----------------|
//! | `claude_storage` | `claude_storage` | `clg_cmd`  | `common/mod.rs` |
//! | `clg`            | `clg`            | `clg_cmd`  | `common/mod.rs` |
//!
//! Both binaries have separate source files (`src/main.rs` and `src/bin/clg.rs`)
//! but both delegate to `cli_main::run()`. Tests use `clg_cmd()`
//! which resolves the `clg` alias — exercising the alias path through Cargo.
//!
//! If the binary is renamed, three things must change atomically:
//! 1. `Cargo.toml` — `[[bin]] name`
//! 2. The `cargo_bin!("<name>")` call in the helper
//! 3. The helper function name itself
//!
//! A partial rename compiles locally (cached artefact) but breaks on a
//! clean build because `cargo_bin!` panics when the binary is not found.

/// Return a Command pointing to the pre-compiled `clg` binary.
///
/// Uses `cargo_bin!()` macro which resolves to the binary built by
/// nextest/cargo-test BEFORE test execution. No compilation at test time.
pub fn clg_cmd() -> std::process::Command
{
  std::process::Command::new( assert_cmd::cargo::cargo_bin!( "clg" ) )
}


/// Write N synthetic conversation JSONL entries into `<root>/<project_id>/<session_id>.jsonl`.
///
/// Entries alternate user/assistant. Both types use fully-valid formats that
/// `Entry::from_json_line()` can parse:
/// - User: `"message":{"role":"user","content":"entry N"}`
/// - Assistant: `"message":{"role":"assistant","model":"...","id":"...","content":[...]}`
///   with top-level `"requestId"` required by the assistant parser.
///
/// Fix(issue-023)
/// Root cause: The previous format used `"content":"entry N"` for assistant entries
/// (a string), but `parse_assistant_message` requires content to be an array of blocks,
/// and requires top-level `requestId`, `message.model`, and `message.id`. Without
/// these fields, assistant entries silently fail to parse and are dropped by
/// `load_entries()`, making it impossible to test assistant-filtering behavior.
/// Pitfall: Synthetic test sessions must match the production JSON schema exactly.
/// When adding required fields to a parser, update all test helpers simultaneously.
///
/// # Panics
///
/// Panics if directory creation or file write fails.
#[allow(dead_code)]
pub fn write_test_session(
  root : &std::path::Path,
  project_id : &str,
  session_id : &str,
  entry_count : usize,
)
{
  use std::io::Write as _;

  let dir = root.join( "projects" ).join( project_id );
  std::fs::create_dir_all( &dir ).expect( "create project dir" );
  let path = dir.join( format!( "{session_id}.jsonl" ) );
  let mut file = std::fs::File::create( &path ).expect( "create session file" );

  for i in 0..entry_count
  {
    if i % 2 == 0
    {
      // User entry: message.content is a plain string
      writeln!(
        file,
        r#"{{"type":"user","uuid":"test-uuid-{i:03}","parentUuid":null,"timestamp":"2025-01-01T00:00:0{i}Z","cwd":"/tmp","sessionId":"{session_id}","version":"2.0.0","gitBranch":"master","userType":"human","isSidechain":false,"message":{{"role":"user","content":"entry {i}"}}}}"#
      )
      .expect( "write user entry" );
    }
    else
    {
      // Assistant entry: message.content is an array of blocks; requires requestId + model + id
      writeln!(
        file,
        r#"{{"type":"assistant","uuid":"test-uuid-{i:03}","parentUuid":"test-uuid-{prev:03}","timestamp":"2025-01-01T00:00:0{i}Z","cwd":"/tmp","sessionId":"{session_id}","version":"2.0.0","gitBranch":"master","userType":"external","isSidechain":false,"requestId":"req_test_{i:03}","message":{{"role":"assistant","model":"claude-test","id":"msg_test_{i:03}","content":[{{"type":"text","text":"entry {i}"}}],"stop_reason":"end_turn","stop_sequence":null,"usage":{{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}}}}"#,
        prev = i.saturating_sub( 1 )
      )
      .expect( "write assistant entry" );
    }
  }
}

/// Write N synthetic entries for a path-based project (auto-encodes the path).
///
/// Returns the path-encoded project ID so callers can use it in assertions.
///
/// # Panics
///
/// Panics if path encoding or file write fails.
#[allow(dead_code)]
pub fn write_path_project_session(
  root : &std::path::Path,
  project_path : &std::path::Path,
  session_id : &str,
  entry_count : usize,
) -> String
{
  let encoded = claude_storage_core::encode_path( project_path )
    .expect( "encode project path" );
  write_test_session( root, &encoded, session_id, entry_count );
  encoded
}

/// Write a synthetic session with a specific last message text.
///
/// Writes `n_before` standard entries (alternating user/assistant) followed by
/// one user entry whose `content` field is `last_msg`. Total entry count is
/// `n_before + 1`. Useful for testing summary-mode output where the last text
/// entry must be controlled precisely.
///
/// Restriction: `last_msg` must contain only characters safe in a JSON string
/// without escaping (no `"`, `\`, or control characters).
///
/// # Panics
///
/// Panics if directory creation or file write fails.
#[allow(dead_code)]
pub fn write_test_session_with_last_message(
  root       : &std::path::Path,
  project_id : &str,
  session_id : &str,
  n_before   : usize,
  last_msg   : &str,
)
{
  use std::io::Write as _;

  let dir = root.join( "projects" ).join( project_id );
  std::fs::create_dir_all( &dir ).expect( "create project dir" );
  let path = dir.join( format!( "{session_id}.jsonl" ) );
  let mut file = std::fs::File::create( &path ).expect( "create session file" );

  for i in 0..n_before
  {
    if i % 2 == 0
    {
      writeln!(
        file,
        r#"{{"type":"user","uuid":"test-uuid-{i:03}","parentUuid":null,"timestamp":"2025-01-01T00:00:{i:02}Z","cwd":"/tmp","sessionId":"{session_id}","version":"2.0.0","gitBranch":"master","userType":"human","isSidechain":false,"message":{{"role":"user","content":"entry {i}"}}}}"#
      ).expect( "write user entry" );
    }
    else
    {
      let prev = i.saturating_sub( 1 );
      writeln!(
        file,
        r#"{{"type":"assistant","uuid":"test-uuid-{i:03}","parentUuid":"test-uuid-{prev:03}","timestamp":"2025-01-01T00:00:{i:02}Z","cwd":"/tmp","sessionId":"{session_id}","version":"2.0.0","gitBranch":"master","userType":"external","isSidechain":false,"requestId":"req_test_{i:03}","message":{{"role":"assistant","model":"claude-test","id":"msg_test_{i:03}","content":[{{"type":"text","text":"entry {i}"}}],"stop_reason":"end_turn","stop_sequence":null,"usage":{{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}}}}"#
      ).expect( "write assistant entry" );
    }
  }

  let idx = n_before;
  writeln!(
    file,
    r#"{{"type":"user","uuid":"test-uuid-{idx:03}","parentUuid":null,"timestamp":"2025-01-01T01:00:00Z","cwd":"/tmp","sessionId":"{session_id}","version":"2.0.0","gitBranch":"master","userType":"human","isSidechain":false,"message":{{"role":"user","content":"{last_msg}"}}}}"#
  ).expect( "write last message entry" );
}

/// Write a `meta.json` sidecar for an agent session.
///
/// Creates `{dir}/agent-{agent_id}.meta.json` with the given `agent_type`.
/// An empty `agent_type` produces `{}` (malformed — no `agentType` key),
/// useful for testing the fallback-to-"unknown" path.
///
/// # Panics
///
/// Panics if file write fails.
#[allow(dead_code)]
pub fn write_agent_meta_json(
  dir        : &std::path::Path,
  agent_id   : &str,
  agent_type : &str,
)
{
  let path = dir.join( format!( "agent-{agent_id}.meta.json" ) );
  if agent_type.is_empty()
  {
    std::fs::write( &path, b"{}" ).expect( "write empty meta.json" );
  }
  else
  {
    let json = format!( r#"{{"agentType":"{agent_type}"}}"# );
    std::fs::write( &path, json.as_bytes() ).expect( "write meta.json" );
  }
}

/// Write a hierarchical session family: root `.jsonl` + `{uuid}/subagents/` agents.
///
/// Creates:
/// - `{root}/projects/{project_id}/{root_session_id}.jsonl` with `entries_each` entries
/// - `{root}/projects/{project_id}/{root_session_id}/subagents/agent-{id}.jsonl` per agent
/// - `{root}/projects/{project_id}/{root_session_id}/subagents/agent-{id}.meta.json` per agent
///
/// `agents` is `&[(&str, &str)]` where each tuple is `(agent_id, agent_type)`.
///
/// # Panics
///
/// Panics if directory creation or file write fails.
#[allow(dead_code)]
pub fn write_hierarchical_session(
  root             : &std::path::Path,
  project_id       : &str,
  root_session_id  : &str,
  agents           : &[ ( &str, &str ) ],
  entries_each     : usize,
)
{
  use std::io::Write as _;

  // Root session
  write_test_session( root, project_id, root_session_id, entries_each );

  // Subagents directory
  let subagents_dir = root
    .join( "projects" )
    .join( project_id )
    .join( root_session_id )
    .join( "subagents" );
  std::fs::create_dir_all( &subagents_dir ).expect( "create subagents dir" );

  for ( agent_id, agent_type ) in agents
  {
    let agent_file_name = format!( "agent-{agent_id}.jsonl" );
    let agent_path = subagents_dir.join( &agent_file_name );
    let mut file = std::fs::File::create( &agent_path ).expect( "create agent session file" );

    for i in 0..entries_each
    {
      if i % 2 == 0
      {
        writeln!(
          file,
          r#"{{"type":"user","uuid":"agent-uuid-{i:03}","parentUuid":null,"timestamp":"2025-01-01T00:00:0{i}Z","cwd":"/tmp","sessionId":"{root_session_id}","version":"2.0.0","gitBranch":"master","userType":"human","isSidechain":false,"message":{{"role":"user","content":"agent entry {i}"}}}}"#
        ).expect( "write agent user entry" );
      }
      else
      {
        let prev = i.saturating_sub( 1 );
        writeln!(
          file,
          r#"{{"type":"assistant","uuid":"agent-uuid-{i:03}","parentUuid":"agent-uuid-{prev:03}","timestamp":"2025-01-01T00:00:0{i}Z","cwd":"/tmp","sessionId":"{root_session_id}","version":"2.0.0","gitBranch":"master","userType":"external","isSidechain":false,"requestId":"req_agent_{i:03}","message":{{"role":"assistant","model":"claude-test","id":"msg_agent_{i:03}","content":[{{"type":"text","text":"agent entry {i}"}}],"stop_reason":"end_turn","stop_sequence":null,"usage":{{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}}}}"#
        ).expect( "write agent assistant entry" );
      }
    }

    write_agent_meta_json( &subagents_dir, agent_id, agent_type );
  }
}

/// Write a hierarchical session family for a path-based project.
///
/// Auto-encodes the project path. Returns the encoded project ID.
///
/// # Panics
///
/// Panics if path encoding or file write fails.
#[allow(dead_code)]
pub fn write_hierarchical_path_session(
  root             : &std::path::Path,
  project_path     : &std::path::Path,
  root_session_id  : &str,
  agents           : &[ ( &str, &str ) ],
  entries_each     : usize,
) -> String
{
  let encoded = claude_storage_core::encode_path( project_path )
    .expect( "encode project path" );
  write_hierarchical_session( root, &encoded, root_session_id, agents, entries_each );
  encoded
}

/// Write a flat-format agent session at the project root.
///
/// Creates `{root}/projects/{project_id}/agent-{agent_id}.jsonl` with
/// the first JSONL entry containing `"sessionId":"{parent_session_id}"`.
///
/// # Panics
///
/// Panics if directory creation or file write fails.
#[allow(dead_code)]
pub fn write_flat_agent_session(
  root               : &std::path::Path,
  project_id         : &str,
  agent_id           : &str,
  parent_session_id  : &str,
  n_entries          : usize,
)
{
  use std::io::Write as _;

  let dir = root.join( "projects" ).join( project_id );
  std::fs::create_dir_all( &dir ).expect( "create project dir" );
  let path = dir.join( format!( "agent-{agent_id}.jsonl" ) );
  let mut file = std::fs::File::create( &path ).expect( "create flat agent file" );

  for i in 0..n_entries
  {
    if i % 2 == 0
    {
      writeln!(
        file,
        r#"{{"type":"user","uuid":"flat-uuid-{i:03}","parentUuid":null,"timestamp":"2025-01-01T00:00:0{i}Z","cwd":"/tmp","sessionId":"{parent_session_id}","version":"2.0.0","gitBranch":"master","userType":"human","isSidechain":false,"message":{{"role":"user","content":"flat agent entry {i}"}}}}"#
      ).expect( "write flat agent user entry" );
    }
    else
    {
      let prev = i.saturating_sub( 1 );
      writeln!(
        file,
        r#"{{"type":"assistant","uuid":"flat-uuid-{i:03}","parentUuid":"flat-uuid-{prev:03}","timestamp":"2025-01-01T00:00:0{i}Z","cwd":"/tmp","sessionId":"{parent_session_id}","version":"2.0.0","gitBranch":"master","userType":"external","isSidechain":false,"requestId":"req_flat_{i:03}","message":{{"role":"assistant","model":"claude-test","id":"msg_flat_{i:03}","content":[{{"type":"text","text":"flat agent entry {i}"}}],"stop_reason":"end_turn","stop_sequence":null,"usage":{{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}}}}"#
      ).expect( "write flat agent assistant entry" );
    }
  }
}

/// Write a session with a specific last message for a path-based project.
///
/// Auto-encodes the project path and delegates to
/// `write_test_session_with_last_message`. Returns the encoded project ID.
///
/// # Panics
///
/// Panics if path encoding or file write fails.
#[allow(dead_code)]
pub fn write_path_project_session_with_last_message(
  root         : &std::path::Path,
  project_path : &std::path::Path,
  session_id   : &str,
  n_before     : usize,
  last_msg     : &str,
) -> String
{
  let encoded = claude_storage_core::encode_path( project_path )
    .expect( "encode project path" );
  write_test_session_with_last_message( root, &encoded, session_id, n_before, last_msg );
  encoded
}