drip-cli 0.1.1

Delta Read Interception Proxy — sends only file diffs to your LLM agent
use crate::common::Drip;
use rusqlite::{params, Connection, OptionalExtension};
use serde_json::{json, Value};
use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};

fn estimate(text: &str) -> i64 {
    if text.is_empty() {
        0
    } else {
        text.len().div_ceil(4) as i64
    }
}

fn db(drip: &Drip) -> Connection {
    Connection::open(drip.data_dir.path().join("sessions.db")).unwrap()
}

fn canonical(path: &Path) -> String {
    path.canonicalize().unwrap().to_string_lossy().into_owned()
}

fn table_exists(conn: &Connection, table: &str) -> bool {
    conn.query_row(
        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
        [table],
        |_| Ok(()),
    )
    .optional()
    .unwrap()
    .is_some()
}

fn assert_no_negative_accounting(drip: &Drip) {
    let conn = db(drip);
    for table in [
        "reads",
        "read_events",
        "lifetime_stats",
        "lifetime_per_file",
        "lifetime_daily",
    ] {
        if !table_exists(&conn, table) {
            continue;
        }
        let mut stmt = conn
            .prepare(&format!(
                "SELECT tokens_full, tokens_sent FROM {table} WHERE tokens_sent > tokens_full"
            ))
            .unwrap();
        let bad: Vec<(i64, i64)> = stmt
            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
            .unwrap()
            .collect::<Result<_, _>>()
            .unwrap();
        assert!(
            bad.is_empty(),
            "{table} contains token-negative accounting rows: {bad:?}"
        );
    }
}

fn latest_event(drip: &Drip) -> (String, i64, i64, String) {
    db(drip)
        .query_row(
            "SELECT outcome_kind, tokens_full, tokens_sent, rendered
             FROM read_events
             WHERE session_id = ?1
             ORDER BY id DESC LIMIT 1",
            params![drip.session_id],
            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
        )
        .unwrap()
}

fn read_row(drip: &Drip, file_path: &Path) -> (i64, i64, i64) {
    db(drip)
        .query_row(
            "SELECT reads_count, tokens_full, tokens_sent
             FROM reads WHERE session_id = ?1 AND file_path = ?2",
            params![drip.session_id, canonical(file_path)],
            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
        )
        .unwrap()
}

fn run_claude_read(
    drip: &Drip,
    file_path: &Path,
    offset: Option<usize>,
    limit: Option<usize>,
) -> Value {
    let mut input = json!({ "file_path": file_path.to_string_lossy() });
    if let Some(offset) = offset {
        input["offset"] = json!(offset);
    }
    if let Some(limit) = limit {
        input["limit"] = json!(limit);
    }
    let payload = json!({
        "session_id": drip.session_id,
        "tool_name": "Read",
        "tool_input": input
    });
    run_hook(drip, "claude", payload)
}

fn run_post_edit(drip: &Drip, file_path: &Path) {
    let payload = json!({
        "session_id": drip.session_id,
        "tool_name": "Edit",
        "tool_input": { "file_path": file_path.to_string_lossy() }
    });
    let _ = run_hook(drip, "claude-post-edit", payload);
}

fn run_hook(drip: &Drip, hook: &str, payload: Value) -> Value {
    let mut cmd = Command::new(&drip.bin);
    cmd.args(["hook", hook])
        .env("DRIP_DATA_DIR", drip.data_dir.path())
        .env("DRIP_SESSION_ID", &drip.session_id)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    let mut child = cmd.spawn().expect("spawn hook");
    child
        .stdin
        .as_mut()
        .unwrap()
        .write_all(payload.to_string().as_bytes())
        .unwrap();
    let output = child.wait_with_output().unwrap();
    assert!(
        output.status.success(),
        "hook {hook} failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    serde_json::from_slice(&output.stdout).expect("hook stdout must be JSON")
}

fn permission_decision(v: &Value) -> &str {
    v["hookSpecificOutput"]["permissionDecision"]
        .as_str()
        .expect("permissionDecision")
}

fn permission_reason(v: &Value) -> &str {
    v["hookSpecificOutput"]["permissionDecisionReason"]
        .as_str()
        .expect("permissionDecisionReason")
}

fn large_python_fixture() -> String {
    let mut src = String::from("import pathlib\n\n");
    for f in 0..24 {
        src.push_str(&format!("def worker_{f}(value):\n"));
        for i in 0..24 {
            src.push_str(&format!(
                "    step_{i} = value + {i}  # stable payload for accounting audit\n"
            ));
        }
        src.push_str("    return value\n\n");
    }
    src
}

#[test]
fn audit_claude_full_read_then_unchanged_accounts_actual_payload() {
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("stable.txt");
    let content = "alpha bravo charlie delta echo foxtrot golf hotel\n".repeat(40);
    fs::write(&f, &content).unwrap();
    let full_tokens = estimate(&content);

    let first = run_claude_read(&drip, &f, None, None);
    assert_eq!(permission_decision(&first), "allow");
    let second = run_claude_read(&drip, &f, None, None);
    assert_eq!(permission_decision(&second), "deny");
    let sentinel_tokens = estimate(permission_reason(&second));
    assert!(sentinel_tokens < full_tokens);

    assert_eq!(
        read_row(&drip, &f),
        (2, full_tokens * 2, full_tokens + sentinel_tokens)
    );
    let (kind, tokens_full, tokens_sent, rendered) = latest_event(&drip);
    assert_eq!(kind, "unchanged");
    assert_eq!(tokens_full, full_tokens);
    assert_eq!(tokens_sent, estimate(&rendered));
    assert_no_negative_accounting(&drip);
}

#[test]
fn audit_delta_counts_current_file_and_rendered_diff_payload() {
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("delta.py");
    let before = large_python_fixture();
    fs::write(&f, &before).unwrap();
    let first = drip.read_stdout(&f);
    assert!(
        estimate(&first) <= estimate(&before),
        "first compressed read must not start with a token loss"
    );

    let after = before.replacen(
        "step_7 = value + 7  # stable payload for accounting audit",
        "step_7 = value + 700  # changed payload for accounting audit",
        1,
    );
    fs::write(&f, &after).unwrap();
    drip.read_stdout(&f);

    let (kind, tokens_full, tokens_sent, rendered) = latest_event(&drip);
    assert_eq!(kind, "delta");
    assert_eq!(tokens_full, estimate(&after));
    assert_eq!(tokens_sent, estimate(&rendered));
    assert!(
        tokens_sent < tokens_full,
        "delta must be cheaper: {rendered}"
    );
    assert_no_negative_accounting(&drip);
}

#[test]
fn audit_partial_read_uses_window_as_counterfactual() {
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("partial.txt");
    let content: String = (1..=80)
        .map(|i| format!("line {i:02} payload payload payload payload\n"))
        .collect();
    fs::write(&f, &content).unwrap();
    let window: String = (5..=24)
        .map(|i| format!("line {i:02} payload payload payload payload\n"))
        .collect();
    let window_tokens = estimate(&window);

    let first = run_claude_read(&drip, &f, Some(5), Some(20));
    assert_eq!(permission_decision(&first), "allow");
    let second = run_claude_read(&drip, &f, Some(5), Some(20));
    assert_eq!(permission_decision(&second), "deny");
    assert!(permission_reason(&second).contains("unchanged (lines 5-24)"));
    let sentinel_tokens = estimate(permission_reason(&second));
    assert!(sentinel_tokens < window_tokens);

    assert_eq!(
        read_row(&drip, &f),
        (2, window_tokens * 2, window_tokens + sentinel_tokens)
    );
    assert_no_negative_accounting(&drip);
}

#[test]
fn audit_tiny_partial_window_passes_through_when_marker_costs_more() {
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("tiny-partial.txt");
    let content: String = (1..=10).map(|i| format!("line {i}\n")).collect();
    fs::write(&f, &content).unwrap();
    let window = "line 1\n";
    let window_tokens = estimate(window);

    let first = run_claude_read(&drip, &f, Some(1), Some(1));
    assert_eq!(permission_decision(&first), "allow");
    let second = run_claude_read(&drip, &f, Some(1), Some(1));
    assert_eq!(permission_decision(&second), "allow");

    assert_eq!(
        read_row(&drip, &f),
        (2, window_tokens * 2, window_tokens * 2)
    );
    assert_no_negative_accounting(&drip);
}

#[test]
fn audit_edit_certificate_counts_rendered_certificate_not_full_file() {
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("edit_cert.txt");
    let before: String = (0..220)
        .map(|i| format!("line {i} payload payload payload\n"))
        .collect();
    fs::write(&f, &before).unwrap();
    let first = run_claude_read(&drip, &f, None, None);
    assert_eq!(permission_decision(&first), "allow");

    let after = before.replace("line 100 payload", "line 100 EDITED payload");
    fs::write(&f, &after).unwrap();
    run_post_edit(&drip, &f);
    let response = run_claude_read(&drip, &f, None, None);
    assert_eq!(permission_decision(&response), "deny");
    assert!(permission_reason(&response).contains("[DRIP: edit verified"));

    let (kind, tokens_full, tokens_sent, rendered) = latest_event(&drip);
    assert_eq!(kind, "edit-cert");
    assert_eq!(tokens_full, estimate(&after));
    assert_eq!(tokens_sent, estimate(&rendered));
    assert!(tokens_sent < tokens_full);
    assert_no_negative_accounting(&drip);
}

#[test]
fn audit_placeholder_fallbacks_are_conservative() {
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let binary = dir.path().join("blob.bin");
    fs::write(&binary, b"\x00\x01\x02\x03\x04").unwrap();

    let response = run_claude_read(&drip, &binary, None, None);
    assert_eq!(permission_decision(&response), "deny");
    assert!(permission_reason(&response).contains("binary file"));
    let (kind, tokens_full, tokens_sent, _) = latest_event(&drip);
    assert_eq!(kind, "fallback");
    assert_eq!(tokens_full, tokens_sent);

    let ignored = dir.path().join(".env");
    fs::write(&ignored, "SECRET=not-recorded\n").unwrap();
    let response = run_claude_read(&drip, &ignored, None, None);
    assert_eq!(permission_decision(&response), "deny");
    assert!(permission_reason(&response).contains("matched .dripignore"));
    let (kind, tokens_full, tokens_sent, _) = latest_event(&drip);
    assert_eq!(kind, "fallback");
    assert_eq!(tokens_full, tokens_sent);
    assert_no_negative_accounting(&drip);
}