scryer-cli 0.4.0

CLI tool and shared daemon runner for Scryer code intelligence
//! `scryer hook pre-grep` end to end: the real binary, a real index, events on stdin.

use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};

use serde_json::{Value, json};

struct Env {
    _dir: tempfile::TempDir,
    project: std::path::PathBuf,
    db: std::path::PathBuf,
    run: std::path::PathBuf,
}

fn scryer(env: &Env) -> Command {
    let mut cmd = Command::new(env!("CARGO_BIN_EXE_scryer"));
    cmd.current_dir(&env.project)
        .env("SCRYER_DB_URL", &env.db)
        .env("SCRYER_NO_DAEMON", "1")
        .env("XDG_RUNTIME_DIR", &env.run)
        .env_remove("SCRYER_HOOK");
    cmd
}

fn setup() -> Env {
    let dir = tempfile::tempdir().unwrap();
    let project = dunce::canonicalize(dir.path()).unwrap().join("proj");
    std::fs::create_dir_all(project.join("src")).unwrap();
    std::fs::write(
        project.join("Cargo.toml"),
        "[package]\nname = \"proj\"\nversion = \"0.1.0\"\n",
    )
    .unwrap();
    std::fs::write(
        project.join("src/lib.rs"),
        "pub fn make_widget() -> u32 {\n    1\n}\n\npub fn use_it() -> u32 {\n    make_widget()\n}\n",
    )
    .unwrap();
    let env = Env {
        db: dir.path().join("db/scryer.db"),
        run: dir.path().join("run"),
        project,
        _dir: dir,
    };
    let out = scryer(&env).arg("index").output().unwrap();
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );
    env
}

/// Run the hook on `event`; its stdout.
fn hook(env: &Env, mode: Option<&str>, event: &Value) -> String {
    let mut cmd = scryer(env);
    cmd.args(["hook", "pre-grep"])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    if let Some(mode) = mode {
        cmd.env("SCRYER_HOOK", mode);
    }
    let mut child = cmd.spawn().unwrap();
    child
        .stdin
        .take()
        .unwrap()
        .write_all(event.to_string().as_bytes())
        .unwrap();
    let out = child.wait_with_output().unwrap();
    assert!(
        out.status.success(),
        "the hook must never fail: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8(out.stdout).unwrap()
}

fn grep(project: &Path, session: &str, pattern: &str) -> Value {
    json!({
        "session_id": session,
        "cwd": project,
        "tool_name": "Grep",
        "tool_input": { "pattern": pattern },
    })
}

#[test]
fn it_is_off_unless_asked() {
    let env = setup();
    let event = grep(&env.project, "off-by-default", "make_widget");
    assert_eq!(hook(&env, None, &event), "");
    assert_eq!(hook(&env, Some("off"), &event), "");
    assert_eq!(hook(&env, Some("garbage"), &event), "");
}

#[test]
fn a_bare_identifier_is_answered_once_then_the_repeat_runs() {
    let env = setup();
    let event = grep(&env.project, "once", "make_widget");

    let first = hook(&env, Some("on"), &event);
    let out: Value = serde_json::from_str(&first).unwrap_or_else(|e| panic!("{e}: {first}"));
    let specific = &out["hookSpecificOutput"];
    assert_eq!(specific["permissionDecision"], "deny");
    let reason = specific["permissionDecisionReason"].as_str().unwrap();
    assert!(
        reason.contains("use_it"),
        "the caller is in the answer: {reason}"
    );
    assert!(reason.contains("src/lib.rs:6"), "{reason}");
    assert!(reason.contains("repeat the same search"), "{reason}");

    // The same pattern again, in the same session: let it through.
    assert_eq!(hook(&env, Some("on"), &event), "");
    // Another session starts fresh.
    let other = grep(&env.project, "another", "make_widget");
    assert!(hook(&env, Some("on"), &other).contains("deny"));
}

#[test]
fn the_same_search_as_a_bash_command_is_answered_too() {
    let env = setup();
    let event = json!({
        "session_id": "bash",
        "cwd": env.project,
        "tool_name": "Bash",
        "tool_input": { "command": "grep -rn make_widget ." },
    });
    assert!(hook(&env, Some("on"), &event).contains("use_it"));
}

#[test]
fn what_is_not_a_single_known_identifier_runs_as_usual() {
    let env = setup();
    for (session, pattern) in [
        ("regex", "make_.*widget"),
        ("short", "use"),
        ("unknown", "does_not_exist_anywhere"),
        ("literal", "\"make_widget\""),
    ] {
        let event = grep(&env.project, session, pattern);
        assert_eq!(hook(&env, Some("on"), &event), "", "{pattern}");
    }
}

#[test]
fn context_mode_allows_the_grep_and_adds_the_answer() {
    let env = setup();
    let out = hook(
        &env,
        Some("context"),
        &grep(&env.project, "ctx", "make_widget"),
    );
    let out: Value = serde_json::from_str(&out).unwrap();
    let specific = &out["hookSpecificOutput"];
    assert!(specific.get("permissionDecision").is_none());
    assert!(
        specific["additionalContext"]
            .as_str()
            .unwrap()
            .contains("use_it")
    );
}

#[test]
fn garbage_on_stdin_never_fails_the_agents_call() {
    let env = setup();
    let mut cmd = scryer(&env);
    cmd.args(["hook", "pre-grep"])
        .env("SCRYER_HOOK", "on")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    let mut child = cmd.spawn().unwrap();
    child
        .stdin
        .take()
        .unwrap()
        .write_all(b"not json at all")
        .unwrap();
    let out = child.wait_with_output().unwrap();
    assert!(out.status.success());
    assert!(out.stdout.is_empty());
}