use crate::config::*;
use serial_test::serial;
use std::fs;
use std::path::{Path, PathBuf};
#[test]
fn test_expand_tilde_with_home() {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| "/tmp".into());
let expanded = expand_tilde(PathBuf::from("~/foo/bar"));
assert_eq!(expanded, PathBuf::from(home).join("foo/bar"));
}
#[test]
fn unknown_sections_flags_typos_but_not_known_keys() {
let raw: toml::value::Table =
toml::from_str("[embeddings]\nprovider = \"ollama\"\n[database]\npath = \"x\"\n").unwrap();
let unknown = unknown_sections(&raw);
assert_eq!(unknown, vec!["embeddings"]);
}
#[test]
fn unknown_sections_empty_for_all_known() {
let raw: toml::value::Table = toml::from_str(
"[database]\npath = \"x\"\n[embedding]\nprovider = \"local\"\n[index]\nexclude = []\n",
)
.unwrap();
assert!(unknown_sections(&raw).is_empty());
}
#[test]
fn validate_providers_accepts_known_values() {
let config: CartogConfig =
toml::from_str("[embedding]\nprovider = \"ollama\"\n[reranker]\nprovider = \"none\"\n")
.unwrap();
assert!(validate_providers(&config).is_ok());
}
#[test]
fn validate_providers_accepts_absent_provider() {
let config = CartogConfig::default();
assert!(validate_providers(&config).is_ok());
}
#[test]
fn validate_providers_rejects_unknown_embedding_provider() {
let config: CartogConfig = toml::from_str("[embedding]\nprovider = \"ollma\"\n").unwrap();
let err = validate_providers(&config).unwrap_err();
assert!(
err.contains("ollma"),
"error should name the bad value: {err}"
);
}
#[test]
fn validate_providers_rejects_unknown_reranker_provider() {
let config: CartogConfig = toml::from_str("[reranker]\nprovider = \"bogus\"\n").unwrap();
let err = validate_providers(&config).unwrap_err();
assert!(
err.contains("bogus"),
"error should name the bad value: {err}"
);
}
#[test]
fn read_config_rejects_unknown_provider() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join("config.toml");
fs::write(&cfg_path, "[embedding]\nprovider = \"ollma\"\n").unwrap();
assert!(read_config(&cfg_path).is_none());
}
#[test]
fn expand_tilde_expands_a_bare_tilde() {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| "/tmp".into());
assert_eq!(
expand_tilde(PathBuf::from("~")),
PathBuf::from(&home),
"a bare `~` is the home directory"
);
assert_eq!(
expand_tilde(PathBuf::from("~/work")),
PathBuf::from(&home).join("work"),
"`~/x` keeps working"
);
assert_eq!(
expand_tilde(PathBuf::from("~other/work")),
PathBuf::from("~other/work"),
"`~user` is not ours to expand"
);
}
#[test]
fn expand_tilde_uses_the_platform_separator() {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| "/tmp".into());
let sep = std::path::MAIN_SEPARATOR;
let expanded = expand_tilde(PathBuf::from(format!("~{sep}work")));
assert_eq!(
expanded,
PathBuf::from(&home).join("work"),
"`~{sep}work` must expand on this platform"
);
}
#[test]
fn test_expand_tilde_no_tilde() {
let p = PathBuf::from("/absolute/path");
assert_eq!(expand_tilde(p.clone()), p);
}
#[test]
fn test_read_config_valid_toml() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join("config.toml");
fs::write(&cfg_path, "[database]\npath = \"/tmp/test.db\"\n").unwrap();
let cfg = read_config(&cfg_path).expect("should parse");
assert_eq!(
cfg.database.as_ref().unwrap().path.as_deref(),
Some("/tmp/test.db")
);
}
#[test]
fn test_read_config_missing_file_returns_none() {
let result = read_config(Path::new("/nonexistent/path/config.toml"));
assert!(result.is_none());
}
#[test]
fn test_read_config_invalid_toml_returns_none() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join("config.toml");
fs::write(&cfg_path, "this is {{ not valid toml").unwrap();
assert!(read_config(&cfg_path).is_none());
}
#[test]
fn test_read_config_empty_toml_returns_default() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join("config.toml");
fs::write(&cfg_path, "").unwrap();
let cfg = read_config(&cfg_path).expect("empty toml is valid");
assert!(cfg.database.is_none());
}
#[test]
fn test_resolve_explicit_wins_over_config() {
let cfg = CartogConfig {
database: Some(DatabaseConfig {
path: Some("/config/path.db".to_string()),
}),
..Default::default()
};
let result = resolve_db_path(Some(PathBuf::from("/explicit/path.db")), &cfg);
assert_eq!(result, PathBuf::from("/explicit/path.db"));
}
#[test]
fn test_resolve_config_path_used_when_no_explicit() {
let cfg = CartogConfig {
database: Some(DatabaseConfig {
path: Some("/config/proj.db".to_string()),
}),
..Default::default()
};
let result = resolve_db_path(None, &cfg);
assert_eq!(result, PathBuf::from("/config/proj.db"));
}
#[test]
#[serial]
fn test_resolve_fallback_when_no_config_and_no_git() {
let dir = tempfile::TempDir::new().unwrap();
let canonical = dir.path().canonicalize().unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let result = resolve_db_path(None, &CartogConfig::default());
std::env::set_current_dir(original).unwrap();
assert_eq!(
result,
canonical
.join(cartog_db::DB_DIR)
.join(cartog_db::DB_FILENAME)
);
}
#[test]
#[serial]
fn test_resolve_git_root_detection() {
let dir = tempfile::TempDir::new().unwrap();
let canonical_root = dir.path().canonicalize().unwrap();
let git_dir = dir.path().join(".git");
std::fs::create_dir(&git_dir).unwrap();
let subdir = dir.path().join("subdir");
std::fs::create_dir(&subdir).unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(&subdir).unwrap();
let result = resolve_db_path(None, &CartogConfig::default());
std::env::set_current_dir(original).unwrap();
assert_eq!(
result,
canonical_root
.join(cartog_db::DB_DIR)
.join(cartog_db::DB_FILENAME)
);
}
#[test]
#[serial]
fn test_resolve_prefers_new_layout_over_legacy() {
let dir = tempfile::TempDir::new().unwrap();
let canonical_root = dir.path().canonicalize().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::create_dir(dir.path().join(cartog_db::DB_DIR)).unwrap();
std::fs::write(
dir.path()
.join(cartog_db::DB_DIR)
.join(cartog_db::DB_FILENAME),
b"",
)
.unwrap();
std::fs::write(dir.path().join(cartog_db::LEGACY_DB_FILE), b"").unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let result = resolve_db_path(None, &CartogConfig::default());
std::env::set_current_dir(original).unwrap();
assert_eq!(
result,
canonical_root
.join(cartog_db::DB_DIR)
.join(cartog_db::DB_FILENAME)
);
}
#[test]
#[serial]
fn test_resolve_falls_back_to_legacy_db_file() {
let dir = tempfile::TempDir::new().unwrap();
let canonical_root = dir.path().canonicalize().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::write(dir.path().join(cartog_db::LEGACY_DB_FILE), b"").unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let result = resolve_db_path(None, &CartogConfig::default());
std::env::set_current_dir(original).unwrap();
assert_eq!(result, canonical_root.join(cartog_db::LEGACY_DB_FILE));
}
#[test]
fn validate_providers_accepts_openai() {
let config: CartogConfig = toml::from_str("[embedding]\nprovider = \"openai\"\n").unwrap();
assert!(validate_providers(&config).is_ok());
}
#[test]
fn lsp_override_parses_nested_table() {
let toml_str = r#"
[lsp.dart]
command = ["docker", "run", "--rm", "-i", "-v", "${ROOT}:${ROOT}", "cartog-lsp-dart:stable"]
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
let dart = &cfg.lsp.unwrap().langs["dart"];
assert_eq!(dart.command[0], "docker");
assert_eq!(dart.command.last().unwrap(), "cartog-lsp-dart:stable");
}
#[test]
fn to_lsp_overrides_flattens_to_argv_map() {
let toml_str = r#"
[lsp.go]
command = ["gopls", "serve"]
"#;
let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
let map = to_lsp_overrides(&cfg);
assert_eq!(map["go"], vec!["gopls".to_string(), "serve".to_string()]);
}
#[test]
fn to_lsp_overrides_empty_when_absent() {
assert!(to_lsp_overrides(&CartogConfig::default()).is_empty());
}
#[test]
fn read_config_rejects_empty_lsp_command() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
fs::write(&cfg_path, "[lsp.dart]\ncommand = []\n").unwrap();
assert!(read_config(&cfg_path).is_none());
}
#[test]
fn read_config_rejects_unknown_lsp_field() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
fs::write(&cfg_path, "[lsp.dart]\ncmd = [\"x\"]\n").unwrap();
assert!(read_config(&cfg_path).is_none());
let stray = dir.path().join("stray.toml");
fs::write(&stray, "[lsp.dart]\ncommand = [\"x\"]\nargz = 1\n").unwrap();
assert!(
read_config(&stray).is_none(),
"a stray key alongside a valid `command` must still reject"
);
}
#[test]
fn read_config_accepts_valid_lsp_block() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
fs::write(&cfg_path, "[lsp.go]\ncommand = [\"gopls\", \"serve\"]\n").unwrap();
let cfg = read_config(&cfg_path).expect("valid lsp block parses");
assert!(cfg.lsp.unwrap().langs.contains_key("go"));
}
#[cfg(feature = "lsp")]
#[test]
fn read_config_rejects_unknown_lsp_language() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
fs::write(&cfg_path, "[lsp.pytho]\ncommand = [\"x\"]\n").unwrap();
assert!(read_config(&cfg_path).is_none());
}
#[test]
#[serial]
fn allow_index_creation_refuses_fresh_repo() {
let dir = tempfile::TempDir::new().unwrap();
let absent = dir.path().join(".cartog").join("db.sqlite");
let _guard = scopeguard(AUTO_INIT_ENV);
std::env::remove_var(AUTO_INIT_ENV);
assert!(
!allow_index_creation(&absent, IndexConsent::Absent),
"no config + no DB + no env must refuse"
);
}
#[test]
fn allow_index_creation_allows_with_config_present() {
let dir = tempfile::TempDir::new().unwrap();
let absent = dir.path().join(".cartog").join("db.sqlite");
assert!(allow_index_creation(&absent, IndexConsent::Granted));
}
#[test]
fn allow_index_creation_allows_with_existing_db() {
let dir = tempfile::TempDir::new().unwrap();
let db = dir.path().join("db.sqlite");
std::fs::write(&db, b"").unwrap();
assert!(allow_index_creation(&db, IndexConsent::Absent));
}
#[test]
#[serial]
fn allow_index_creation_stray_wal_without_main_file_is_gated() {
let dir = tempfile::TempDir::new().unwrap();
let db = dir.path().join("db.sqlite");
std::fs::write(dir.path().join("db.sqlite-wal"), b"").unwrap();
let _guard = scopeguard(AUTO_INIT_ENV);
std::env::remove_var(AUTO_INIT_ENV);
assert!(!allow_index_creation(&db, IndexConsent::Absent));
}
#[test]
#[serial]
fn allow_index_creation_allows_with_auto_init_env() {
let dir = tempfile::TempDir::new().unwrap();
let absent = dir.path().join(".cartog").join("db.sqlite");
let _guard = scopeguard(AUTO_INIT_ENV);
std::env::set_var(AUTO_INIT_ENV, "1");
assert!(allow_index_creation(&absent, IndexConsent::Absent));
}
#[test]
#[serial]
fn allow_index_creation_ignores_empty_auto_init_env() {
let dir = tempfile::TempDir::new().unwrap();
let absent = dir.path().join(".cartog").join("db.sqlite");
let _guard = scopeguard(AUTO_INIT_ENV);
std::env::set_var(AUTO_INIT_ENV, "");
assert!(
!allow_index_creation(&absent, IndexConsent::Absent),
"an empty CARTOG_AUTO_INIT must not count as opt-in"
);
}
fn scopeguard(key: &'static str) -> impl Drop {
struct Restore {
key: &'static str,
prev: Option<String>,
}
impl Drop for Restore {
fn drop(&mut self) {
match &self.prev {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
Restore {
key,
prev: std::env::var(key).ok(),
}
}
#[test]
fn unknown_key_keeps_the_rest_of_the_config() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(
&cfg_path,
"[database]\npath = \"/tmp/kept.db\"\n\n[rag]\nrerank_mx = 10\nrerank_max = 33\n",
)
.unwrap();
let cfg = read_config(&cfg_path).expect("a stray key must not reject the whole config");
assert_eq!(
cfg.database.expect("[database] survives").path.as_deref(),
Some("/tmp/kept.db"),
);
assert_eq!(cfg.rag.expect("[rag] survives").rerank_max, Some(33));
}
#[test]
fn unknown_key_still_loads_so_consent_is_preserved() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(&cfg_path, "[security]\nredact_secretz = false\n").unwrap();
assert!(
read_config(&cfg_path).is_some(),
"a typo must not revoke index-creation consent"
);
}
#[test]
fn genuine_syntax_error_is_still_rejected() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(&cfg_path, "[database\npath = \"x\"\n").unwrap();
assert!(read_config(&cfg_path).is_none());
}
#[test]
fn wrong_value_type_is_still_rejected() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(&cfg_path, "[index]\njobs = \"many\"\n").unwrap();
assert!(read_config(&cfg_path).is_none());
}
#[test]
fn unknown_lsp_scalar_key_is_dropped_not_fatal() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(
&cfg_path,
"[lsp]\nmax_concurrent_serverz = 2\n\n[lsp.rust]\ncommand = [\"rust-analyzer\"]\n",
)
.unwrap();
let cfg = read_config(&cfg_path).expect("[lsp] typo must not reject the config");
let lsp = cfg.lsp.expect("[lsp] survives");
assert!(
lsp.langs.contains_key("rust"),
"per-language entry survives"
);
assert_eq!(lsp.max_concurrent_servers, None);
}
#[test]
fn unknown_remote_key_stays_a_hard_rejection() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(
&cfg_path,
"[remote]\nurl = \"s3://b/k\"\npathstyle = true\n",
)
.unwrap();
assert!(
read_config(&cfg_path).is_none(),
"a stray [remote] key must reject the config, not be ignored"
);
}
#[test]
fn toml_still_reports_unknown_fields_in_the_format_we_parse() {
let e = toml::from_str::<CartogConfig>("[security]\nredact_secretz = false\n")
.expect_err("unknown field must error");
assert!(
e.to_string().contains("unknown field"),
"toml error format changed — is_unknown_field_error is now dead: {e}"
);
assert_eq!(
crate::config::repair::unknown_field_name(&e).as_deref(),
Some("redact_secretz"),
"toml no longer names the offending key in a parseable form: {e}"
);
}
#[test]
fn typo_does_not_delete_a_same_named_key_in_another_section() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(
&cfg_path,
"[database]\npath = \"/tmp/kept.db\"\n\n[rag]\npath = \"oops\"\nrerank_max = 33\n",
)
.unwrap();
let cfg = read_config(&cfg_path).expect("stray key must not reject the config");
assert_eq!(
cfg.database.expect("[database] survives").path.as_deref(),
Some("/tmp/kept.db"),
"a [rag] typo must not delete [database] path"
);
assert_eq!(cfg.rag.expect("[rag] survives").rerank_max, Some(33));
}
#[test]
fn typo_does_not_delete_embedding_provider_from_another_section() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(
&cfg_path,
"[embedding]\nprovider = \"ollama\"\n\n[security]\nprovider = \"x\"\n",
)
.unwrap();
let cfg = read_config(&cfg_path).expect("stray key must not reject the config");
assert_eq!(
cfg.embedding.expect("[embedding] survives").provider(),
"ollama",
"a [security] typo must not reset [embedding] provider to the default"
);
}
#[test]
fn multiple_typos_in_different_sections_all_resolve() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(
&cfg_path,
"[database]\npath = \"/tmp/x.db\"\nbogus = 1\n\n[rag]\nrerank_max = 7\nalso_bogus = 2\n",
)
.unwrap();
let cfg = read_config(&cfg_path).expect("stray keys must not reject the config");
assert_eq!(
cfg.database.expect("[database] survives").path.as_deref(),
Some("/tmp/x.db")
);
assert_eq!(cfg.rag.expect("[rag] survives").rerank_max, Some(7));
}
#[test]
fn two_typos_in_one_section_both_resolve() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(&cfg_path, "[rag]\nrerank_max = 7\nbogus1 = 1\nbogus2 = 2\n").unwrap();
let cfg = read_config(&cfg_path).expect("two stray keys must not reject the config");
assert_eq!(cfg.rag.expect("[rag] survives").rerank_max, Some(7));
}
#[test]
fn many_typos_in_one_section_all_resolve() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
let strays: String = (0..12).map(|i| format!("bogus{i} = {i}\n")).collect();
std::fs::write(&cfg_path, format!("[rag]\nrerank_max = 7\n{strays}")).unwrap();
let cfg = read_config(&cfg_path).expect("many stray keys must not reject the config");
assert_eq!(cfg.rag.expect("[rag] survives").rerank_max, Some(7));
}
#[test]
fn every_known_section_salvages_a_stray_key() {
const STRICT: &[&str] = &["remote", "lsp"];
for section in KNOWN_CONFIG_SECTIONS.iter().filter(|s| !STRICT.contains(s)) {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(&cfg_path, format!("[{section}]\nbogus_key = 1\n")).unwrap();
assert!(
read_config(&cfg_path).is_some(),
"[{section}] is in KNOWN_CONFIG_SECTIONS but has no salvage arm, \
so a typo there rejects the whole file"
);
}
}
#[test]
fn unknown_lsp_lang_key_stays_a_hard_rejection() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(
&cfg_path,
"[lsp.rust]\ncommand = [\"rust-analyzer\"]\nargz = 1\n",
)
.unwrap();
assert!(
read_config(&cfg_path).is_none(),
"a stray [lsp.<lang>] key must reject the config"
);
}
#[test]
fn typo_elsewhere_never_deletes_a_remote_key() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(
&cfg_path,
"[remote]\nurl = \"s3://team/idx\"\nendpoint = \"https://minio.internal\"\n\
\n[security]\nendpoint = \"typo\"\n",
)
.unwrap();
let cfg = read_config(&cfg_path).expect("stray [security] key must not reject the config");
let remote = cfg.remote.expect("[remote] survives");
assert_eq!(
remote.endpoint.as_deref(),
Some("https://minio.internal"),
"a typo in another section must never delete [remote] endpoint"
);
assert_eq!(remote.url.as_deref(), Some("s3://team/idx"));
}
#[test]
fn lsp_scalar_keys_are_all_real_lsp_config_fields() {
for key in LSP_SCALAR_KEYS {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(&cfg_path, format!("[lsp]\n{key} = 2\n")).unwrap();
let cfg = read_config(&cfg_path)
.unwrap_or_else(|| panic!("[lsp] {key} must parse — is it a real LspConfig field?"));
let lsp = cfg
.lsp
.unwrap_or_else(|| panic!("[lsp] section must survive for key {key}"));
assert!(
!lsp.langs.contains_key(*key),
"{key} was routed into the per-language map instead of a real field \
— LSP_SCALAR_KEYS and LspConfig have drifted"
);
}
}
#[test]
fn typo_in_a_provider_subtable_keeps_the_subtable() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
std::fs::write(
&cfg_path,
"[embedding]\nprovider = \"openai\"\n\n[embedding.openai]\n\
base_url = \"http://good.example/v1\"\napi_key_env = \"MY_CUSTOM_KEY\"\n\
base_urll = \"typo\"\n",
)
.unwrap();
let cfg = read_config(&cfg_path).expect("stray sub-table key must not reject the config");
let openai = cfg
.embedding
.expect("[embedding] survives")
.openai
.expect("[embedding.openai] must survive a typo inside it");
assert_eq!(
openai.base_url(),
"http://good.example/v1",
"a typo must not repoint the endpoint at the public API"
);
assert_eq!(openai.api_key_env(), "MY_CUSTOM_KEY");
}
#[test]
fn consent_is_absent_when_no_config_file_exists() {
assert_eq!(ConfigLoad::Missing.consent(), IndexConsent::Absent);
}
#[test]
fn consent_is_granted_by_a_loaded_config() {
let loaded = ConfigLoad::Loaded {
config: CartogConfig::default(),
path: PathBuf::from("/tmp/.cartog.toml"),
};
assert_eq!(loaded.consent(), IndexConsent::Granted);
}
#[test]
fn consent_is_granted_by_a_rejected_config_too() {
let rejected = ConfigLoad::Rejected {
path: PathBuf::from("/tmp/.cartog.toml"),
};
assert_eq!(
rejected.consent(),
IndexConsent::Granted,
"a config file that failed to parse is still an opt-in: the file exists"
);
}
#[test]
fn a_rejected_config_still_allows_index_creation() {
let dir = tempfile::TempDir::new().unwrap();
let absent_db = dir.path().join("nope.sqlite");
let rejected = ConfigLoad::Rejected {
path: dir.path().join(".cartog.toml"),
};
assert!(
allow_index_creation(&absent_db, rejected.consent()),
"a broken config must not be reported as no config"
);
}
#[test]
fn settings_still_fall_back_to_defaults_when_rejected() {
let rejected = ConfigLoad::Rejected {
path: PathBuf::from("/tmp/.cartog.toml"),
};
let cfg = rejected.config_or_default();
assert!(cfg.database.is_none());
assert!(cfg.embedding.is_none());
assert!(cfg.index.is_none());
}
#[test]
fn is_granted_matches_the_variant() {
assert!(IndexConsent::Granted.is_granted());
assert!(!IndexConsent::Absent.is_granted());
}
struct AutoInitGuard(Option<String>);
impl AutoInitGuard {
fn clearing() -> Self {
let prev = std::env::var(AUTO_INIT_ENV).ok();
std::env::remove_var(AUTO_INIT_ENV);
Self(prev)
}
}
impl Drop for AutoInitGuard {
fn drop(&mut self) {
match self.0.take() {
Some(v) => std::env::set_var(AUTO_INIT_ENV, v),
None => std::env::remove_var(AUTO_INIT_ENV),
}
}
}
#[test]
fn explicit_db_override_lifts_the_unknown_db_path_refusal() {
let dir = tempfile::TempDir::new().unwrap();
let absent = dir.path().join(".cartog").join("db.sqlite");
let over = dir.path().join("explicit.db");
assert_eq!(
IndexCreation::resolve(&absent, IndexConsent::Granted, true, None),
IndexCreation::RefusedUnknownDbPath,
"a rejected config with no override must not guess the db path"
);
assert_eq!(
IndexCreation::resolve(&absent, IndexConsent::Granted, true, Some(&over)),
IndexCreation::Allowed,
"an explicit --db settles the location the rejected config left unknown"
);
}
#[test]
#[serial]
fn rejected_config_refuses_on_db_path_not_on_consent() {
let _guard = AutoInitGuard::clearing();
let dir = tempfile::TempDir::new().unwrap();
let absent = dir.path().join(".cartog").join("db.sqlite");
assert_eq!(
IndexCreation::resolve(&absent, IndexConsent::Granted, true, None),
IndexCreation::RefusedUnknownDbPath
);
assert_eq!(
IndexCreation::resolve(&absent, IndexConsent::Absent, false, None),
IndexCreation::RefusedNoConsent,
"no config at all is the generic no-consent case"
);
}
#[test]
fn existing_db_lifts_the_unknown_db_path_refusal() {
let dir = tempfile::TempDir::new().unwrap();
let db = dir.path().join("db.sqlite");
std::fs::write(&db, b"").unwrap();
assert_eq!(
IndexCreation::resolve(&db, IndexConsent::Granted, true, None),
IndexCreation::Allowed
);
}
#[test]
fn project_is_a_known_config_section() {
let raw: toml::value::Table =
toml::from_str("[project]\nname = \"billing-service\"\n").unwrap();
assert!(unknown_sections(&raw).is_empty());
}
#[test]
fn validate_project_rejects_an_over_length_name() {
let config: CartogConfig =
toml::from_str(&format!("[project]\nname = \"{}\"\n", "n".repeat(101))).unwrap();
let err = validate_project(&config).expect_err("101 chars must be rejected");
assert!(
err.contains("[project] name"),
"message names the field: {err}"
);
assert!(err.contains("100"), "message names the limit: {err}");
assert!(err.contains("101"), "message names what was given: {err}");
}
#[test]
fn validate_project_rejects_an_over_length_description() {
let config: CartogConfig = toml::from_str(&format!(
"[project]\ndescription = \"{}\"\n",
"d".repeat(312)
))
.unwrap();
let err = validate_project(&config).expect_err("312 chars must be rejected");
assert_eq!(
err,
"[project] description exceeds 280 characters (got 312)"
);
}
#[test]
fn validate_project_accepts_a_value_exactly_at_the_cap() {
let config: CartogConfig = toml::from_str(&format!(
"[project]\ndescription = \"{}\"\n",
"d".repeat(280)
))
.unwrap();
assert!(validate_project(&config).is_ok());
}
#[test]
fn validate_project_rejects_a_newline_in_the_description() {
let config: CartogConfig =
toml::from_str("[project]\ndescription = \"line one\\nline two\"\n").unwrap();
let err = validate_project(&config).expect_err("a newline must be rejected");
assert!(
err.contains("[project] description") && err.contains("control character"),
"{err}"
);
}
#[test]
fn validate_project_rejects_a_control_character_in_the_name() {
let config: CartogConfig = toml::from_str("[project]\nname = \"svc\\u0007billing\"\n").unwrap();
let err = validate_project(&config).expect_err("a BEL must be rejected");
assert!(err.contains("[project] name"), "{err}");
}
#[test]
fn validate_project_rejects_a_tab_in_the_description() {
let config: CartogConfig = toml::from_str("[project]\ndescription = \"one\\ttwo\"\n").unwrap();
assert!(validate_project(&config).is_err());
}
#[test]
fn validate_project_rejects_a_whitespace_only_name() {
let config: CartogConfig = toml::from_str("[project]\nname = \" \"\n").unwrap();
let err = validate_project(&config).expect_err("whitespace-only must be rejected");
assert!(
err.contains("[project] name") && err.contains("empty"),
"{err}"
);
}
#[test]
fn validate_project_accepts_an_absent_section() {
assert!(validate_project(&CartogConfig::default()).is_ok());
}
#[test]
fn validate_project_counts_chars_not_bytes() {
let config: CartogConfig =
toml::from_str(&format!("[project]\nname = \"{}\"\n", "é".repeat(100))).unwrap();
assert!(validate_project(&config).is_ok());
}
#[test]
fn read_config_rejects_an_over_length_project_description() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
fs::write(
&cfg_path,
format!("[project]\ndescription = \"{}\"\n", "d".repeat(281)),
)
.unwrap();
assert!(read_config(&cfg_path).is_none());
}
#[test]
fn unknown_project_key_is_salvaged_without_costing_the_index() {
let dir = tempfile::TempDir::new().unwrap();
let cfg_path = dir.path().join(".cartog.toml");
fs::write(
&cfg_path,
"[project]\nname = \"billing-service\"\ndescriptoin = \"typo\"\n[security]\nredact_secrets = false\n",
)
.unwrap();
let cfg = read_config(&cfg_path).expect("a typo must not reject the whole file");
let project = cfg.project.as_ref().expect("section survives the salvage");
assert_eq!(project.name(), Some("billing-service"));
assert_eq!(project.description(), None);
assert!(
!cfg.security.as_ref().unwrap().redact_secrets(),
"a sibling section's setting still applies"
);
assert_eq!(
ConfigLoad::Loaded {
config: cfg,
path: cfg_path
}
.consent(),
IndexConsent::Granted
);
}
#[test]
fn resolve_project_at_reads_the_named_roots_own_config_not_the_cwd() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path();
fs::write(
root.join(".cartog.toml"),
"[project]\nname = \"Alpha\"\ndescription = \"Does alpha things.\"\n",
)
.unwrap();
let resolved = resolve_project_at(root);
assert_eq!(
resolved.declared,
DeclaredAtRoot::Known {
name: Some("Alpha".to_string()),
description: Some("Does alpha things.".to_string()),
}
);
assert_eq!(
resolved.db_path,
root.join(cartog_db::DB_DIR).join(cartog_db::DB_FILENAME),
"the default db path must be resolved under the named root"
);
}
#[test]
fn resolve_project_at_treats_a_relative_database_path_as_relative_to_that_root() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path();
fs::write(
root.join(".cartog.toml"),
"[database]\npath = \"custom/g.db\"\n",
)
.unwrap();
assert_eq!(resolve_project_at(root).db_path, root.join("custom/g.db"));
}
#[test]
fn resolve_project_at_still_resolves_a_db_path_when_the_config_is_unreadable() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path();
fs::write(root.join(".cartog.toml"), "this is not = = valid toml\n").unwrap();
let resolved = resolve_project_at(root);
assert_eq!(
resolved.db_path,
root.join(cartog_db::DB_DIR).join(cartog_db::DB_FILENAME)
);
assert_eq!(
resolved.declared,
DeclaredAtRoot::Unreadable,
"a rejected config must be distinguishable from one declaring nothing"
);
}
#[test]
fn resolve_project_at_declares_nothing_for_a_root_with_no_config() {
let dir = tempfile::TempDir::new().unwrap();
let resolved = resolve_project_at(dir.path());
assert_eq!(
resolved.declared,
DeclaredAtRoot::Known {
name: None,
description: None,
}
);
}