gaze-cli 0.11.1

Gaze command-line interface
#![cfg(feature = "index")]

use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};

use assert_cmd::Command;

const DOMAIN: &str = "local_owner/support_notes/v1";

#[test]
fn index_ingest_then_search_returns_tokenized_hits_without_raw_values() {
    let temp = tempfile::tempdir().expect("tempdir");
    let corpus = temp.path().join("corpus");
    let index = temp.path().join("owner-index");
    let fake_kiji = write_fake_kiji(&temp);
    fs::create_dir_all(&corpus).expect("corpus dir");
    fs::write(
        corpus.join("alpha.md"),
        "\
Name: Dr. Schmidt
Email: alice@example.invalid
Organization: Globex GmbH
Case ID: CASE-001
Support note references a local-only index fixture.
",
    )
    .expect("write alpha");
    fs::write(
        corpus.join("beta.md"),
        "\
Name: Prof. Weber
Email: weber@example.invalid
Organization: Initech AG
Case ID: CASE-002
Second support note for search isolation.
",
    )
    .expect("write beta");

    let ingest = gaze_index_command(&fake_kiji)
        .args(["index", "ingest"])
        .arg(&corpus)
        .args(["--domain", DOMAIN, "--index-path"])
        .arg(&index)
        .output()
        .expect("run index ingest");
    assert!(
        ingest.status.success(),
        "ingest failed: stderr={}",
        String::from_utf8_lossy(&ingest.stderr)
    );

    let search = gaze_index_command(&fake_kiji)
        .args(["index", "search", "alice@example.invalid"])
        .args(["--class", "email", "--domain", DOMAIN, "--index-path"])
        .arg(&index)
        .output()
        .expect("run index search");
    assert!(
        search.status.success(),
        "search failed: stderr={}",
        String::from_utf8_lossy(&search.stderr)
    );

    let stdout = String::from_utf8(search.stdout).expect("utf8 stdout");
    assert!(stdout.contains("doc: doc:"));
    assert!(stdout.contains(":Email_"));
    assert!(stdout.contains("raw PII never shown (owner-side only)"));

    for raw in [
        "Dr. Schmidt",
        "alice@example.invalid",
        "Globex GmbH",
        "CASE-001",
        "Prof. Weber",
        "weber@example.invalid",
        "Initech AG",
        "CASE-002",
    ] {
        assert!(
            !stdout.contains(raw),
            "search stdout leaked raw fixture value {raw}: {stdout}"
        );
    }
}

#[test]
fn realistic_prose_name_org_email_regression_never_returns_raw_values() {
    let temp = tempfile::tempdir().expect("tempdir");
    let corpus = temp.path().join("corpus");
    let index = temp.path().join("owner-index");
    let fake_kiji = write_fake_kiji(&temp);
    fs::create_dir_all(&corpus).expect("corpus dir");
    fs::write(
        corpus.join("prose.md"),
        "\
Support summary: Alice Mueller from Globex GmbH wrote from alice@example.invalid about onboarding. Follow up next week.
",
    )
    .expect("write prose");

    let ingest = gaze_index_command(&fake_kiji)
        .args(["index", "ingest"])
        .arg(&corpus)
        .args(["--domain", DOMAIN, "--index-path"])
        .arg(&index)
        .output()
        .expect("run index ingest");
    assert!(
        ingest.status.success(),
        "ingest failed: stderr={}",
        String::from_utf8_lossy(&ingest.stderr)
    );
    let ingest_stdout = String::from_utf8(ingest.stdout).expect("utf8 ingest stdout");
    assert!(
        ingest_stdout.contains("entities: 3"),
        "expected name + org + email entities, got: {ingest_stdout}"
    );

    let search = gaze_index_command(&fake_kiji)
        .args(["index", "search", "alice@example.invalid"])
        .args(["--class", "email", "--domain", DOMAIN, "--index-path"])
        .arg(&index)
        .output()
        .expect("run index search");
    assert!(
        search.status.success(),
        "search failed: stderr={}",
        String::from_utf8_lossy(&search.stderr)
    );

    let stdout = String::from_utf8(search.stdout).expect("utf8 stdout");
    assert!(stdout.contains("doc: doc:"));
    assert!(stdout.contains(":Email_"));
    assert!(stdout.contains(":Name_"));
    for raw in ["Alice Mueller", "Globex GmbH", "alice@example.invalid"] {
        assert!(
            !stdout.contains(raw),
            "realistic prose search stdout leaked raw fixture value {raw}: {stdout}"
        );
    }
}

#[test]
fn index_ingest_fails_closed_without_kiji_model_or_command() {
    let temp = tempfile::tempdir().expect("tempdir");
    let corpus = temp.path().join("corpus");
    let index = temp.path().join("owner-index");
    fs::create_dir_all(&corpus).expect("corpus dir");
    fs::write(corpus.join("alpha.md"), "Email: alice@example.invalid\n").expect("write alpha");

    let ingest = Command::cargo_bin("gaze")
        .expect("gaze bin")
        .env_remove("GAZE_KIJI_DISTILBERT_COMMAND")
        .env_remove("GAZE_KIJI_DISTILBERT_MODEL_DIR")
        .args(["index", "ingest"])
        .arg(&corpus)
        .args(["--domain", DOMAIN, "--index-path"])
        .arg(&index)
        .output()
        .expect("run index ingest");

    assert!(
        !ingest.status.success(),
        "ingest unexpectedly succeeded without Kiji backend"
    );
    let stderr = String::from_utf8_lossy(&ingest.stderr);
    assert!(stderr.contains("SafetyNetConfig"), "stderr={stderr}");
    assert!(
        stderr.contains("GAZE_KIJI_DISTILBERT_MODEL_DIR"),
        "stderr={stderr}"
    );
}

fn gaze_index_command(fake_kiji: &Path) -> Command {
    let mut command = Command::cargo_bin("gaze").expect("gaze bin");
    command
        .env("GAZE_KIJI_DISTILBERT_COMMAND", fake_kiji)
        .env_remove("GAZE_KIJI_DISTILBERT_MODEL_DIR");
    command
}

fn write_fake_kiji(temp: &tempfile::TempDir) -> PathBuf {
    let script = temp.path().join("fake-kiji.py");
    fs::write(
        &script,
        r#"#!/usr/bin/env python3
import json
import sys

text = sys.stdin.read()
targets = [
    ("Dr. Schmidt", "PER"),
    ("Prof. Weber", "PER"),
    ("Alice Mueller", "PER"),
    ("Globex GmbH", "ORG"),
    ("Initech AG", "ORG"),
]

spans = []
for value, label in targets:
    cursor = 0
    while True:
        index = text.find(value, cursor)
        if index < 0:
            break
        start = len(text[:index].encode("utf-8"))
        end = start + len(value.encode("utf-8"))
        spans.append({"label": label, "start": start, "end": end, "score": 0.99})
        cursor = index + len(value)

print(json.dumps(spans))
"#,
    )
    .expect("write fake kiji");
    let mut permissions = fs::metadata(&script)
        .expect("fake kiji metadata")
        .permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(&script, permissions).expect("chmod fake kiji");
    script
}