talon-cli 0.4.2

Talon CLI: hybrid retrieval over Obsidian vaults and markdown corpora, with grounded answers, MCP server, and agent-native output.
Documentation
use super::{CONFIG_TEMPLATE, RefreshLockPolicy, load_config_file};
use eyre::Result;
use std::path::PathBuf;

fn temp_config_path(label: &str) -> PathBuf {
    std::env::temp_dir().join(format!("talon-{label}-{}.toml", std::process::id()))
}

fn temp_dir_path(label: &str) -> PathBuf {
    std::env::temp_dir().join(format!("talon-{label}-{}", std::process::id()))
}

#[test]
fn default_config_for_vault_uses_workspace_db_under_home_talon() {
    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
    let config = super::default_config_for_vault(PathBuf::from("/vaults/My Notes!"));

    assert_eq!(
        config.db_path,
        home.join(".talon").join("my-notes.db"),
        "workspace db path should be sanitized under ~/.talon"
    );
}

fn load_config_str(label: &str, config: &str) -> Result<talon_core::TalonConfig> {
    let path = temp_config_path(label);
    fs_err::write(&path, config)?;
    let result = load_config_file(&path);
    let _ = fs_err::remove_file(path);
    result
}

#[test]
fn config_template_parses_indexer_chunk_settings() {
    let config = match load_config_str("template-config", CONFIG_TEMPLATE) {
        Ok(config) => config,
        Err(err) => panic!("template config should parse: {err}"),
    };

    assert_eq!(config.chunker.chunk_tokens, 512);
    assert_eq!(config.chunker.chunk_overlap, 64);
    assert_eq!(config.chunker.chunk_min_tokens, 16);
    assert_eq!(config.search.candidate_limit, 60);
    assert_eq!(config.search.limit, 10);
    assert_eq!(config.search.cache_size, 200);
    assert_eq!(config.search.rerank_cache_size, 2000);
    assert_eq!(config.search.rerank_batch_size, 4);
    assert_eq!(config.search.rerank_max_tokens, 128);
    assert_eq!(config.rerank.adapter, talon_core::RerankAdapter::Minimal);
    assert_eq!(
        config.rerank.score_scale,
        talon_core::RerankScoreScale::Normalized
    );
    assert!(
        config.db_path.is_absolute(),
        "template db_path should load as an absolute path, got {}",
        config.db_path.display()
    );
}

#[test]
fn load_config_file_parses_search_tunables() {
    const CONFIG: &str = r#"
vault_path = "/tmp/vault"
db_path = "/tmp/index.sqlite"

[search]
candidate_limit = 60
limit = 10
cache_size = 200
rerank_cache_size = 2000
rerank_batch_size = 4
rerank_max_tokens = 128

[embedding]
base_url = "http://localhost:8080"
adapter = "tei"
model = "embed"

[rerank]
base_url = "http://localhost:8080"
adapter = "tei"
model = "rerank"
score_scale = "logits"
truncate = false

[chat.expansion]
base_url = "http://localhost:1234/v1"
model = "gemma-smol"
"#;

    let config = load_config_str("search-tunables", CONFIG)
        .unwrap_or_else(|err| panic!("config should load: {err}"));

    assert_eq!(config.search.candidate_limit, 60);
    assert_eq!(config.search.limit, 10);
    assert_eq!(config.search.cache_size, 200);
    assert_eq!(config.search.rerank_cache_size, 2000);
    assert_eq!(config.search.rerank_batch_size, 4);
    assert_eq!(config.search.rerank_max_tokens, 128);
    assert_eq!(config.rerank.adapter, talon_core::RerankAdapter::Tei);
    assert_eq!(
        config.rerank.score_scale,
        talon_core::RerankScoreScale::Logits
    );
    assert!(!config.rerank.truncate);
}

#[test]
fn load_config_file_resolves_relative_paths_from_config_dir() {
    const CONFIG: &str = r#"
vault_path = "vault"
db_path = "state/index.sqlite"

[indexer]
chunk_tokens = 512
chunk_overlap = 64
chunk_min_tokens = 16

[embedding]
base_url = "http://localhost:8080"
adapter = "tei"
model = "embed"

[rerank]
base_url = "http://localhost:8080"
adapter = "minimal"
model = "rerank"

[chat.expansion]
base_url = "http://localhost:1234/v1"
model = "gemma-smol"
"#;
    let path = temp_config_path("relative-paths");
    fs_err::write(&path, CONFIG).unwrap_or_else(|err| panic!("write config: {err}"));

    let loaded = load_config_file(&path).unwrap_or_else(|err| panic!("load config: {err}"));

    let base = path
        .parent()
        .unwrap_or_else(|| std::path::Path::new("/tmp"));
    assert_eq!(loaded.vault_path, base.join("vault"));
    assert_eq!(loaded.db_path, base.join("state").join("index.sqlite"));
    let _ = fs_err::remove_file(path);
}

#[test]
fn load_config_file_rejects_invalid_chunk_overlap() {
    const CONFIG: &str = r#"
vault_path = "/tmp/vault"
db_path = "/tmp/index.sqlite"

[indexer]
chunk_tokens = 64
chunk_overlap = 64
chunk_min_tokens = 16

[embedding]
base_url = "http://localhost:8080"
adapter = "tei"
model = "embed"

[rerank]
base_url = "http://localhost:8080"
adapter = "minimal"
model = "rerank"

[chat.expansion]
base_url = "http://localhost:1234/v1"
model = "gemma-smol"
"#;
    let Err(err) = load_config_str("invalid-chunk-overlap", CONFIG) else {
        panic!("invalid chunk overlap should fail");
    };
    assert!(
        err.chain().any(|cause| cause
            .to_string()
            .contains("indexer.chunk_overlap must be less than indexer.chunk_tokens")),
        "unexpected error: {err}"
    );
}

#[test]
fn load_config_file_rejects_non_canonical_names() {
    for (label, config) in [
        (
            "camel-case-compat",
            r#"
vaultPath = "/tmp/vault"
dbPath = "/tmp/index.sqlite"

[chunker]
chunkTokens = 512
chunkOverlap = 64
chunkMinTokens = 16

[embedding]
baseUrl = "http://localhost:8080"
adapter = "tei"
model = "embed"

[rerank]
baseUrl = "http://localhost:8080"
adapter = "minimal"
model = "rerank"

[chat.expansion]
baseUrl = "http://localhost:1234/v1"
model = "gemma-smol"
"#,
        ),
        (
            "chunker-table-alias",
            r#"
vault_path = "/tmp/vault"
db_path = "/tmp/index.sqlite"

[chunker]
chunk_tokens = 512
chunk_overlap = 64
chunk_min_tokens = 16

[embedding]
base_url = "http://localhost:8080"
adapter = "tei"
model = "embed"

[rerank]
base_url = "http://localhost:8080"
adapter = "minimal"
model = "rerank"

[chat.expansion]
base_url = "http://localhost:1234/v1"
model = "gemma-smol"
"#,
        ),
    ] {
        let Err(err) = load_config_str(label, config) else {
            panic!("{label} should fail");
        };
        assert!(err.to_string().contains("failed to parse config file"));
    }
}

#[test]
fn refresh_index_if_needed_skips_when_lock_is_busy_and_policy_allows_it() {
    let root = temp_dir_path("skip-busy-refresh");
    let vault = root.join("vault");
    fs_err::create_dir_all(&vault).unwrap_or_else(|err| panic!("create vault: {err}"));
    let db = root.join("index.sqlite");
    let mut config = super::default_config_for_vault(vault);
    config.db_path = db.clone();
    let lock_path = super::sync_lock_path(&config);
    let _lock =
        talon_core::acquire_sync_lock(&lock_path).unwrap_or_else(|err| panic!("lock: {err}"));
    let mut conn =
        talon_core::open_database(&db).unwrap_or_else(|err| panic!("open database: {err}"));

    super::refresh_index_if_needed(&config, &mut conn, false, RefreshLockPolicy::SkipIfBusy)
        .unwrap_or_else(|err| panic!("busy refresh should be skipped: {err}"));

    drop(conn);
    let _ = fs_err::remove_dir_all(root);
}

#[test]
fn refresh_index_if_needed_errors_when_lock_is_busy_and_policy_requires_it() {
    let root = temp_dir_path("error-busy-refresh");
    let vault = root.join("vault");
    fs_err::create_dir_all(&vault).unwrap_or_else(|err| panic!("create vault: {err}"));
    let db = root.join("index.sqlite");
    let mut config = super::default_config_for_vault(vault);
    config.db_path = db.clone();
    let lock_path = super::sync_lock_path(&config);
    let _lock =
        talon_core::acquire_sync_lock(&lock_path).unwrap_or_else(|err| panic!("lock: {err}"));
    let mut conn =
        talon_core::open_database(&db).unwrap_or_else(|err| panic!("open database: {err}"));

    let Err(err) =
        super::refresh_index_if_needed(&config, &mut conn, false, RefreshLockPolicy::ErrorIfBusy)
    else {
        panic!("busy refresh should fail when policy requires it");
    };

    assert!(
        err.to_string().contains("auto-refresh failed"),
        "unexpected error: {err}"
    );
    drop(conn);
    let _ = fs_err::remove_dir_all(root);
}