bobbin-ai 0.29.0

Local-first context injection engine for AI coding agents
use super::*;
use crate::breadcrumb::{Breadcrumb, BreadcrumbStore};

fn fixture(root: &Path) -> Breadcrumb {
    std::fs::create_dir_all(root.join(".bobbin")).unwrap();
    std::fs::write(root.join("auth.rs"), "fn refresh_token() {}\n").unwrap();
    let bc = Breadcrumb {
        name: "auth-refresh".into(),
        description: "Refresh flow".into(),
        query: "".into(),
        pinned_files: vec!["auth.rs".into()],
        tags: vec![],
        keywords: vec!["refresh_token".into()],
        created_by: "test".into(),
        created_at: Utc::now(),
        last_recalled: None,
        recall_count: 0,
        ttl_days: 0,
    };
    BreadcrumbStore::new(root)
        .save(&[(bc.name.clone(), bc.clone())].into())
        .unwrap();
    bc
}

#[tokio::test]
async fn literal_keywords_share_dedup_across_hook_inputs_and_restart() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    fixture(root);
    let config = Config::default();
    let store = root.join(".bobbin/breadcrumbs.json");
    let original = std::fs::read(&store).unwrap();
    let mut first = prepare(root, "explain REFRESH_TOKEN", "same", 30, &config, None).await;
    assert!(first.text.contains("auth-refresh"));
    assert!(first.text.contains("pinned: auth.rs"));
    assert!(first.text.lines().count() <= 9);
    // Preparation alone must not consume delivery.
    assert!(!prepare(root, "refresh_token", "same", 30, &config, None)
        .await
        .text
        .is_empty());
    first.commit();
    assert!(prepare(
        root,
        r#"{"command":"refresh_token"}"#,
        "same",
        30,
        &config,
        None
    )
    .await
    .text
    .is_empty());
    assert!(!prepare(root, "refresh_token", "new", 30, &config, None)
        .await
        .text
        .is_empty());
    assert_eq!(std::fs::read(store).unwrap(), original);
}

#[tokio::test]
async fn absent_corrupt_expired_empty_and_nonmatching_are_quiet() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    let config = Config::default();
    assert!(prepare(root, "refresh_token", "s", 30, &config, None)
        .await
        .text
        .is_empty());
    let mut bc = fixture(root);
    assert!(matching(root, "unrelated").is_empty());
    assert!(prepare(root, "refresh_token", "s", 9, &config, None)
        .await
        .text
        .is_empty());
    bc.ttl_days = 1;
    bc.created_at = Utc::now() - chrono::Duration::days(2);
    BreadcrumbStore::new(root)
        .save(&[(bc.name.clone(), bc.clone())].into())
        .unwrap();
    assert!(matching(root, "refresh_token").is_empty());
    bc.ttl_days = 0;
    bc.keywords = vec!["".into(), " ".into()];
    BreadcrumbStore::new(root)
        .save(&[(bc.name.clone(), bc)].into())
        .unwrap();
    assert!(matching(root, "anything").is_empty());
    std::fs::write(root.join(".bobbin/breadcrumbs.json"), "broken").unwrap();
    assert!(prepare(root, "refresh_token", "s", 30, &config, None)
        .await
        .text
        .is_empty());
}

#[tokio::test]
async fn pins_cannot_escape_and_updated_content_rearms() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    let config = Config::default();
    let outside = tempfile::tempdir().unwrap();
    std::fs::write(outside.path().join("secret"), "secret").unwrap();
    let mut bc = fixture(root);
    std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap();
    bc.pinned_files.extend([
        "../secret".into(),
        "/etc/passwd".into(),
        "link/secret".into(),
    ]);
    BreadcrumbStore::new(root)
        .save(&[(bc.name.clone(), bc.clone())].into())
        .unwrap();
    let mut first = prepare(root, "refresh_token", "../bad", 30, &config, None).await;
    assert!(!first.text.contains("secret"));
    assert!(!first.text.contains("passwd"));
    first.commit();
    bc.description = "New flow".into();
    BreadcrumbStore::new(root)
        .save(&[(bc.name.clone(), bc)].into())
        .unwrap();
    assert!(prepare(root, "refresh_token", "../bad", 30, &config, None)
        .await
        .text
        .contains("New flow"));
    assert!(!root.join("bad").exists());
}

#[tokio::test]
async fn access_policy_applies_to_pins_and_unknown_repository_identity() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    fixture(root);
    let mut config = Config::default();
    config.access.roles = vec![crate::config::RoleConfig {
        name: "default".into(),
        allow: vec![],
        deny: vec![],
        deny_paths: vec!["auth.rs".into()],
    }];
    assert!(
        !crate::access::RepoFilter::from_config(&config.access, "unlisted-role")
            .is_path_allowed("repos/project/auth.rs")
    );
    let allowed_metadata = prepare(root, "refresh_token", "s", 30, &config, None).await;
    assert!(allowed_metadata.text.contains("auth-refresh"));
    assert!(!allowed_metadata.text.contains("pinned:"));
    config.access.default_allow = false;
    assert!(prepare(root, "refresh_token", "s", 30, &config, None)
        .await
        .text
        .is_empty());
}

#[tokio::test]
async fn saved_query_uses_remote_search_role_budget_and_path_filter() {
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    let mut bc = fixture(root);
    bc.query = "saved authentication query".into();
    BreadcrumbStore::new(root)
        .save(&[(bc.name.clone(), bc)].into())
        .unwrap();
    let mut config = Config::default();
    config.access.roles = vec![crate::config::RoleConfig {
        name: "default".into(),
        allow: vec![],
        deny: vec![],
        deny_paths: vec!["secrets.*".into(), "harnesses/**".into()],
    }];
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let url = format!("http://{}", listener.local_addr().unwrap());
    let server = tokio::spawn(async move {
        let (mut stream, _) =
            tokio::time::timeout(std::time::Duration::from_secs(3), listener.accept())
                .await
                .unwrap()
                .unwrap();
        let mut input = [0u8; 8192];
        let n = stream.read(&mut input).await.unwrap();
        let request = String::from_utf8_lossy(&input[..n])
            .lines()
            .next()
            .unwrap()
            .to_owned();
        let body = serde_json::json!({
            "query":"saved authentication query", "budget":{"max_lines":5,"used_lines":2},
            "files":[
                {"path":"harnesses/worker/CLAUDE.md","language":"text","relevance":"direct","score":0.95,"chunks":[]},
                {"path":"repos/project/related.rs","language":"rust","relevance":"direct","score":0.9,"chunks":[]},
                {"path":"repos/project/secrets.key","language":"text","relevance":"direct","score":0.9,"chunks":[]}
            ],
            "summary":{"total_files":2,"total_chunks":0,"direct_hits":2,"coupled_additions":0,"bridged_additions":0,"source_files":2,"doc_files":0}
        }).to_string();
        let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",body.len(),body);
        stream.write_all(response.as_bytes()).await.unwrap();
        request
    });
    let result = prepare(root, "refresh_token", "s", 30, &config, Some(&url)).await;
    assert!(
        result.text.contains("related: repos/project/related.rs"),
        "{}",
        result.text
    );
    assert!(!result.text.contains("secrets.key"));
    assert!(!result.text.contains("harnesses/"));
    assert!(result.text.lines().count() <= 9);
    let request = server.await.unwrap();
    assert!(request.contains("q=saved+authentication+query"));
    assert!(request.contains("budget=5"));
    assert!(request.contains("role="));
}

#[test]
fn local_saved_query_subdirectory_acl_keeps_allowed_positive() {
    let mut config = Config::default();
    config.access.roles = vec![crate::config::RoleConfig {
        name: "default".into(),
        allow: vec![],
        deny: vec![],
        deny_paths: vec!["harnesses/**".into()],
    }];
    let access = crate::access::RepoFilter::from_config(&config.access, "default");
    // These are the relative paths returned by the local search leg.
    assert!(!saved_query_path_allowed(
        "harnesses/worker/CLAUDE.md",
        Some("project"),
        &access
    ));
    assert!(saved_query_path_allowed(
        "src/allowed.rs",
        Some("project"),
        &access
    ));
    assert!(!saved_query_path_allowed("src/allowed.rs", None, &access));
    for path in [
        "repos/project/harnesses/worker/CLAUDE.md",
        "/project/src/a.rs",
        "beads:issue",
    ] {
        assert_eq!(
            saved_query_path_allowed(path, Some("project"), &access),
            access.is_path_allowed(path)
        );
    }
}

#[tokio::test]
async fn dedup_does_not_starve_fourth_match() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    let bc = fixture(root);
    let entries = (0..4)
        .map(|i| {
            let mut entry = bc.clone();
            entry.name = format!("crumb-{i}");
            entry.pinned_files.clear();
            (entry.name.clone(), entry)
        })
        .collect();
    BreadcrumbStore::new(root).save(&entries).unwrap();
    let store = root.join(".bobbin/breadcrumbs.json");
    let before = std::fs::read(&store).unwrap();
    for expected in [3, 1, 0] {
        let mut result = prepare(root, "refresh_token", "same", 60, &Config::default(), None).await;
        assert_eq!(result.text.matches("=== Breadcrumb:").count(), expected);
        result.commit();
    }
    assert_eq!(std::fs::read(store).unwrap(), before);
}