Big Brain v0.4.0

tests.rs

Rust · 713 lines · 32.5 KB
//! Unit tests. Each test works in its own throwaway folder; nothing touches a real brain.

use crate::store::{self, Brain, Config, Meta, Project};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;

/// A fresh, empty folder under the system temp dir.
fn tmp(name: &str) -> PathBuf {
    let d = std::env::temp_dir().join(format!("brain-test-{}-{name}", std::process::id()));
    let _ = fs::remove_dir_all(&d);
    fs::create_dir_all(&d).unwrap();
    d
}

/// A brain in a temp folder with one project ("demo") whose repo has src/lib.rs.
fn brain(name: &str) -> (Brain, PathBuf) {
    let root = tmp(name);
    let home = root.join("brain");
    for d in ["entries", "proposals", "reviews", "hook-state"] {
        fs::create_dir_all(home.join(d)).unwrap();
    }
    let repo = root.join("demo");
    fs::create_dir_all(repo.join("src")).unwrap();
    fs::write(repo.join("src/lib.rs"), "pub fn retry() {}\n").unwrap();
    let mut projects = BTreeMap::new();
    projects.insert("demo".to_string(), Project { path: repo.to_string_lossy().to_string(), ..Default::default() });
    let mut config = Config::default();
    config.embeddings.provider = "none".into();
    (Brain { home, projects, config }, repo)
}

const ENTRY: &str = "---
title: Retry queue with backoff
kind: system
project: demo
tags: [Retries, Web Hooks]
verified: yes
refs: [demo:src/lib.rs]
---

## Goal

Deliver webhooks reliably when receivers are down.

## Approach

Failed deliveries are retried with exponential backoff, capped at one hour.

## Key files

- src/lib.rs
";

// ------------------------------------------------------------------ store

#[test]
fn frontmatter_parses_and_normalises() {
    let (m, body) = store::split_frontmatter(ENTRY).unwrap();
    assert_eq!(m.title, "Retry queue with backoff");
    assert_eq!(m.tags, vec!["retries", "web-hooks"]);
    assert!(m.verified, "`yes` counts as verified");
    assert!(body.starts_with("## Goal"));
    let again = store::render(&m, &body);
    let (m2, body2) = store::split_frontmatter(&again).unwrap();
    assert_eq!(m2.title, m.title);
    assert_eq!(body2, body);
}

#[test]
fn frontmatter_errors_are_helpful() {
    let e = store::split_frontmatter("no frontmatter").unwrap_err().to_string();
    assert!(e.contains("---"));
    let e = format!("{:#}", store::split_frontmatter("---\nverified_how: live: works\n---\nx").unwrap_err());
    assert!(e.contains("quote"), "YAML error should suggest quoting: {e}");
}

#[test]
fn sections_split_on_h2() {
    let (_, body) = store::split_frontmatter(ENTRY).unwrap();
    let names: Vec<String> = store::sections(&body).into_iter().map(|s| s.0).collect();
    assert_eq!(names, vec!["Goal", "Approach", "Key files"]);
}

#[test]
fn slugs_cut_at_word_boundaries() {
    assert_eq!(store::slugify("Hello, World!"), "hello-world");
    let long = store::slugify("home server overview what runs where ports backups monitoring and more words");
    assert!(long.len() <= 60);
    assert!(!long.ends_with('-'));
    assert!(long.ends_with("backups"), "cut at a whole word: {long}");
    assert_eq!(store::slugify("!!!"), "entry");
}

#[test]
fn secrets_are_refused_but_ordinary_hex_is_not() {
    for bad in [
        "-----BEGIN OPENSSH PRIVATE KEY-----",
        "api_key = sk-proj-abcdefghijklmnopqrstuvwxyz123",
        "token: ghp_abcdefghijklmnopqrstuvwxyz0123456789",
        "the private key is 0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318",
        "password: hunter2hunter2",
        "AKIAABCDEFGHIJKLMNOP",
    ] {
        assert!(store::secret_check(bad).is_err(), "should refuse: {bad}");
    }
    for ok in [
        "tx 0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318 confirmed",
        "set the password in $DB_PASSWORD",
        "token: <redacted>",
    ] {
        assert!(store::secret_check(ok).is_ok(), "should allow: {ok}");
    }
}

#[test]
fn validation_checks_kind_sections_and_project() {
    let (b, _) = brain("validate");
    let (m, body) = store::split_frontmatter(ENTRY).unwrap();
    b.validate(&m, &body).unwrap();

    let mut wrong_kind = m.clone();
    wrong_kind.kind = "essay".into();
    assert!(b.validate(&wrong_kind, &body).is_err());

    let mut unknown = m.clone();
    unknown.project = "nope".into();
    assert!(b.validate(&unknown, &body).is_err());

    let missing = body.replace("## Key files", "## Notes");
    let e = b.validate(&m, &missing).unwrap_err().to_string();
    assert!(e.contains("Key files"), "{e}");
}

#[test]
fn staleness_follows_the_referenced_file() {
    let (b, repo) = brain("stale");
    let (mut m, _) = store::split_frontmatter(ENTRY).unwrap();
    b.stamp_refs(&mut m).unwrap();
    assert!(b.stale_refs(&m).is_empty(), "fresh right after stamping");

    fs::write(repo.join("src/lib.rs"), "pub fn retry() { /* changed */ }\n").unwrap();
    let st = b.stale_refs(&m);
    assert_eq!(st.len(), 1);
    assert_eq!(st[0].1, store::RefStatus::Changed);

    b.stamp_refs(&mut m).unwrap(); // what `brain ack` does
    assert!(b.stale_refs(&m).is_empty());

    fs::remove_file(repo.join("src/lib.rs")).unwrap();
    assert_eq!(b.stale_refs(&m)[0].1, store::RefStatus::Missing);
}

#[test]
fn refs_to_unknown_files_are_refused() {
    let (b, _) = brain("badref");
    let mut m = Meta { refs: vec!["demo:src/missing.rs".into()], ..Default::default() };
    assert!(b.stamp_refs(&mut m).is_err());
    m.refs = vec!["ghost:src/lib.rs".into()];
    assert!(b.stamp_refs(&mut m).is_err());
}

#[test]
fn refs_cannot_escape_the_project() {
    let (b, repo) = brain("escape");
    assert!(b.resolve_ref("demo:../outside.txt").is_err(), "`..` refused");
    let outside = repo.parent().unwrap().join("secret.txt");
    fs::write(&outside, "top secret").unwrap();
    #[cfg(unix)]
    {
        std::os::unix::fs::symlink(&outside, repo.join("src/link.txt")).unwrap();
        assert!(b.resolve_ref("demo:src/link.txt").is_err(), "symlink out of the project refused");
    }
    assert!(b.resolve_ref("demo:src/lib.rs").is_ok());
}

#[test]
fn project_detected_from_working_dir() {
    let (b, repo) = brain("cwd");
    assert_eq!(b.project_for_dir(&repo.join("src")).as_deref(), Some("demo"));
    assert_eq!(b.project_for_dir(&std::env::temp_dir()), None);
}

#[test]
fn personalize_fills_identity() {
    let mut c = Config::default();
    c.identity.user = "Alex".into();
    c.identity.reviewer = "the ops agent".into();
    assert_eq!(store::personalize("{user} asks {reviewer}", &c.identity), "Alex asks the ops agent");
}

// ------------------------------------------------------------------ index (keyword-only mode)

#[test]
fn keyword_search_works_without_embeddings() {
    let (b, _) = brain("search");
    fs::write(b.home.join("entries/2026-01-01-retry-queue.md"), ENTRY).unwrap();
    let mut ix = crate::index::Index::open(&b).unwrap();
    assert!(!ix.semantic());
    assert_eq!(ix.sync(&b).unwrap(), 1);
    let hits = ix.search(&["exponential backoff".to_string()], &Default::default(), 5).unwrap();
    assert_eq!(hits.len(), 1);
    assert_eq!(hits[0].id, "2026-01-01-retry-queue");
    assert!(!hits[0].section.is_empty(), "keyword hits show their section");
    let none = ix.search(&["kubernetes".to_string()], &Default::default(), 5).unwrap();
    assert!(none.is_empty());
    // Removing the file removes it from the index.
    fs::remove_file(b.home.join("entries/2026-01-01-retry-queue.md")).unwrap();
    ix.sync(&b).unwrap();
    assert!(ix.list(&Default::default()).unwrap().is_empty());
}

// ------------------------------------------------------------------ hooks

#[test]
fn reminder_rule() {
    use crate::hooks::due;
    assert!(due(None, 1), "never mentioned: mention");
    assert!(!due(Some(3), 4), "just mentioned: quiet");
    assert!(!due(Some(3), 12));
    assert!(due(Some(3), 13), "10 messages later and still stale: remind");
}

#[test]
fn edits_counted_for_each_tool() {
    let d = tmp("edits");
    let cases = [
        ("claude", r#"{"message":{"content":[{"type":"tool_use","name":"Edit"},{"type":"tool_use","name":"Write"}]}}"#, 2),
        ("codex", r#"{"type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch"}}"#, 1),
        ("grok", r#"{"update":{"name":"search_replace"}} {"update":{"name": "write"}} {"update":{"name":"read_file"}}"#, 2),
    ];
    for (tool, text, want) in cases {
        let p = d.join(format!("{tool}.jsonl"));
        fs::write(&p, text).unwrap();
        assert_eq!(crate::hooks::count_edits(&p), want, "{tool}");
    }
}

// ------------------------------------------------------------------ setup

#[test]
fn hook_merge_keeps_other_settings_and_is_idempotent() {
    let d = tmp("merge");
    let p = d.join("settings.json");
    fs::write(&p, r#"{"model":"x","hooks":{"Stop":[{"hooks":[{"type":"command","command":"/old/brain hook stop"}]}],
        "PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo mine"}]}]}}"#).unwrap();
    let mut changed = vec![];
    crate::setup::merge_hooks(&p, &mut changed).unwrap();
    let v: serde_json::Value = serde_json::from_str(&fs::read_to_string(&p).unwrap()).unwrap();
    assert_eq!(v["model"], "x");
    assert_eq!(v["hooks"]["PreToolUse"][0]["hooks"][0]["command"], "echo mine");
    assert_eq!(v["hooks"]["Stop"].as_array().unwrap().len(), 1, "old brain hook updated, not duplicated");
    assert!(!v["hooks"]["Stop"][0]["hooks"][0]["command"].as_str().unwrap().starts_with("/old/"));
    for ev in ["SessionStart", "UserPromptSubmit"] {
        assert_eq!(v["hooks"][ev].as_array().unwrap().len(), 1, "{ev}");
    }
    let mut again = vec![];
    crate::setup::merge_hooks(&p, &mut again).unwrap();
    assert!(again.is_empty(), "second run changes nothing");
}

#[test]
fn managed_block_replaces_only_itself() {
    let d = tmp("block");
    let p = d.join("AGENTS.md");
    fs::write(&p, "# Mine\n\nKeep this.\n").unwrap();
    let mut c = vec![];
    crate::setup::write_managed_block(&p, "first version", &mut c).unwrap();
    crate::setup::write_managed_block(&p, "second version", &mut c).unwrap();
    let text = fs::read_to_string(&p).unwrap();
    assert!(text.starts_with("# Mine\n\nKeep this."));
    assert!(text.contains("second version"));
    assert!(!text.contains("first version"));
    assert_eq!(text.matches("big-brain:start").count(), 1);
    // A block written before the rename (old markers) is replaced, not duplicated.
    fs::write(&p, "# Mine\n\n<!-- giga-brain:start (managed by `brain setup`) -->\nold\n<!-- giga-brain:end -->\n").unwrap();
    crate::setup::write_managed_block(&p, "new", &mut c).unwrap();
    let text = fs::read_to_string(&p).unwrap();
    assert!(!text.contains("giga-brain:start") && !text.contains("old") && text.contains("big-brain:start"));
}

#[test]
#[cfg(unix)]
fn existing_giga_brain_folders_keep_working() {
    let root = tmp("rename-dirs");
    let _ = fs::remove_dir_all(&root);
    fs::create_dir_all(root.join("giga-brain")).unwrap();
    // Only the old folder exists: it is used. Once the new one exists, the new one wins.
    unsafe { std::env::set_var("XDG_DATA_HOME", &root) };
    unsafe { std::env::remove_var("BRAIN_HOME") };
    assert_eq!(Brain::home(), root.join("giga-brain"));
    fs::create_dir_all(root.join("big-brain")).unwrap();
    assert_eq!(Brain::home(), root.join("big-brain"));
    fs::remove_dir_all(root.join("big-brain")).unwrap();
    fs::remove_dir_all(root.join("giga-brain")).unwrap();
    assert_eq!(Brain::home(), root.join("big-brain"), "fresh installs use the new name");
    // A stray config.toml in the new folder doesn't hide a real (old) brain.
    fs::create_dir_all(root.join("giga-brain/entries")).unwrap();
    fs::write(root.join("giga-brain/entries/x.md"), "x").unwrap();
    fs::create_dir_all(root.join("big-brain")).unwrap();
    fs::write(root.join("big-brain/config.toml"), "").unwrap();
    assert_eq!(Brain::home(), root.join("giga-brain"));
    unsafe { std::env::remove_var("XDG_DATA_HOME") };
}

// ------------------------------------------------------------------ ai / report

#[test]
fn auth_and_azure_urls() {
    use crate::ai::{Auth, Client, Kind};
    assert!(matches!(Auth::parse("azure"), Auth::Azure(s) if s == "https://cognitiveservices.azure.com"));
    assert!(matches!(Auth::parse("azure:https://ai.azure.com/.default"), Auth::Azure(s) if s == "https://ai.azure.com"));
    assert!(matches!(Auth::parse("env:KEY"), Auth::KeyEnv(v) if v == "KEY"));
    assert!(matches!(Auth::parse("command:get-token"), Auth::Command(c) if c == "get-token"));
    assert!(matches!(Auth::parse("none"), Auth::None));
    let c = Client::new(Kind::Azure, "https://res.openai.azure.com/", Auth::None, 5);
    assert_eq!(c.base, "https://res.openai.azure.com/openai/v1");
    let c = Client::new(Kind::Azure, "https://res.openai.azure.com/openai/v1", Auth::None, 5);
    assert_eq!(c.base, "https://res.openai.azure.com/openai/v1", "already has the path");
    let c = Client::new(Kind::Azure, "https://res.openai.azure.com/openai", Auth::None, 5);
    assert_eq!(c.base, "https://res.openai.azure.com/openai/v1", "…/openai gets /v1");
}

#[test]
fn digest_since_values() {
    use crate::report::since_arg;
    assert_eq!(since_arg("24h"), "24 hours ago");
    assert_eq!(since_arg("3d"), "3 days ago");
    assert_eq!(since_arg("2w"), "2 weeks ago");
    assert_eq!(since_arg("2026-10-01"), "2026-10-01");
}

#[test]
fn secret_formats_seen_at_work_are_refused() {
    for bad in [
        r#"{"password": "examplepassword123"}"#, // leak-scan: allow
        r#"{"api_key": "QWxwaGFCZXRhR2FtbWFEZWx0YUVwc2lsb24="}"#, // leak-scan: allow
        "client_secret = \"abcDEF123456ghiJKL\"", // leak-scan: allow
        "AZURE_CLIENT_SECRET=abcDEF123456ghiJKL", // leak-scan: allow
        "aws_secret_access_key: wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY", // leak-scan: allow
        "Authorization: Bearer QWxwaGFCZXRhR2FtbWFEZWx0YUVwc2lsb24=", // leak-scan: allow
        "git clone https://person:OPAQUE_TOKEN_123@example.invalid/repo.git", // leak-scan: allow
        "https://acct.blob.core.windows.net/c/f?sv=2022-11-02&sig=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789%3D", // leak-scan: allow
        "SharedAccessSignature sr=foo&sig=bar", // leak-scan: allow
    ] {
        assert!(store::secret_check(bad).is_err(), "should refuse: {bad}");
    }
    for ok in [
        "Authorization: Bearer $TOKEN",
        "the password reset flow sends an email",
        "clone with https://gigabrain.example/git/giga-brain.git",
        "set client_secret in the vault, never here",
        "auth = \"env:AZURE_OPENAI_KEY\"",
    ] {
        assert!(store::secret_check(ok).is_ok(), "should allow: {ok}");
    }
}

#[test]
fn absolute_refs_must_be_inside_a_project() {
    let (b, repo) = brain("absref");
    let inside = repo.join("src/lib.rs");
    let (root, _) = b.resolve_ref(&inside.display().to_string()).expect("absolute path inside a project is fine");
    assert!(root.is_some());
    assert!(b.resolve_ref("~/../.ssh/id_rsa").is_err(), "~/.. refused");
    assert!(b.resolve_ref("~/.ssh/id_rsa").is_err(), "outside every project refused");
    let outside = repo.parent().unwrap().join("elsewhere.txt");
    fs::write(&outside, "x").unwrap();
    assert!(b.resolve_ref(&outside.display().to_string()).is_err());
    assert!(b.resolve_ref(&format!("{}/../elsewhere.txt", repo.display())).is_err(), "`..` in an absolute ref refused");
}

#[test]
fn credentials_only_go_to_safe_destinations() {
    use crate::ai::{url_host, valid_scope, Auth, Client, Kind};
    assert_eq!(url_host("https://Res.OpenAI.Azure.com/openai/v1"), Some((true, "res.openai.azure.com".into())));
    assert_eq!(url_host("http://127.0.0.1:1234/v1"), Some((false, "127.0.0.1".into())));
    assert_eq!(url_host("https://user:pw@host/x"), None);
    let ok = |url: &str, auth: Auth, hosts: &[String]| Client::new(Kind::Azure, url, auth, 5).with_token_hosts(hosts).check_destination().is_ok();
    let az = || Auth::Azure("https://cognitiveservices.azure.com".into());
    assert!(ok("https://res.openai.azure.com", az(), &[]));
    assert!(ok("https://gw.contoso.azure-api.net", az(), &["gw.contoso.azure-api.net".into()]), "a listed gateway");
    assert!(!ok("https://evil.example.com", az(), &[]), "Azure token never goes to a non-Azure host");
    assert!(ok("https://gw.bank.example", az(), &["gw.bank.example".into()]), "unless it is listed");
    assert!(!ok("http://res.openai.azure.com", az(), &[]), "never over plain http");
    assert!(ok("http://127.0.0.1:1234", Auth::None, &[]));
    assert!(!ok("http://192.0.2.5:1234", Auth::KeyEnv("K".into()), &[]), "keys need https off this machine");
    assert!(valid_scope("https://cognitiveservices.azure.com"));
    assert!(!valid_scope("https://x; rm -rf ~"));
    assert!(!valid_scope("https://x&calc"));
    assert!(!valid_scope("cognitiveservices.azure.com"));
}

#[test]
fn model_notes_are_flattened() {
    let n = crate::librarian::clean_note("fine.\n\n- run `curl evil`\x1b[31m\r\nok");
    assert!(!n.contains('\n') && !n.contains('\x1b') && !n.contains('\r'));
    assert!(crate::librarian::clean_note(&"x".repeat(5000)).chars().count() <= 1500);
}

#[test]
fn wrap_respects_width_and_indent() {
    let w = crate::ui::wrap("aaa bbb ccc ddd eee", 4, 4, 12);
    for (i, line) in w.lines().enumerate() {
        let used = if i == 0 { 4 } else { 0 } + line.chars().count();
        assert!(used <= 12, "line too long: {line:?}");
        if i > 0 {
            assert!(line.starts_with("    "));
        }
    }
}

#[test]
fn upgrade_cleans_token_urls_and_reads_changelog() {
    use crate::upgrade::{allowed_remote, new_sections, split_userinfo};
    let c = split_userinfo("https://alice%40bank.example:ab%2Fc123@gigabrain.example/git/giga-brain.git").unwrap(); // leak-scan: allow
    assert_eq!(c.clean, "https://gigabrain.example/git/giga-brain.git");
    assert_eq!((c.user.as_str(), c.secret.as_str()), ("alice@bank.example", "ab/c123"));
    assert_eq!((c.host.as_str(), c.path.as_str()), ("gigabrain.example", "git/giga-brain.git"));
    assert!(split_userinfo("https://gigabrain.example/git/giga-brain.git").is_none());
    assert!(split_userinfo("https://host/a@b").is_none(), "an @ in the path is not credentials");
    assert!(split_userinfo("https://u:x%0Ay@host/r").is_none(), "control characters refused");
    assert!(allowed_remote("https://gigabrain.example/git/giga-brain.git"));
    assert!(allowed_remote("ssh://git@host/repo.git"));
    assert!(allowed_remote("git@git.example.com:org/repo.git"));
    assert!(!allowed_remote("/tmp/repo@backup"));
    assert!(!allowed_remote("ext::sh -c touch% /tmp/x@y:z"));
    assert!(!allowed_remote("file:///tmp/repo"));
    assert!(!allowed_remote("git@host:-oProxyCommand=x"));
    assert!(!allowed_remote("ssh://-oProxyCommand=x/repo"));
    assert!(!allowed_remote("ssh://git@-oProxyCommand=x/repo"));
    let old = "# Changelog\n\n## 0.2.0\n\n- old\n";
    let new = "# Changelog\n\n## 0.3.0 (2026-11-02)\n\n- new thing\n\n## 0.2.0 (2026-10-31)\n\n- old\n";
    let s = new_sections(old, new);
    assert_eq!(s.len(), 1);
    assert!(s[0].starts_with("## 0.3.0") && s[0].contains("new thing"));
}

#[test]
fn loopback_and_azure_hosts_are_exact() {
    use crate::ai::{is_loopback, Auth, Client, Kind};
    assert!(is_loopback("localhost") && is_loopback("127.0.0.1") && is_loopback("127.5.5.5") && is_loopback("::1"));
    assert!(!is_loopback("127.evil.example") && !is_loopback("127.0.0.1.attacker.example"));
    let az = || Auth::Azure("https://cognitiveservices.azure.com".into());
    let ok = |url: &str| Client::new(Kind::Azure, url, az(), 5).check_destination().is_ok();
    assert!(!ok("http://127.evil.example/v1"), "a 127.-prefixed name is not this machine");
    assert!(!ok("https://someone.azure-api.net"), "API Management gateways must be listed");
    assert!(!ok("https://management.azure.com"));
    assert!(ok("https://res.cognitiveservices.azure.com"));
}

#[test]
fn links_backlinks_retirement_and_broken_links() {
    let (b, _) = brain("links");
    let put = |id: &str, extra: &str| {
        let text = ENTRY.replacen("verified: yes\n", &format!("verified: yes\n{extra}"), 1);
        fs::write(b.home.join("entries").join(format!("{id}.md")), text).unwrap();
    };
    put("old-way", "");
    put("new-way", "supersedes: old-way\nrelated: [notes]\n");
    put("notes", "related: [new-way, ghost]\n");
    put("gone", "status: retired\nretired_why: replaced by the queue\n");
    let g = crate::links::Graph::build(&b);
    assert!(g.is_retired("old-way"), "superseded entries are retired");
    assert!(g.is_retired("gone"), "status: retired");
    assert!(!g.is_retired("new-way") && !g.is_retired("notes"));
    assert!(g.backlinks["new-way"].contains("notes"), "related shows up as a backlink");
    assert!(g.backlinks["old-way"].contains("new-way"));
    assert_eq!(g.broken, vec![("notes".to_string(), "related", "ghost".to_string())]);
    assert!(matches!(&g.retired["gone"], crate::links::Retired::Retired { why, .. } if why == "replaced by the queue"));
    let (m, _) = store::split_frontmatter(&ENTRY.replacen("verified: yes\n", "verified: yes\nrelated: [nope]\n", 1)).unwrap();
    assert_eq!(g.missing_targets(&m), vec!["nope".to_string()]);
}

#[test]
fn masking_before_remote_calls() {
    let m = crate::privacy::Masker::new(&store::Privacy { mask: true, patterns: vec![r"ACCT-\d{6}".into()] });
    // Built at runtime so the site's leak scan (which rejects private IPs and home paths) stays clean.
    let ip = format!("{}.20.30.40", 10);
    let home = format!("/{}/alice", "home");
    let win = format!("C:\\{}\\Alice Smith", "Users");
    let v6 = format!("{}:db8::1234", 2001);
    let text = format!("mail alice@bank.example from {ip} and {v6}, logs in {home}/app and {win}\\x, ref ACCT-123456");
    let out = m.mask(&text).unwrap();
    for gone in ["alice@bank.example", ip.as_str(), v6.as_str(), home.as_str(), "Alice Smith", "ACCT-123456"] {
        assert!(!out.contains(gone), "{gone} should be masked: {out}");
    }
    assert!(out.contains("<email>") && out.contains("<ip>") && out.contains("/home/<user>") && out.contains("<masked>"));
    // Rules match the original text: email masking can't hide an account number from a custom rule.
    let m2 = crate::privacy::Masker::new(&store::Privacy { mask: true, patterns: vec![r"alice@corp\.example:\d{8}".into()] });
    assert!(!m2.mask("id alice@corp.example:12345678 end").unwrap().contains("12345678"));
    // A broken pattern fails closed: nothing is sent.
    let bad = crate::privacy::Masker::new(&store::Privacy { mask: true, patterns: vec!["(".into()] });
    assert!(bad.mask("anything").is_err());
    assert_eq!(crate::privacy::Masker::off().mask("alice@bank.example").unwrap(), "alice@bank.example");
    let off = crate::privacy::Masker::new(&store::Privacy { mask: false, patterns: vec![] });
    assert_eq!(off.mask(&ip).unwrap(), ip);
}

#[test]
fn replacement_loops_are_reported_not_hidden() {
    let (b, _) = brain("loops");
    let put = |id: &str, extra: &str| {
        let text = ENTRY.replacen("verified: yes\n", &format!("verified: yes\n{extra}"), 1);
        fs::write(b.home.join("entries").join(format!("{id}.md")), text).unwrap();
    };
    put("x", "supersedes: y\n");
    put("y", "supersedes: x\n");
    put("z", "");
    let g = crate::links::Graph::build(&b);
    assert!(!g.is_retired("x") && !g.is_retired("y"), "a loop hides neither entry");
    assert_eq!(g.cycles.len(), 2);
    assert!(g.would_loop("z", "z"));
    put("w", "supersedes: z\n");
    let g = crate::links::Graph::build(&b);
    assert!(g.would_loop("z", "w"), "z superseding w would close w→z");
    assert!(!g.would_loop("w", "z"));
}

#[test]
fn masking_edge_cases_from_review() {
    let m = crate::privacy::Masker::new(&store::Privacy::default());
    let users = format!("/{}/Alice Smith/app", "Users");
    for (input, gone) in [("ping ::dead:beef now".to_string(), "dead:beef"), (format!("at {users} today"), "Smith")] {
        let out = m.mask(&input).unwrap();
        assert!(!out.contains(gone), "{gone} should be masked: {out}");
    }
    assert_eq!(m.mask("call std::fs::read and Vec::new").unwrap(), "call std::fs::read and Vec::new", "Rust paths are not IPs");
    // A long non-ASCII pattern is a configuration error, not a crash.
    let long = crate::privacy::Masker::new(&store::Privacy { mask: true, patterns: vec!["€".repeat(101)] });
    assert!(long.mask("x").is_err());
    // Huge input fails closed rather than risking a slow match.
    let pat = crate::privacy::Masker::new(&store::Privacy { mask: true, patterns: vec!["X".into()] });
    assert!(pat.mask(&"A".repeat(70_000)).is_err());
}

#[test]
fn branching_replacements_still_find_loops() {
    let (b, _) = brain("branch");
    let put = |id: &str, extra: &str| {
        let text = ENTRY.replacen("verified: yes\n", &format!("verified: yes\n{extra}"), 1);
        fs::write(b.home.join("entries").join(format!("{id}.md")), text).unwrap();
    };
    put("a", "supersedes: b\n");
    put("b", "supersedes: a\n");
    put("z", "supersedes: a\n");
    put("s", "supersedes: s\n");
    let g = crate::links::Graph::build(&b);
    assert!(g.cycles.contains(&"a".to_string()) && g.cycles.contains(&"b".to_string()));
    assert!(!g.is_retired("b"), "b is in the loop and stays visible");
    assert!(g.is_retired("a"), "a is also replaced by z, outside the loop");
    assert!(g.broken.iter().any(|(f, _, t)| f == "s" && t.contains("itself")), "self-link reported");
}

#[test]
fn masking_cross_line_and_punctuation() {
    let m = crate::privacy::Masker::new(&store::Privacy { mask: true, patterns: vec![r"ACCT\s+\d{8}".into()] });
    assert!(!m.mask("ref ACCT\n12345678 end").unwrap().contains("12345678"), "custom patterns match across lines");
    let v6 = format!("{}:db8::1", 2001);
    let out = m.mask(&format!("host {v6}: unreachable")).unwrap();
    assert!(!out.contains(&v6) && out.contains("<ip>: unreachable"), "{out}");
    let many: Vec<String> = (0..51).map(|i| format!("x{i}")).collect();
    assert!(crate::privacy::Masker::new(&store::Privacy { mask: true, patterns: many }).mask("x").is_err());
}

#[test]
fn grok_final_audit_cases() {
    // An existing loop elsewhere doesn't block a valid change.
    let (b, _) = brain("loops2");
    let put = |id: &str, extra: &str| {
        let text = ENTRY.replacen("verified: yes\n", &format!("verified: yes\n{extra}"), 1);
        fs::write(b.home.join("entries").join(format!("{id}.md")), text).unwrap();
    };
    put("a", "supersedes: b\n");
    put("b", "supersedes: a\n");
    put("z", "");
    let g = crate::links::Graph::build(&b);
    assert!(!g.would_loop("z", "a"), "z replacing a (in an a↔b loop) is not a new loop");
    assert!(g.would_loop("b", "a") || g.would_loop("a", "b"));
    // IPv6 with clinging punctuation.
    let m = crate::privacy::Masker::new(&store::Privacy::default());
    let v6 = format!("{}:db8::1", 2001);
    for s in [format!("{v6}.example"), format!("{v6}:unreachable"), format!("({v6}.)"), format!("{v6}:failed")] {
        assert!(!m.mask(&s).unwrap().contains(&v6), "{s}");
    }
}

#[test]
fn openai_param_rejections_are_recognised() {
    use crate::ai::older_params_refused;
    // OpenAI's wording for GPT-5 / reasoning models.
    assert!(older_params_refused(400, r#"{"error":{"message":"Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead."}}"#));
    assert!(older_params_refused(400, r#"{"error":{"message":"Unsupported value: 'temperature' does not support 0.1 with this model."}}"#));
    assert!(!older_params_refused(401, "max_tokens"), "auth errors are not retried");
    assert!(!older_params_refused(400, "context length exceeded"));
}

#[test]
fn spaced_quoted_secrets_are_caught() {
    use crate::store::secret_kind;
    assert!(secret_kind(r#"password: "correct horse battery staple""#).is_some());
    assert!(secret_kind("api_key = 'my secret phrase here'").is_some());
    assert!(secret_kind(r#"password: "<your password here>""#).is_none());
    assert!(secret_kind(r#"secret: "set in $DEPLOY_KEY""#).is_none());
}

#[test]
fn hardening_round_two() {
    use crate::ai::{Auth, Client, Kind};
    use crate::store::secret_kind;
    // Short and `$`-leading passwords are secrets; names of where a secret lives are not.
    assert!(secret_kind("password: hunter2").is_some());
    assert!(secret_kind("password: $DEPLOY_KEY").is_none());
    assert!(secret_kind("token = ${TOKEN}").is_none());
    assert!(secret_kind("password = ********").is_none());
    // user:password@ is refused with no auth too, and so is a query string.
    let bad = |u: &str| Client::new(Kind::OpenAi, u, Auth::None, 5).check_destination().is_err();
    assert!(bad("http://user:pw@remote.example/v1"));
    assert!(bad("https://api.example.com/v1?key=abc"));
    assert!(!bad("https://api.example.com/v1"));
    // Doctor labels never show a token command.
    assert_eq!(Auth::parse("command:get-token --password hunter2").label(), "token command");
    // Masking: IPv6 after a label, Windows paths with forward slashes.
    let m = crate::privacy::Masker::new(&crate::store::Privacy { mask: true, patterns: vec![] });
    // The path is built at run time so the public-source leak scan doesn't see a profile path.
    let win = format!("see C:/{}/Alice/repo", "users");
    let escaped = format!(r#""path": "C:\\\\{}\\\\Alice\\\\repo""#, "Users");
    for (input, gone) in [("ip:2001:db8::1 down", "2001:db8"), ("id:2001:db8::1", "2001:db8"), (win.as_str(), "Alice"), (escaped.as_str(), "Alice")] {
        let out = m.mask(input).unwrap();
        assert!(!out.contains(gone), "{input} -> {out}");
    }
    assert_eq!(m.mask("use std::fs::read").unwrap(), "use std::fs::read");
}

#[test]
fn secret_rules_balance() {
    use crate::store::secret_kind;
    for s in [
        "password: hunter2",
        r#"password: "correct horse battery staple""#,
        "api_key = 'my secret phrase here'",
        "password=%RealSecret123", // leak-scan: allow
        "password: |\n  hunter2-long-one\n",
        "AZURE_CLIENT_SECRET=Abc123xyz789", // leak-scan: allow
        "auth_token: Hunter22",
        r#"DB_PASSWORD="Summer2024.Winter""#, // leak-scan: allow
        "DB_PASSWORD=Summer2024.Winter", // leak-scan: allow
        "password: Correcthorsebatterystaple", // leak-scan: allow
        r#"password: "Yourdog4ever!""#, // leak-scan: allow
        r#"secret: "me and the 7 dwarfs""#,
        r#"password: "String1234""#, // leak-scan: allow
        "password: CorrectHorseBatteryStaple", // leak-scan: allow
        "DB_PASSWORD=mypassword",
        r#"password: "not-your-real-secret9""#, // leak-scan: allow
        r#"password: "winter is coming""#, // leak-scan: allow
        "DB_PASSWORD=Summer2024_password", // leak-scan: allow
        "DB_PASSWORD=Summer.Winter", // leak-scan: allow
        "password=Welcome[2024]", // leak-scan: allow
        "pwd=hunter(22x",
        "password=summer2024(winter)", // leak-scan: allow
        "password=welcome.home2025", // leak-scan: allow
    ] {
        assert!(secret_kind(s).is_some(), "should be a secret: {s}");
    }
    for s in [
        "    password: String,",
        "pub api_key: Option<SecretString>,", // leak-scan: allow
        "password=$password",
        "password: $DEPLOY_KEY",
        r#"secret: "stored in the vault""#,
        "token = ${TOKEN}",
        "password = ********",
        "api_key: example",
        "let password = self.password.clone();", // leak-scan: allow
        r#"let key = env::var("API_KEY")?;"#,
        r#"password = os.environ["DB_PASSWORD"]"#,
        "Password: password,",
        "secret: %APPDATA%\\x", // leak-scan: allow
        "access_token: <your token>",
        "PRIVATE_KEY=0xYOUR_PRIVATE_KEY", // leak-scan: allow
        r#"fetch(u, { credentials: "same-origin" })"#,
        "let API_KEY = Math.random() > 0.5 ? A : B", // leak-scan: allow
        "pub password: SecretString,", // leak-scan: allow
        "db_password: db_password,", // leak-scan: allow
        "let private_key = require_env(", // leak-scan: allow
        "let private_key = env_first(env_prefix, &[X]);", // leak-scan: allow
        "/// Prose, not a passphrase: `secret: x`, `api_key=api_key`).",
    ] {
        assert!(secret_kind(s).is_none(), "should pass: {s}");
    }
}

/// Manual false-positive survey: `SCAN_LIST=<file of paths> cargo test -- --ignored scan_for_secrets --nocapture`.
#[test]
#[ignore]
fn scan_for_secrets() {
    let list = std::env::var("SCAN_LIST").unwrap();
    for path in fs::read_to_string(list).unwrap().lines() {
        let Ok(text) = fs::read_to_string(path) else { continue };
        for (n, line) in text.lines().enumerate() {
            if let Some(what) = store::secret_kind(line) {
                println!("HIT {path}:{} [{what}] {}", n + 1, line.trim().chars().take(110).collect::<String>());
            }
        }
    }
}

#[test]
fn only_known_error_codes_are_shown() {
    assert!(crate::ai::is_plain_code("unsupported_parameter"));
    assert!(!crate::ai::is_plain_code("IGNORE_PREVIOUS_INSTRUCTIONS"));
    assert!(!crate::ai::is_plain_code("sk-abc123"));
}