use std::path::Path;
use anyhow::Result;
use serde::Serialize;
use cartog_db::{Database, DbError};
pub(crate) fn declared_for(
project: Option<&crate::config::ProjectConfig>,
root: &Path,
) -> cartog_registry::Declared {
crate::registry_hook::resolve_declared(
project.and_then(crate::config::ProjectConfig::name),
project.and_then(crate::config::ProjectConfig::description),
root,
)
}
#[derive(Debug, Clone, Copy)]
pub enum ProjectSource<'a> {
Config(Option<&'a crate::config::ProjectConfig>),
Rejected,
}
pub(crate) fn declared_update_for(
source: ProjectSource<'_>,
root: &Path,
) -> cartog_registry::DeclaredUpdate {
match source {
ProjectSource::Config(project) => {
cartog_registry::DeclaredUpdate::Set(declared_for(project, root))
}
ProjectSource::Rejected => cartog_registry::DeclaredUpdate::Keep,
}
}
pub(crate) fn open_db(path: &Path, embedding_dim: usize) -> Result<Database> {
match Database::open_existing(path, embedding_dim) {
Ok(db) => Ok(db),
Err(DbError::NotFound { .. }) => {
Database::open_memory().map_err(|e| open_db_error(path, e.into()))
}
Err(e) => Err(open_db_error(path, e.into())),
}
}
pub(crate) fn open_db_create(path: &Path, embedding_dim: usize) -> Result<Database> {
Database::open(path, embedding_dim).map_err(|e| open_db_error(path, e.into()))
}
pub(crate) fn open_db_error(path: &Path, err: anyhow::Error) -> anyhow::Error {
let raw = err.to_string().to_ascii_lowercase();
let p = path.display();
let hint = if raw.contains("not a database") {
format!(
"database at {p} is corrupt or not a cartog database — \
delete it and run `cartog index .` to rebuild"
)
} else if raw.contains("readonly") || raw.contains("read-only") {
format!(
"database at {p} is not writable — check the file and directory \
permissions, or set [database].path to a writable location"
)
} else {
format!("failed to open cartog database at {p}")
};
err.context(hint)
}
#[cfg(test)]
pub(crate) fn estimate_tokens(s: &str) -> u32 {
(s.len() as u32).div_ceil(4)
}
pub(crate) fn truncate_to_budget(s: &str, max_tokens: u32) -> String {
let max_bytes = (max_tokens as usize) * 4;
if s.len() <= max_bytes {
return s.to_string();
}
let notice = "\n... (truncated to fit token budget)";
let target = max_bytes.saturating_sub(notice.len());
let cut = (target.saturating_sub(3)..=target)
.rev()
.find(|&i| s.is_char_boundary(i))
.unwrap_or(0);
let mut out = s[..cut].to_string();
out.push_str(notice);
out
}
pub(crate) fn output<T: Serialize>(
data: &T,
json: bool,
token_budget: Option<u32>,
human_fmt: impl FnOnce(&T) -> String,
) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(data)?);
} else {
let text = human_fmt(data);
match token_budget {
Some(budget) => print!("{}", truncate_to_budget(&text, budget)),
None => print!("{}", text),
}
}
Ok(())
}
pub(crate) fn empty_index_hint(db: &Database) -> &'static str {
match db.is_empty() {
Ok(true) => " (index is empty — run 'cartog init' then 'cartog index .' first)",
_ => "",
}
}
pub(crate) fn did_you_mean(db: &Database, name: &str) -> String {
if name.is_empty() || matches!(db.is_empty(), Ok(true)) {
return String::new();
}
let candidates = match db.search(name, None, None, 5) {
Ok(c) => c,
Err(_) => return String::new(),
};
if candidates.iter().any(|s| s.name == name) || candidates.is_empty() {
return String::new();
}
let names: Vec<&str> = candidates.iter().map(|s| s.name.as_str()).collect();
format!(" — did you mean: {}?", names.join(", "))
}
pub(crate) fn is_zero(n: &usize) -> bool {
*n == 0
}
#[cfg(test)]
mod tests {
use super::*;
use cartog_core::{Symbol, SymbolKind};
fn project(name: Option<&str>, description: Option<&str>) -> crate::config::ProjectConfig {
crate::config::ProjectConfig {
name: name.map(str::to_string),
description: description.map(str::to_string),
}
}
#[test]
fn the_declared_name_reaches_the_resolved_identity() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = project(Some("billing-service"), None);
let declared = declared_for(Some(&cfg), dir.path());
assert_eq!(declared.name.as_deref(), Some("billing-service"));
}
#[test]
fn the_declared_description_reaches_the_resolved_identity_with_its_source() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = project(None, Some("Invoices."));
let declared = declared_for(Some(&cfg), dir.path());
let d = declared.description.expect("a resolved description");
assert_eq!(d.text, "Invoices.");
assert_eq!(d.source, cartog_registry::DescriptionSource::Config);
}
#[test]
fn surrounding_whitespace_is_trimmed_off_both_declared_values() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = project(Some(" billing-service "), Some(" Invoices. "));
let declared = declared_for(Some(&cfg), dir.path());
assert_eq!(declared.name.as_deref(), Some("billing-service"));
assert_eq!(
declared.description.map(|d| d.text).as_deref(),
Some("Invoices.")
);
}
#[test]
fn no_project_section_resolves_from_the_readme_alone() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("README.md"), "Only the readme.\n").unwrap();
let declared = declared_for(None, dir.path());
assert_eq!(declared.name, None);
let d = declared.description.expect("the readme fallback");
assert_eq!(d.source, cartog_registry::DescriptionSource::Readme);
}
#[test]
fn a_rejected_config_keeps_the_stored_identity_rather_than_resolving_one() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("README.md"), "A readme paragraph.\n").unwrap();
let update = declared_update_for(ProjectSource::Rejected, dir.path());
assert_eq!(update, cartog_registry::DeclaredUpdate::Keep);
}
#[test]
fn a_loaded_config_resolves_a_set_update() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = project(Some("billing-service"), Some("Invoices."));
let update = declared_update_for(ProjectSource::Config(Some(&cfg)), dir.path());
let cartog_registry::DeclaredUpdate::Set(declared) = update else {
panic!("a loaded config must resolve a Set update");
};
assert_eq!(declared.name.as_deref(), Some("billing-service"));
}
#[test]
fn an_absent_config_file_still_resolves_a_set_update_from_the_readme() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("README.md"), "A readme paragraph.\n").unwrap();
let update = declared_update_for(ProjectSource::Config(None), dir.path());
let cartog_registry::DeclaredUpdate::Set(declared) = update else {
panic!("an absent config must still resolve a Set update");
};
assert_eq!(
declared.description.map(|d| d.text).as_deref(),
Some("A readme paragraph.")
);
}
fn db_with_symbol(name: &str) -> Database {
use cartog_core::FileInfo;
let db = Database::open_memory().unwrap();
db.upsert_file(&FileInfo {
path: "a.rs".into(),
last_modified: 0.0,
hash: "h".into(),
language: "rust".into(),
num_symbols: 1,
})
.unwrap();
let sym = Symbol::new(name, SymbolKind::Class, "a.rs", 1, 2, 0, 10, None);
db.insert_symbols(&[sym]).unwrap();
db
}
#[test]
fn open_db_error_corrupt_names_path_and_rebuild() {
let e = anyhow::anyhow!("file is not a database");
let msg = open_db_error(Path::new("/p/.cartog/db.sqlite"), e).to_string();
assert!(msg.contains("/p/.cartog/db.sqlite"), "names path: {msg}");
assert!(msg.contains("corrupt"), "{msg}");
assert!(msg.contains("cartog index"), "{msg}");
}
#[test]
fn open_db_error_readonly_names_path_and_permissions() {
let e = anyhow::anyhow!("attempt to write a readonly database");
let msg = open_db_error(Path::new("/p/db.sqlite"), e).to_string();
assert!(msg.contains("/p/db.sqlite"), "{msg}");
assert!(msg.contains("permission"), "{msg}");
}
#[test]
fn open_db_error_generic_keeps_path() {
let e = anyhow::anyhow!("disk full");
let msg = open_db_error(Path::new("/p/db.sqlite"), e).to_string();
assert!(msg.contains("/p/db.sqlite"), "{msg}");
}
#[test]
fn did_you_mean_suggests_near_matches() {
let db = db_with_symbol("ReviewResult");
let hint = did_you_mean(&db, "Revie");
assert!(hint.contains("did you mean"), "got: {hint}");
assert!(hint.contains("ReviewResult"), "got: {hint}");
}
#[test]
fn did_you_mean_silent_on_exact_match() {
let db = db_with_symbol("ReviewResult");
assert_eq!(did_you_mean(&db, "ReviewResult"), "");
}
#[test]
fn did_you_mean_silent_on_empty_index() {
let db = Database::open_memory().unwrap();
assert_eq!(did_you_mean(&db, "Whatever"), "");
}
#[test]
fn did_you_mean_silent_when_no_candidates() {
let db = db_with_symbol("ReviewResult");
assert_eq!(did_you_mean(&db, "ZZZnomatch"), "");
}
#[test]
fn test_estimate_tokens() {
assert_eq!(estimate_tokens(""), 0);
assert_eq!(estimate_tokens("a"), 1);
assert_eq!(estimate_tokens("abcd"), 1);
assert_eq!(estimate_tokens("abcde"), 2);
assert_eq!(estimate_tokens("abcdefgh"), 2);
}
#[test]
fn test_truncate_to_budget_within_limit() {
let text = "short text";
let result = truncate_to_budget(text, 100);
assert_eq!(result, text);
}
#[test]
fn test_truncate_to_budget_exceeds_limit() {
let text = "a".repeat(200);
let result = truncate_to_budget(&text, 10);
assert!(result.len() <= 40 + 50); assert!(result.ends_with("... (truncated to fit token budget)"));
}
#[test]
fn test_truncate_to_budget_exact_boundary() {
let text = "abcd"; let result = truncate_to_budget(text, 1);
assert_eq!(result, "abcd");
}
#[test]
fn test_truncate_to_budget_unicode() {
let text = "Hello 🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍";
let result = truncate_to_budget(text, 5);
assert!(result.ends_with("... (truncated to fit token budget)"));
}
#[test]
fn empty_index_hint_present_on_fresh_db() {
let db = Database::open_memory().unwrap();
assert!(empty_index_hint(&db).contains("cartog index"));
}
#[test]
fn empty_index_hint_mentions_init() {
let db = Database::open_memory().unwrap();
assert!(empty_index_hint(&db).contains("cartog init"));
}
#[test]
fn open_db_falls_back_to_memory_without_creating_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let db_path = tmp.path().join(".cartog").join("db.sqlite");
let db = open_db(&db_path, cartog_db::DEFAULT_EMBEDDING_DIM).unwrap();
assert!(db.is_empty().unwrap(), "fallback DB must be empty");
assert!(
!db_path.parent().unwrap().exists(),
"open_db must NOT create .cartog/ for a read on a fresh repo"
);
}
#[test]
fn open_db_opens_an_existing_index() {
use cartog_core::FileInfo;
let tmp = tempfile::TempDir::new().unwrap();
let db_path = tmp.path().join(".cartog").join("db.sqlite");
{
let db = Database::open(&db_path, cartog_db::DEFAULT_EMBEDDING_DIM).unwrap();
db.upsert_file(&FileInfo {
path: "a.rs".into(),
last_modified: 0.0,
hash: "h".into(),
language: "rust".into(),
num_symbols: 1,
})
.unwrap();
db.insert_symbols(&[Symbol::new(
"SentinelSym",
SymbolKind::Class,
"a.rs",
1,
2,
0,
10,
None,
)])
.unwrap();
}
let db = open_db(&db_path, cartog_db::DEFAULT_EMBEDDING_DIM).unwrap();
let hits = db.search("SentinelSym", None, None, 5).unwrap();
assert!(
hits.iter().any(|s| s.name == "SentinelSym"),
"open_db must reopen the on-disk index (sentinel present), not the in-memory fallback"
);
}
proptest::proptest! {
#[test]
fn truncate_never_panics(s in ".*", budget in 0u32..64) {
let _ = truncate_to_budget(&s, budget);
}
#[test]
fn truncate_within_budget_is_verbatim(s in ".{0,200}", slack in 0u32..50) {
let budget = (s.len() as u32).div_ceil(4) + slack;
proptest::prop_assert_eq!(truncate_to_budget(&s, budget), s);
}
#[test]
fn truncate_respects_byte_budget(s in ".{0,500}", budget in 0u32..200) {
let max_bytes = (budget as usize) * 4;
proptest::prop_assume!(s.len() > max_bytes);
let notice = "\n... (truncated to fit token budget)";
let out = truncate_to_budget(&s, budget);
proptest::prop_assert!(out.ends_with(notice), "truncated output must carry the notice");
let content = &out[..out.len() - notice.len()];
proptest::prop_assert!(
content.len() <= max_bytes,
"kept {} content bytes > {} budget",
content.len(),
max_bytes
);
}
}
}