use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
pub const COLOCATED_DIR_NAME: &str = ".trusty-search";
pub const GITIGNORE_LINE: &str = ".trusty-search/";
pub fn colocated_storage_dir(root_path: &Path) -> Result<PathBuf> {
let dir = root_path.join(COLOCATED_DIR_NAME);
std::fs::create_dir_all(&dir)
.with_context(|| format!("create colocated storage dir at {}", dir.display()))?;
Ok(dir)
}
pub fn colocated_hnsw_path(root_path: &Path) -> Result<PathBuf> {
Ok(colocated_storage_dir(root_path)?.join("hnsw.usearch"))
}
pub fn colocated_redb_path(root_path: &Path) -> Result<PathBuf> {
Ok(colocated_storage_dir(root_path)?.join("index.redb"))
}
pub fn colocated_schema_version_path(root_path: &Path) -> Result<PathBuf> {
Ok(colocated_storage_dir(root_path)?.join("schema_version.json"))
}
pub fn colocated_redb_tmp_path(root_path: &Path) -> Result<PathBuf> {
Ok(colocated_storage_dir(root_path)?.join("index.redb.tmp"))
}
pub fn has_colocated_storage(root_path: &Path) -> bool {
let dir = root_path.join(COLOCATED_DIR_NAME);
dir.exists() && dir.is_dir()
}
pub fn ensure_gitignored(root_path: &Path) -> Result<()> {
let gitignore_path = find_or_create_gitignore(root_path)?;
let content = match std::fs::read_to_string(&gitignore_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e).context("read .gitignore"),
};
if gitignore_already_covers(&content) {
tracing::debug!(
".gitignore at {} already covers .trusty-search/",
gitignore_path.display()
);
return Ok(());
}
let needs_newline = !content.is_empty() && !content.ends_with('\n');
let mut new_content = content;
if needs_newline {
new_content.push('\n');
}
new_content.push_str(GITIGNORE_LINE);
new_content.push('\n');
std::fs::write(&gitignore_path, &new_content)
.with_context(|| format!("write .gitignore at {}", gitignore_path.display()))?;
tracing::info!(
"added .trusty-search/ to .gitignore at {}",
gitignore_path.display()
);
Ok(())
}
fn gitignore_already_covers(content: &str) -> bool {
for line in content.lines() {
let trimmed = line.trim();
if trimmed == ".trusty-search/" || trimmed == ".trusty-search" {
return true;
}
}
false
}
fn find_or_create_gitignore(root_path: &Path) -> Result<PathBuf> {
let mut current = root_path;
loop {
let candidate = current.join(".gitignore");
if candidate.exists() {
return Ok(candidate);
}
if current.join(".git").exists() {
return Ok(current.join(".gitignore"));
}
match current.parent() {
Some(p) => current = p,
None => break,
}
}
Ok(root_path.join(".gitignore"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn storage_dir_resolves_under_root() {
let tmp = tempdir().unwrap();
let root = tmp.path();
let dir = colocated_storage_dir(root).unwrap();
assert!(dir.starts_with(root), "dir must be inside root");
assert_eq!(dir.file_name().unwrap(), ".trusty-search");
assert!(dir.exists() && dir.is_dir());
}
#[test]
fn colocated_paths_distinct_for_different_roots() {
let tmp1 = tempdir().unwrap();
let tmp2 = tempdir().unwrap();
let path1 = colocated_redb_path(tmp1.path()).unwrap();
let path2 = colocated_redb_path(tmp2.path()).unwrap();
assert_ne!(path1, path2, "colocated paths must differ per root");
assert!(path1.starts_with(tmp1.path()));
assert!(path2.starts_with(tmp2.path()));
}
#[test]
fn gitignore_entry_added_idempotently() {
let tmp = tempdir().unwrap();
let root = tmp.path();
ensure_gitignored(root).unwrap();
let content1 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
assert!(
content1.contains(GITIGNORE_LINE),
"first call must write the entry"
);
let count = content1
.lines()
.filter(|l| l.trim() == ".trusty-search/")
.count();
assert_eq!(count, 1, "exactly one entry after first call");
ensure_gitignored(root).unwrap();
let content2 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
let count2 = content2
.lines()
.filter(|l| l.trim() == ".trusty-search/")
.count();
assert_eq!(count2, 1, "still exactly one entry after second call");
}
#[test]
fn gitignore_respects_no_trailing_slash_form() {
let tmp = tempdir().unwrap();
let gitignore = tmp.path().join(".gitignore");
std::fs::write(&gitignore, ".trusty-search\n").unwrap();
ensure_gitignored(tmp.path()).unwrap();
let content = std::fs::read_to_string(&gitignore).unwrap();
let count = content
.lines()
.filter(|l| {
let t = l.trim();
t == ".trusty-search/" || t == ".trusty-search"
})
.count();
assert_eq!(count, 1, "no duplicate when no-slash form already present");
}
#[test]
fn gitignore_found_at_parent_level() {
let tmp = tempdir().unwrap();
let parent_gitignore = tmp.path().join(".gitignore");
std::fs::write(&parent_gitignore, "target/\n").unwrap();
let git_dir = tmp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let project = tmp.path().join("my-project");
std::fs::create_dir_all(&project).unwrap();
ensure_gitignored(&project).unwrap();
let content = std::fs::read_to_string(&parent_gitignore).unwrap();
assert!(
content.contains(GITIGNORE_LINE),
"parent .gitignore must receive the entry; content={content:?}"
);
assert!(
!project.join(".gitignore").exists(),
"no .gitignore should be created inside the project subdir"
);
}
#[test]
fn has_colocated_storage_reflects_dir_presence() {
let tmp = tempdir().unwrap();
let root = tmp.path();
assert!(
!has_colocated_storage(root),
"should be false before create"
);
colocated_storage_dir(root).unwrap();
assert!(has_colocated_storage(root), "should be true after create");
}
}