use anyhow::{Context, Result};
use serde::Serialize;
use std::collections::{BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use tsift_index::{config, index::IndexDb};
#[derive(Debug, Clone, Serialize)]
pub struct LintResult {
pub file: String,
pub annotations: Vec<Annotation>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Annotation {
pub line: usize,
pub column: usize,
pub text: String,
pub entity: String,
pub kind: AnnotationKind,
pub suggestion: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AnnotationKind {
Symbol,
Heading,
Bold,
}
pub fn lint_markdown(path: &Path, entities: &HashSet<String>) -> Result<LintResult> {
let content =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let mut annotations = Vec::new();
let mut in_code_block = false;
for (line_idx, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block {
continue;
}
if trimmed.starts_with('#') || trimmed.starts_with("<!--") {
continue;
}
find_unannotated(line, line_idx + 1, entities, &mut annotations);
}
Ok(LintResult {
file: path.display().to_string(),
annotations,
})
}
fn find_unannotated(
line: &str,
line_num: usize,
entities: &HashSet<String>,
annotations: &mut Vec<Annotation>,
) {
for entity in entities {
let mut search_from = 0;
while let Some(pos) = line[search_from..].find(entity.as_str()) {
let abs_pos = search_from + pos;
if is_already_annotated(line, abs_pos, entity.len()) {
search_from = abs_pos + entity.len();
continue;
}
if !is_word_boundary(line, abs_pos, entity.len()) {
search_from = abs_pos + entity.len();
continue;
}
let kind = guess_annotation_kind(entity);
let suggestion = match kind {
AnnotationKind::Symbol => format!("`{}`", entity),
AnnotationKind::Bold => format!("**{}**", entity),
AnnotationKind::Heading => {
format!("[{}](#{})", entity, entity.to_lowercase().replace(' ', "-"))
}
};
annotations.push(Annotation {
line: line_num,
column: abs_pos + 1,
text: entity.clone(),
entity: entity.clone(),
kind,
suggestion,
});
search_from = abs_pos + entity.len();
}
}
}
fn is_already_annotated(line: &str, pos: usize, len: usize) -> bool {
let before = if pos > 0 { &line[..pos] } else { "" };
let after_end = pos + len;
let after = if after_end < line.len() {
&line[after_end..]
} else {
""
};
if before.ends_with('`') && after.starts_with('`') {
return true;
}
if before.ends_with("**") && after.starts_with("**") {
return true;
}
if before.ends_with('[') && after.starts_with("](") {
return true;
}
let backtick_count_before = before.chars().filter(|&c| c == '`').count();
if backtick_count_before % 2 == 1 {
return true;
}
false
}
fn is_word_boundary(line: &str, pos: usize, len: usize) -> bool {
let before_ok = pos == 0
|| line
.as_bytes()
.get(pos - 1)
.is_none_or(|&b| !b.is_ascii_alphanumeric() && b != b'_');
let after_end = pos + len;
let after_ok = after_end >= line.len()
|| line
.as_bytes()
.get(after_end)
.is_none_or(|&b| !b.is_ascii_alphanumeric() && b != b'_');
before_ok && after_ok
}
fn guess_annotation_kind(entity: &str) -> AnnotationKind {
if entity.contains('_')
|| entity.contains("::")
|| entity.chars().all(|c| c.is_ascii_lowercase() || c == '_')
{
AnnotationKind::Symbol
} else if entity.chars().next().is_some_and(|c| c.is_uppercase()) && entity.contains(' ') {
AnnotationKind::Heading
} else {
AnnotationKind::Bold
}
}
fn project_root_from_canonical_path(canonical: &Path) -> Option<PathBuf> {
let start = canonical_path_start_dir(canonical);
let temp_root = std::env::temp_dir().canonicalize().ok();
for ancestor in start.ancestors() {
let ambient_temp_root = temp_root.as_deref() == Some(ancestor) && ancestor != start;
if (ancestor.join(".tsift").is_dir() || ancestor.join(".gitmodules").is_file())
&& !ambient_temp_root
{
return Some(ancestor.to_path_buf());
}
if ancestor.join(".git").exists() && !ambient_temp_root {
return Some(ancestor.to_path_buf());
}
}
None
}
fn harness_root_from_canonical_path(canonical: &Path) -> Option<PathBuf> {
let start = canonical_path_start_dir(canonical);
let mut workspace_root = None;
for ancestor in start.ancestors() {
if ancestor.join(".tsift").is_dir() || ancestor.join(".git").exists() {
return Some(ancestor.to_path_buf());
}
if workspace_root.is_none() && ancestor.join(".gitmodules").is_file() {
workspace_root = Some(ancestor.to_path_buf());
}
}
workspace_root
}
fn canonical_path_start_dir(canonical: &Path) -> PathBuf {
if canonical.is_dir() {
canonical.to_path_buf()
} else {
canonical
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| canonical.to_path_buf())
}
}
pub fn find_project_root_for_path(path: &Path) -> Result<Option<PathBuf>> {
let canonical = path
.canonicalize()
.with_context(|| format!("canonicalizing {}", path.display()))?;
Ok(project_root_from_canonical_path(&canonical))
}
pub fn resolve_project_root_or_canonical_path(path: &Path) -> Result<PathBuf> {
let canonical = path
.canonicalize()
.with_context(|| format!("canonicalizing {}", path.display()))?;
Ok(project_root_from_canonical_path(&canonical).unwrap_or(canonical))
}
pub fn resolve_harness_root_or_canonical_path(path: &Path) -> Result<PathBuf> {
let canonical = path
.canonicalize()
.with_context(|| format!("canonicalizing {}", path.display()))?;
Ok(harness_root_from_canonical_path(&canonical)
.unwrap_or_else(|| canonical_path_start_dir(&canonical)))
}
pub fn collect_entities_from_db(db_path: &Path) -> Result<HashSet<String>> {
Ok(IndexDb::symbol_names_read_only_min_len(db_path, 4)?
.into_iter()
.collect())
}
pub fn collect_entities_from_index_path(index_path: &Path) -> Result<HashSet<String>> {
if let Some(root) = workspace_root_for_aggregate_index_path(index_path)? {
return collect_entities_from_workspace_root(&root);
}
let mut entities = HashSet::new();
for db_path in discover_index_dbs(index_path)? {
entities.extend(collect_entities_from_db(&db_path)?);
}
Ok(entities)
}
pub fn collect_entities_from_workspace_root(root: &Path) -> Result<HashSet<String>> {
let mut entities = HashSet::new();
push_entities_if_exists(&mut entities, &root.join(".tsift/index.db"))?;
let cfg = config::Config::load(root)?;
for scope in config::Config::submodule_dirs(root)? {
if !cfg.federation_for_scope(&scope) {
continue;
}
push_entities_if_exists(&mut entities, &cfg.db_path_for(root, &scope.id))?;
}
Ok(entities)
}
fn workspace_root_for_aggregate_index_path(index_path: &Path) -> Result<Option<PathBuf>> {
if !index_path.exists() {
return Ok(None);
}
let canonical = index_path
.canonicalize()
.with_context(|| format!("canonicalizing {}", index_path.display()))?;
let Some(root) = project_root_from_canonical_path(&canonical) else {
return Ok(None);
};
if config::Config::submodule_dirs(&root)?.is_empty() {
return Ok(None);
}
let is_workspace_aggregate_target = canonical == root
|| canonical == root.join(".tsift")
|| canonical == root.join(".tsift/index.db")
|| canonical == root.join(".tsift/indexes");
Ok(is_workspace_aggregate_target.then_some(root))
}
fn discover_index_dbs(index_path: &Path) -> Result<Vec<PathBuf>> {
let mut dbs = BTreeSet::new();
if index_path.is_file()
&& index_path.file_name().and_then(|name| name.to_str()) == Some("index.db")
{
dbs.insert(index_path.to_path_buf());
}
if index_path.is_dir()
&& index_path.file_name().and_then(|name| name.to_str()) == Some("indexes")
{
collect_child_index_dbs(&mut dbs, index_path)?;
}
push_if_exists(&mut dbs, &index_path.join("index.db"));
push_if_exists(&mut dbs, &index_path.join(".tsift/index.db"));
collect_child_index_dbs(&mut dbs, &index_path.join("indexes"))?;
collect_child_index_dbs(&mut dbs, &index_path.join(".tsift/indexes"))?;
Ok(dbs.into_iter().collect())
}
fn push_if_exists(dbs: &mut BTreeSet<PathBuf>, db_path: &Path) {
if db_path.is_file() {
dbs.insert(db_path.to_path_buf());
}
}
fn push_entities_if_exists(entities: &mut HashSet<String>, db_path: &Path) -> Result<()> {
if db_path.is_file() {
entities.extend(collect_entities_from_db(db_path)?);
}
Ok(())
}
fn collect_child_index_dbs(dbs: &mut BTreeSet<PathBuf>, indexes_dir: &Path) -> Result<()> {
if !indexes_dir.is_dir() {
return Ok(());
}
for entry in std::fs::read_dir(indexes_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
push_if_exists(dbs, &path.join("index.db"));
collect_child_index_dbs(dbs, &path)?;
}
}
Ok(())
}
pub fn collect_entities_from_markdown(path: &Path) -> Result<HashSet<String>> {
let content =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let mut entities = HashSet::new();
for line in content.lines() {
let trimmed = line.trim();
if let Some(heading) = trimmed.strip_prefix('#') {
let heading = heading.trim_start_matches('#').trim();
if heading.len() >= 4 {
entities.insert(heading.to_string());
}
}
let mut in_bold = false;
for part in trimmed.split("**") {
if in_bold && part.len() >= 4 {
entities.insert(part.to_string());
}
in_bold = !in_bold;
}
let mut in_code = false;
for part in trimmed.split('`') {
if in_code && part.len() >= 4 {
entities.insert(part.to_string());
}
in_code = !in_code;
}
}
Ok(entities)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn create_symbol_index(db_path: &Path, names: &[&str]) {
if let Some(parent) = db_path.parent() {
fs::create_dir_all(parent).unwrap();
}
let conn = rusqlite::Connection::open(db_path).unwrap();
conn.execute_batch("CREATE TABLE symbols (name TEXT NOT NULL);")
.unwrap();
for name in names {
conn.execute("INSERT INTO symbols (name) VALUES (?1)", [name])
.unwrap();
}
}
#[test]
fn find_unannotated_plain_text() {
let entities: HashSet<String> = ["scan_skills".to_string()].into();
let mut annotations = Vec::new();
find_unannotated(
"The scan_skills function works.",
1,
&entities,
&mut annotations,
);
assert_eq!(annotations.len(), 1);
assert_eq!(annotations[0].text, "scan_skills");
assert_eq!(annotations[0].column, 5);
assert_eq!(annotations[0].suggestion, "`scan_skills`");
}
#[test]
fn skip_already_backtick_wrapped() {
let entities: HashSet<String> = ["scan_skills".to_string()].into();
let mut annotations = Vec::new();
find_unannotated(
"The `scan_skills` function works.",
1,
&entities,
&mut annotations,
);
assert!(annotations.is_empty());
}
#[test]
fn skip_already_bold_wrapped() {
let entities: HashSet<String> = ["AuditResult".to_string()].into();
let mut annotations = Vec::new();
find_unannotated(
"The **AuditResult** struct.",
1,
&entities,
&mut annotations,
);
assert!(annotations.is_empty());
}
#[test]
fn skip_link_text() {
let entities: HashSet<String> = ["SPEC".to_string()].into();
let mut annotations = Vec::new();
find_unannotated(
"See [SPEC](spec.md) for details.",
1,
&entities,
&mut annotations,
);
assert!(annotations.is_empty());
}
#[test]
fn word_boundary_prevents_partial_match() {
let entities: HashSet<String> = ["scan".to_string()].into();
let mut annotations = Vec::new();
find_unannotated("The scanning process.", 1, &entities, &mut annotations);
assert!(annotations.is_empty());
}
#[test]
fn multiple_occurrences_on_same_line() {
let entities: HashSet<String> = ["test".to_string()].into();
let mut annotations = Vec::new();
find_unannotated(
"Run test then check test output.",
1,
&entities,
&mut annotations,
);
assert_eq!(annotations.len(), 2);
}
#[test]
fn lint_skips_code_blocks() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.md");
fs::write(
&file,
"# Doc\n\nscan_skills works.\n\n```\nscan_skills in code block\n```\n",
)
.unwrap();
let entities: HashSet<String> = ["scan_skills".to_string()].into();
let result = lint_markdown(&file, &entities).unwrap();
assert_eq!(result.annotations.len(), 1);
assert_eq!(result.annotations[0].line, 3);
}
#[test]
fn lint_skips_headings_and_comments() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.md");
fs::write(
&file,
"# scan_skills\n\n<!-- scan_skills -->\n\nPlain scan_skills here.\n",
)
.unwrap();
let entities: HashSet<String> = ["scan_skills".to_string()].into();
let result = lint_markdown(&file, &entities).unwrap();
assert_eq!(result.annotations.len(), 1);
assert_eq!(result.annotations[0].line, 5);
}
#[test]
fn collect_entities_from_markdown_extracts_headings_bold_code() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.md");
fs::write(
&file,
"# Architecture\n\nThe **AuditResult** struct uses `scan_skills` internally.\n",
)
.unwrap();
let entities = collect_entities_from_markdown(&file).unwrap();
assert!(entities.contains("Architecture"));
assert!(entities.contains("AuditResult"));
assert!(entities.contains("scan_skills"));
}
#[test]
fn guess_annotation_kind_symbols() {
assert!(matches!(
guess_annotation_kind("scan_skills"),
AnnotationKind::Symbol
));
assert!(matches!(
guess_annotation_kind("std::path"),
AnnotationKind::Symbol
));
}
#[test]
fn guess_annotation_kind_headings() {
assert!(matches!(
guess_annotation_kind("Audit Result"),
AnnotationKind::Heading
));
}
#[test]
fn guess_annotation_kind_bold() {
assert!(matches!(
guess_annotation_kind("AuditResult"),
AnnotationKind::Bold
));
}
#[test]
fn find_project_root_uses_markdown_ancestors() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".tsift")).unwrap();
let file = dir.path().join("docs/README.md");
fs::create_dir_all(file.parent().unwrap()).unwrap();
fs::write(&file, "alpha_helper should be annotated.\n").unwrap();
let root = find_project_root_for_path(&file).unwrap();
assert_eq!(root.unwrap(), dir.path());
}
#[test]
fn resolve_project_root_or_canonical_path_promotes_nested_subdir_to_tsift_root() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".tsift")).unwrap();
let nested = dir.path().join("src/nested");
fs::create_dir_all(&nested).unwrap();
let root = resolve_project_root_or_canonical_path(&nested).unwrap();
assert_eq!(root, dir.path());
}
#[test]
fn resolve_project_root_or_canonical_path_promotes_nested_workspace_subdir_to_gitmodules_root()
{
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join(".gitmodules"),
r#"[submodule "src/alpha"]
path = src/alpha
url = https://example.com/alpha
"#,
)
.unwrap();
let nested = dir.path().join("docs/nested");
fs::create_dir_all(&nested).unwrap();
let root = resolve_project_root_or_canonical_path(&nested).unwrap();
assert_eq!(root, dir.path());
}
#[test]
fn resolve_project_root_or_canonical_path_stops_at_nested_git_root_before_parent_tsift() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".tsift")).unwrap();
let repo = dir.path().join("repo");
fs::create_dir_all(repo.join(".git")).unwrap();
let nested = repo.join("src/nested");
fs::create_dir_all(&nested).unwrap();
let root = resolve_project_root_or_canonical_path(&nested).unwrap();
assert_eq!(root, repo);
}
#[test]
fn resolve_harness_root_or_canonical_path_prefers_submodule_git_root() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join(".gitmodules"),
r#"[submodule "src/alpha"]
path = src/alpha
url = https://example.com/alpha
"#,
)
.unwrap();
let submodule = dir.path().join("src/alpha");
fs::create_dir_all(submodule.join("nested")).unwrap();
fs::write(
submodule.join(".git"),
"gitdir: ../../.git/modules/src/alpha\n",
)
.unwrap();
let root = resolve_harness_root_or_canonical_path(&submodule.join("nested")).unwrap();
assert_eq!(root, submodule);
}
#[test]
fn resolve_harness_root_or_canonical_path_falls_back_to_parent_dir_for_files() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("session.jsonl");
fs::write(&file, "{\"message\":\"hi\"}\n").unwrap();
let root = resolve_harness_root_or_canonical_path(&file).unwrap();
assert!(file.starts_with(&root));
assert!(root.is_dir());
}
#[test]
fn collect_entities_from_project_root_index_db() {
let dir = tempfile::tempdir().unwrap();
create_symbol_index(&dir.path().join(".tsift/index.db"), &["alpha_helper"]);
let entities = collect_entities_from_index_path(dir.path()).unwrap();
assert!(entities.contains("alpha_helper"));
}
#[test]
fn collect_entities_from_scoped_index_dbs() {
let dir = tempfile::tempdir().unwrap();
create_symbol_index(
&dir.path().join(".tsift/indexes/alpha/index.db"),
&["alpha_helper"],
);
create_symbol_index(
&dir.path().join(".tsift/indexes/beta/index.db"),
&["beta_helper"],
);
let entities = collect_entities_from_index_path(dir.path()).unwrap();
assert!(entities.contains("alpha_helper"));
assert!(entities.contains("beta_helper"));
}
#[test]
fn collect_entities_from_explicit_indexes_dir() {
let dir = tempfile::tempdir().unwrap();
create_symbol_index(
&dir.path().join(".tsift/indexes/alpha/index.db"),
&["alpha_helper"],
);
create_symbol_index(
&dir.path().join(".tsift/indexes/beta/index.db"),
&["beta_helper"],
);
let entities =
collect_entities_from_index_path(&dir.path().join(".tsift/indexes")).unwrap();
assert!(entities.contains("alpha_helper"));
assert!(entities.contains("beta_helper"));
}
#[test]
fn collect_entities_from_explicit_indexes_dir_recurses_nested_scope_ids() {
let dir = tempfile::tempdir().unwrap();
create_symbol_index(
&dir.path().join(".tsift/indexes/pkg/app/foo/index.db"),
&["pkg_helper"],
);
create_symbol_index(
&dir.path().join(".tsift/indexes/vendor/foo/index.db"),
&["vendor_helper"],
);
let entities =
collect_entities_from_index_path(&dir.path().join(".tsift/indexes")).unwrap();
assert!(entities.contains("pkg_helper"));
assert!(entities.contains("vendor_helper"));
}
#[test]
fn collect_entities_from_workspace_index_targets_skip_non_federated_scopes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".tsift/indexes/public")).unwrap();
fs::create_dir_all(root.join(".tsift/indexes/private")).unwrap();
fs::create_dir_all(root.join(".tsift/indexes/isolated")).unwrap();
fs::create_dir_all(root.join(".tsift/indexes/nonfed")).unwrap();
fs::write(
root.join(".gitmodules"),
r#"[submodule "src/public"]
path = src/public
url = https://example.com/public
[submodule "src/private"]
path = src/private
url = https://example.com/private
[submodule "src/isolated"]
path = src/isolated
url = https://example.com/isolated
[submodule "src/nonfed"]
path = src/nonfed
url = https://example.com/nonfed
"#,
)
.unwrap();
fs::write(
root.join(".tsift/config.toml"),
r#"
[overrides.private]
tier = "private"
[overrides.isolated]
tier = "isolated"
[overrides.nonfed]
federation = false
"#,
)
.unwrap();
create_symbol_index(&root.join(".tsift/index.db"), &["root_helper"]);
create_symbol_index(
&root.join(".tsift/indexes/public/index.db"),
&["public_helper"],
);
create_symbol_index(
&root.join(".tsift/indexes/private/index.db"),
&["private_helper"],
);
create_symbol_index(
&root.join(".tsift/indexes/isolated/index.db"),
&["isolated_helper"],
);
create_symbol_index(
&root.join(".tsift/indexes/nonfed/index.db"),
&["nonfed_helper"],
);
for target in [
root.to_path_buf(),
root.join(".tsift"),
root.join(".tsift/indexes"),
] {
let entities = collect_entities_from_index_path(&target).unwrap();
assert!(entities.contains("root_helper"));
assert!(entities.contains("public_helper"));
assert!(!entities.contains("private_helper"));
assert!(!entities.contains("isolated_helper"));
assert!(!entities.contains("nonfed_helper"));
}
}
#[test]
fn collect_entities_from_workspace_root_ignores_repo_root_index_db() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".tsift")).unwrap();
fs::write(
root.join(".gitmodules"),
r#"[submodule "src/public"]
path = src/public
url = https://example.com/public
"#,
)
.unwrap();
create_symbol_index(&root.join(".tsift/index.db"), &["root_helper"]);
let conn = rusqlite::Connection::open(root.join("index.db")).unwrap();
conn.execute_batch("CREATE TABLE unrelated (id INTEGER PRIMARY KEY);")
.unwrap();
let entities = collect_entities_from_index_path(root).unwrap();
assert!(entities.contains("root_helper"));
}
#[test]
fn collect_entities_from_explicit_private_scope_dir_keeps_private_entities() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".tsift/indexes/private")).unwrap();
fs::write(
root.join(".gitmodules"),
r#"[submodule "src/private"]
path = src/private
url = https://example.com/private
"#,
)
.unwrap();
fs::write(
root.join(".tsift/config.toml"),
r#"
[overrides.private]
tier = "private"
"#,
)
.unwrap();
create_symbol_index(
&root.join(".tsift/indexes/private/index.db"),
&["private_helper"],
);
let entities =
collect_entities_from_index_path(&root.join(".tsift/indexes/private")).unwrap();
assert!(entities.contains("private_helper"));
}
#[test]
fn collect_entities_from_workspace_root_skips_non_federated_scopes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".tsift")).unwrap();
fs::write(
root.join(".gitmodules"),
r#"[submodule "src/public"]
path = src/public
url = https://example.com/public
[submodule "src/private"]
path = src/private
url = https://example.com/private
[submodule "src/isolated"]
path = src/isolated
url = https://example.com/isolated
[submodule "src/nonfed"]
path = src/nonfed
url = https://example.com/nonfed
"#,
)
.unwrap();
fs::write(
root.join(".tsift/config.toml"),
r#"
[overrides.private]
tier = "private"
[overrides.isolated]
tier = "isolated"
[overrides.nonfed]
federation = false
"#,
)
.unwrap();
create_symbol_index(&root.join(".tsift/index.db"), &["root_helper"]);
create_symbol_index(
&root.join(".tsift/indexes/public/index.db"),
&["public_helper"],
);
create_symbol_index(
&root.join(".tsift/indexes/private/index.db"),
&["private_helper"],
);
create_symbol_index(
&root.join(".tsift/indexes/isolated/index.db"),
&["isolated_helper"],
);
create_symbol_index(
&root.join(".tsift/indexes/nonfed/index.db"),
&["nonfed_helper"],
);
let entities = collect_entities_from_workspace_root(root).unwrap();
assert!(entities.contains("root_helper"));
assert!(entities.contains("public_helper"));
assert!(!entities.contains("private_helper"));
assert!(!entities.contains("isolated_helper"));
assert!(!entities.contains("nonfed_helper"));
}
#[test]
fn collect_entities_uses_snapshot_fallback_when_rollback_journal_is_locked() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join(".tsift/index.db");
create_symbol_index(&db_path, &["alpha_helper"]);
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
.unwrap();
fs::write(format!("{}-journal", db_path.display()), "locked").unwrap();
let entities = collect_entities_from_db(&db_path).unwrap();
assert!(entities.contains("alpha_helper"));
}
}