saya-cli 0.3.1

Database-aware AI agent for the terminal: full-screen TUI, schema discovery, and bounded read-only SQL over PostgreSQL, MySQL, SQLite, DuckDB, and Snowflake.
use super::*;
use saya_types::DatabaseProfile;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

fn unique_temp_db_path(label: &str, index: usize) -> PathBuf {
    std::env::temp_dir().join(format!(
        "saya-conn-build-test-{}-{}-{}.duckdb",
        label,
        std::process::id(),
        index
    ))
}

fn duckdb_profile(path: &Path) -> DatabaseProfile {
    DatabaseProfile::DuckDb {
        path: path.to_string_lossy().into_owned(),
        read_only: Some(false),
    }
}

#[tokio::test]
async fn build_registry_primary_and_secondary_succeed() {
    let primary_path = unique_temp_db_path("primary", 1);
    let secondary_path = unique_temp_db_path("secondary", 2);

    let primary_prof = duckdb_profile(&primary_path);
    let secondary_prof = duckdb_profile(&secondary_path);

    let resolver = saya_config::MapSecretResolver::new(BTreeMap::new());
    let cache_scope = Path::new("/tmp/test_scope");

    let secondaries = vec![("sec_db".to_string(), secondary_prof)];

    let result = build_registry(
        &resolver,
        cache_scope,
        30,
        false,
        "pri_db",
        &primary_prof,
        &secondaries,
    )
    .await;

    assert!(result.is_ok());
    let (registry, failures) = result.unwrap();

    assert!(failures.is_empty());
    assert_eq!(registry.names().len(), 2);
    assert!(registry.names().contains(&"pri_db"));
    assert!(registry.names().contains(&"sec_db"));

    let context = registry.describe_context();
    assert!(context.is_some());
    assert!(context.as_ref().unwrap().contains("duckdb"));

    let _ = std::fs::remove_file(&primary_path);
    let _ = std::fs::remove_file(&secondary_path);
}

#[tokio::test]
async fn build_registry_soft_skips_failed_secondary() {
    let primary_path = unique_temp_db_path("primary_soft_skip", 1);
    let primary_prof = duckdb_profile(&primary_path);

    let bad_dir = std::env::temp_dir().join(format!("does-not-exist-{}", std::process::id()));
    let bad_path = bad_dir.join("x.duckdb");
    let bad_secondary_prof = duckdb_profile(&bad_path);

    let resolver = saya_config::MapSecretResolver::new(BTreeMap::new());
    let cache_scope = Path::new("/tmp/test_scope");

    let secondaries = vec![("bad_sec".to_string(), bad_secondary_prof)];

    let result = build_registry(
        &resolver,
        cache_scope,
        30,
        false,
        "pri_db",
        &primary_prof,
        &secondaries,
    )
    .await;

    assert!(result.is_ok());
    let (registry, failures) = result.unwrap();

    assert_eq!(registry.names().len(), 1);
    assert_eq!(registry.names(), vec!["pri_db"]);
    assert_eq!(failures.len(), 1);
    assert_eq!(failures[0].0, "bad_sec");
    assert!(!failures[0].1.is_empty());

    let _ = std::fs::remove_file(&primary_path);
    if bad_dir.exists() {
        let _ = std::fs::remove_dir_all(&bad_dir);
    }
}

#[tokio::test]
async fn build_registry_multiple_good_secondaries_connect() {
    let primary_path = unique_temp_db_path("primary_multi", 1);
    let sec1_path = unique_temp_db_path("secondary_multi", 2);
    let sec2_path = unique_temp_db_path("secondary_multi", 3);
    let sec3_path = unique_temp_db_path("secondary_multi", 4);

    let primary_prof = duckdb_profile(&primary_path);
    let sec1_prof = duckdb_profile(&sec1_path);
    let sec2_prof = duckdb_profile(&sec2_path);
    let sec3_prof = duckdb_profile(&sec3_path);

    let resolver = saya_config::MapSecretResolver::new(BTreeMap::new());
    let cache_scope = Path::new("/tmp/test_scope");

    let secondaries = vec![
        ("sec1".to_string(), sec1_prof),
        ("sec2".to_string(), sec2_prof),
        ("sec3".to_string(), sec3_prof),
    ];

    let result = build_registry(
        &resolver,
        cache_scope,
        30,
        false,
        "pri_db",
        &primary_prof,
        &secondaries,
    )
    .await;

    assert!(result.is_ok());
    let (registry, failures) = result.unwrap();

    assert!(failures.is_empty());
    assert_eq!(registry.names().len(), 4);
    assert!(registry.names().contains(&"pri_db"));
    assert!(registry.names().contains(&"sec1"));
    assert!(registry.names().contains(&"sec2"));
    assert!(registry.names().contains(&"sec3"));

    let _ = std::fs::remove_file(&primary_path);
    let _ = std::fs::remove_file(&sec1_path);
    let _ = std::fs::remove_file(&sec2_path);
    let _ = std::fs::remove_file(&sec3_path);
}

#[tokio::test]
async fn build_registry_bad_secondary_does_not_abort_good_secondaries() {
    let primary_path = unique_temp_db_path("primary_mixed", 1);
    let sec1_path = unique_temp_db_path("secondary_mixed", 2);
    let sec2_path = unique_temp_db_path("secondary_mixed", 3);

    let bad_dir = std::env::temp_dir().join(format!("does-not-exist-mixed-{}", std::process::id()));
    let bad_path = bad_dir.join("bad.duckdb");

    let primary_prof = duckdb_profile(&primary_path);
    let sec1_prof = duckdb_profile(&sec1_path);
    let bad_prof = duckdb_profile(&bad_path);
    let sec2_prof = duckdb_profile(&sec2_path);

    let resolver = saya_config::MapSecretResolver::new(BTreeMap::new());
    let cache_scope = Path::new("/tmp/test_scope");

    let secondaries = vec![
        ("sec1".to_string(), sec1_prof),
        ("bad_sec".to_string(), bad_prof),
        ("sec2".to_string(), sec2_prof),
    ];

    let result = build_registry(
        &resolver,
        cache_scope,
        30,
        false,
        "pri_db",
        &primary_prof,
        &secondaries,
    )
    .await;

    assert!(result.is_ok());
    let (registry, failures) = result.unwrap();

    assert_eq!(registry.names().len(), 3);
    assert!(registry.names().contains(&"pri_db"));
    assert!(registry.names().contains(&"sec1"));
    assert!(registry.names().contains(&"sec2"));

    assert_eq!(failures.len(), 1);
    assert_eq!(failures[0].0, "bad_sec");
    assert!(!failures[0].1.is_empty());

    let _ = std::fs::remove_file(&primary_path);
    let _ = std::fs::remove_file(&sec1_path);
    let _ = std::fs::remove_file(&sec2_path);
    if bad_dir.exists() {
        let _ = std::fs::remove_dir_all(&bad_dir);
    }
}