use std::path::Path;
use anyhow::Result;
use serde::Serialize;
use cartog_db::Database;
pub(crate) fn open_db(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 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(", "))
}
#[cfg(test)]
mod tests {
use super::*;
use cartog_core::{Symbol, SymbolKind};
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"));
}
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
);
}
}
}