Skip to main content

tsift_quality/
lint.rs

1use anyhow::{Context, Result};
2use serde::Serialize;
3use std::collections::{BTreeSet, HashSet};
4use std::path::{Path, PathBuf};
5use tsift_index::{config, index::IndexDb};
6
7#[derive(Debug, Clone, Serialize)]
8pub struct LintResult {
9    pub file: String,
10    pub annotations: Vec<Annotation>,
11}
12
13#[derive(Debug, Clone, Serialize)]
14pub struct Annotation {
15    pub line: usize,
16    pub column: usize,
17    pub text: String,
18    pub entity: String,
19    pub kind: AnnotationKind,
20    pub suggestion: String,
21}
22
23#[derive(Debug, Clone, Serialize)]
24#[serde(rename_all = "lowercase")]
25pub enum AnnotationKind {
26    Symbol,
27    Heading,
28    Bold,
29}
30
31pub fn lint_markdown(path: &Path, entities: &HashSet<String>) -> Result<LintResult> {
32    let content =
33        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
34
35    let mut annotations = Vec::new();
36    let mut in_code_block = false;
37
38    for (line_idx, line) in content.lines().enumerate() {
39        let trimmed = line.trim();
40        if trimmed.starts_with("```") {
41            in_code_block = !in_code_block;
42            continue;
43        }
44        if in_code_block {
45            continue;
46        }
47        if trimmed.starts_with('#') || trimmed.starts_with("<!--") {
48            continue;
49        }
50
51        find_unannotated(line, line_idx + 1, entities, &mut annotations);
52    }
53
54    Ok(LintResult {
55        file: path.display().to_string(),
56        annotations,
57    })
58}
59
60fn find_unannotated(
61    line: &str,
62    line_num: usize,
63    entities: &HashSet<String>,
64    annotations: &mut Vec<Annotation>,
65) {
66    for entity in entities {
67        let mut search_from = 0;
68        while let Some(pos) = line[search_from..].find(entity.as_str()) {
69            let abs_pos = search_from + pos;
70
71            if is_already_annotated(line, abs_pos, entity.len()) {
72                search_from = abs_pos + entity.len();
73                continue;
74            }
75
76            if !is_word_boundary(line, abs_pos, entity.len()) {
77                search_from = abs_pos + entity.len();
78                continue;
79            }
80
81            let kind = guess_annotation_kind(entity);
82            let suggestion = match kind {
83                AnnotationKind::Symbol => format!("`{}`", entity),
84                AnnotationKind::Bold => format!("**{}**", entity),
85                AnnotationKind::Heading => {
86                    format!("[{}](#{})", entity, entity.to_lowercase().replace(' ', "-"))
87                }
88            };
89
90            annotations.push(Annotation {
91                line: line_num,
92                column: abs_pos + 1,
93                text: entity.clone(),
94                entity: entity.clone(),
95                kind,
96                suggestion,
97            });
98
99            search_from = abs_pos + entity.len();
100        }
101    }
102}
103
104fn is_already_annotated(line: &str, pos: usize, len: usize) -> bool {
105    let before = if pos > 0 { &line[..pos] } else { "" };
106    let after_end = pos + len;
107    let after = if after_end < line.len() {
108        &line[after_end..]
109    } else {
110        ""
111    };
112
113    // backtick-wrapped
114    if before.ends_with('`') && after.starts_with('`') {
115        return true;
116    }
117    // bold-wrapped
118    if before.ends_with("**") && after.starts_with("**") {
119        return true;
120    }
121    // link text
122    if before.ends_with('[') && after.starts_with("](") {
123        return true;
124    }
125    // inside inline code span
126    let backtick_count_before = before.chars().filter(|&c| c == '`').count();
127    if backtick_count_before % 2 == 1 {
128        return true;
129    }
130
131    false
132}
133
134fn is_word_boundary(line: &str, pos: usize, len: usize) -> bool {
135    let before_ok = pos == 0
136        || line
137            .as_bytes()
138            .get(pos - 1)
139            .is_none_or(|&b| !b.is_ascii_alphanumeric() && b != b'_');
140    let after_end = pos + len;
141    let after_ok = after_end >= line.len()
142        || line
143            .as_bytes()
144            .get(after_end)
145            .is_none_or(|&b| !b.is_ascii_alphanumeric() && b != b'_');
146    before_ok && after_ok
147}
148
149fn guess_annotation_kind(entity: &str) -> AnnotationKind {
150    if entity.contains('_')
151        || entity.contains("::")
152        || entity.chars().all(|c| c.is_ascii_lowercase() || c == '_')
153    {
154        AnnotationKind::Symbol
155    } else if entity.chars().next().is_some_and(|c| c.is_uppercase()) && entity.contains(' ') {
156        AnnotationKind::Heading
157    } else {
158        AnnotationKind::Bold
159    }
160}
161
162fn project_root_from_canonical_path(canonical: &Path) -> Option<PathBuf> {
163    let start = canonical_path_start_dir(canonical);
164    let temp_root = std::env::temp_dir().canonicalize().ok();
165
166    for ancestor in start.ancestors() {
167        let ambient_temp_root = temp_root.as_deref() == Some(ancestor) && ancestor != start;
168        if (ancestor.join(".tsift").is_dir() || ancestor.join(".gitmodules").is_file())
169            && !ambient_temp_root
170        {
171            return Some(ancestor.to_path_buf());
172        }
173        if ancestor.join(".git").exists() && !ambient_temp_root {
174            return Some(ancestor.to_path_buf());
175        }
176    }
177
178    None
179}
180
181fn harness_root_from_canonical_path(canonical: &Path) -> Option<PathBuf> {
182    let start = canonical_path_start_dir(canonical);
183    let mut workspace_root = None;
184
185    for ancestor in start.ancestors() {
186        if ancestor.join(".tsift").is_dir() || ancestor.join(".git").exists() {
187            return Some(ancestor.to_path_buf());
188        }
189        if workspace_root.is_none() && ancestor.join(".gitmodules").is_file() {
190            workspace_root = Some(ancestor.to_path_buf());
191        }
192    }
193
194    workspace_root
195}
196
197fn canonical_path_start_dir(canonical: &Path) -> PathBuf {
198    if canonical.is_dir() {
199        canonical.to_path_buf()
200    } else {
201        canonical
202            .parent()
203            .map(Path::to_path_buf)
204            .unwrap_or_else(|| canonical.to_path_buf())
205    }
206}
207
208pub fn find_project_root_for_path(path: &Path) -> Result<Option<PathBuf>> {
209    let canonical = path
210        .canonicalize()
211        .with_context(|| format!("canonicalizing {}", path.display()))?;
212    Ok(project_root_from_canonical_path(&canonical))
213}
214
215pub fn resolve_project_root_or_canonical_path(path: &Path) -> Result<PathBuf> {
216    let canonical = path
217        .canonicalize()
218        .with_context(|| format!("canonicalizing {}", path.display()))?;
219    Ok(project_root_from_canonical_path(&canonical).unwrap_or(canonical))
220}
221
222pub fn resolve_harness_root_or_canonical_path(path: &Path) -> Result<PathBuf> {
223    let canonical = path
224        .canonicalize()
225        .with_context(|| format!("canonicalizing {}", path.display()))?;
226    Ok(harness_root_from_canonical_path(&canonical)
227        .unwrap_or_else(|| canonical_path_start_dir(&canonical)))
228}
229
230pub fn collect_entities_from_db(db_path: &Path) -> Result<HashSet<String>> {
231    Ok(IndexDb::symbol_names_read_only_min_len(db_path, 4)?
232        .into_iter()
233        .collect())
234}
235
236pub fn collect_entities_from_index_path(index_path: &Path) -> Result<HashSet<String>> {
237    if let Some(root) = workspace_root_for_aggregate_index_path(index_path)? {
238        return collect_entities_from_workspace_root(&root);
239    }
240
241    let mut entities = HashSet::new();
242
243    for db_path in discover_index_dbs(index_path)? {
244        entities.extend(collect_entities_from_db(&db_path)?);
245    }
246
247    Ok(entities)
248}
249
250pub fn collect_entities_from_workspace_root(root: &Path) -> Result<HashSet<String>> {
251    let mut entities = HashSet::new();
252
253    push_entities_if_exists(&mut entities, &root.join(".tsift/index.db"))?;
254
255    let cfg = config::Config::load(root)?;
256    for scope in config::Config::submodule_dirs(root)? {
257        if !cfg.federation_for_scope(&scope) {
258            continue;
259        }
260        push_entities_if_exists(&mut entities, &cfg.db_path_for(root, &scope.id))?;
261    }
262
263    Ok(entities)
264}
265
266fn workspace_root_for_aggregate_index_path(index_path: &Path) -> Result<Option<PathBuf>> {
267    if !index_path.exists() {
268        return Ok(None);
269    }
270
271    let canonical = index_path
272        .canonicalize()
273        .with_context(|| format!("canonicalizing {}", index_path.display()))?;
274    let Some(root) = project_root_from_canonical_path(&canonical) else {
275        return Ok(None);
276    };
277    if config::Config::submodule_dirs(&root)?.is_empty() {
278        return Ok(None);
279    }
280
281    let is_workspace_aggregate_target = canonical == root
282        || canonical == root.join(".tsift")
283        || canonical == root.join(".tsift/index.db")
284        || canonical == root.join(".tsift/indexes");
285
286    Ok(is_workspace_aggregate_target.then_some(root))
287}
288
289fn discover_index_dbs(index_path: &Path) -> Result<Vec<PathBuf>> {
290    let mut dbs = BTreeSet::new();
291
292    if index_path.is_file()
293        && index_path.file_name().and_then(|name| name.to_str()) == Some("index.db")
294    {
295        dbs.insert(index_path.to_path_buf());
296    }
297
298    if index_path.is_dir()
299        && index_path.file_name().and_then(|name| name.to_str()) == Some("indexes")
300    {
301        collect_child_index_dbs(&mut dbs, index_path)?;
302    }
303
304    push_if_exists(&mut dbs, &index_path.join("index.db"));
305    push_if_exists(&mut dbs, &index_path.join(".tsift/index.db"));
306    collect_child_index_dbs(&mut dbs, &index_path.join("indexes"))?;
307    collect_child_index_dbs(&mut dbs, &index_path.join(".tsift/indexes"))?;
308
309    Ok(dbs.into_iter().collect())
310}
311
312fn push_if_exists(dbs: &mut BTreeSet<PathBuf>, db_path: &Path) {
313    if db_path.is_file() {
314        dbs.insert(db_path.to_path_buf());
315    }
316}
317
318fn push_entities_if_exists(entities: &mut HashSet<String>, db_path: &Path) -> Result<()> {
319    if db_path.is_file() {
320        entities.extend(collect_entities_from_db(db_path)?);
321    }
322    Ok(())
323}
324
325fn collect_child_index_dbs(dbs: &mut BTreeSet<PathBuf>, indexes_dir: &Path) -> Result<()> {
326    if !indexes_dir.is_dir() {
327        return Ok(());
328    }
329
330    for entry in std::fs::read_dir(indexes_dir)? {
331        let entry = entry?;
332        let path = entry.path();
333        if path.is_dir() {
334            push_if_exists(dbs, &path.join("index.db"));
335            collect_child_index_dbs(dbs, &path)?;
336        }
337    }
338
339    Ok(())
340}
341
342pub fn collect_entities_from_markdown(path: &Path) -> Result<HashSet<String>> {
343    let content =
344        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
345
346    let mut entities = HashSet::new();
347
348    for line in content.lines() {
349        let trimmed = line.trim();
350        // headings
351        if let Some(heading) = trimmed.strip_prefix('#') {
352            let heading = heading.trim_start_matches('#').trim();
353            if heading.len() >= 4 {
354                entities.insert(heading.to_string());
355            }
356        }
357        // bold terms
358        let mut in_bold = false;
359        for part in trimmed.split("**") {
360            if in_bold && part.len() >= 4 {
361                entities.insert(part.to_string());
362            }
363            in_bold = !in_bold;
364        }
365        // backtick terms
366        let mut in_code = false;
367        for part in trimmed.split('`') {
368            if in_code && part.len() >= 4 {
369                entities.insert(part.to_string());
370            }
371            in_code = !in_code;
372        }
373    }
374
375    Ok(entities)
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use std::fs;
382
383    fn create_symbol_index(db_path: &Path, names: &[&str]) {
384        if let Some(parent) = db_path.parent() {
385            fs::create_dir_all(parent).unwrap();
386        }
387
388        let conn = rusqlite::Connection::open(db_path).unwrap();
389        conn.execute_batch("CREATE TABLE symbols (name TEXT NOT NULL);")
390            .unwrap();
391        for name in names {
392            conn.execute("INSERT INTO symbols (name) VALUES (?1)", [name])
393                .unwrap();
394        }
395    }
396
397    #[test]
398    fn find_unannotated_plain_text() {
399        let entities: HashSet<String> = ["scan_skills".to_string()].into();
400        let mut annotations = Vec::new();
401        find_unannotated(
402            "The scan_skills function works.",
403            1,
404            &entities,
405            &mut annotations,
406        );
407        assert_eq!(annotations.len(), 1);
408        assert_eq!(annotations[0].text, "scan_skills");
409        assert_eq!(annotations[0].column, 5);
410        assert_eq!(annotations[0].suggestion, "`scan_skills`");
411    }
412
413    #[test]
414    fn skip_already_backtick_wrapped() {
415        let entities: HashSet<String> = ["scan_skills".to_string()].into();
416        let mut annotations = Vec::new();
417        find_unannotated(
418            "The `scan_skills` function works.",
419            1,
420            &entities,
421            &mut annotations,
422        );
423        assert!(annotations.is_empty());
424    }
425
426    #[test]
427    fn skip_already_bold_wrapped() {
428        let entities: HashSet<String> = ["AuditResult".to_string()].into();
429        let mut annotations = Vec::new();
430        find_unannotated(
431            "The **AuditResult** struct.",
432            1,
433            &entities,
434            &mut annotations,
435        );
436        assert!(annotations.is_empty());
437    }
438
439    #[test]
440    fn skip_link_text() {
441        let entities: HashSet<String> = ["SPEC".to_string()].into();
442        let mut annotations = Vec::new();
443        find_unannotated(
444            "See [SPEC](spec.md) for details.",
445            1,
446            &entities,
447            &mut annotations,
448        );
449        assert!(annotations.is_empty());
450    }
451
452    #[test]
453    fn word_boundary_prevents_partial_match() {
454        let entities: HashSet<String> = ["scan".to_string()].into();
455        let mut annotations = Vec::new();
456        find_unannotated("The scanning process.", 1, &entities, &mut annotations);
457        assert!(annotations.is_empty());
458    }
459
460    #[test]
461    fn multiple_occurrences_on_same_line() {
462        let entities: HashSet<String> = ["test".to_string()].into();
463        let mut annotations = Vec::new();
464        find_unannotated(
465            "Run test then check test output.",
466            1,
467            &entities,
468            &mut annotations,
469        );
470        assert_eq!(annotations.len(), 2);
471    }
472
473    #[test]
474    fn lint_skips_code_blocks() {
475        let dir = tempfile::tempdir().unwrap();
476        let file = dir.path().join("test.md");
477        fs::write(
478            &file,
479            "# Doc\n\nscan_skills works.\n\n```\nscan_skills in code block\n```\n",
480        )
481        .unwrap();
482        let entities: HashSet<String> = ["scan_skills".to_string()].into();
483        let result = lint_markdown(&file, &entities).unwrap();
484        assert_eq!(result.annotations.len(), 1);
485        assert_eq!(result.annotations[0].line, 3);
486    }
487
488    #[test]
489    fn lint_skips_headings_and_comments() {
490        let dir = tempfile::tempdir().unwrap();
491        let file = dir.path().join("test.md");
492        fs::write(
493            &file,
494            "# scan_skills\n\n<!-- scan_skills -->\n\nPlain scan_skills here.\n",
495        )
496        .unwrap();
497        let entities: HashSet<String> = ["scan_skills".to_string()].into();
498        let result = lint_markdown(&file, &entities).unwrap();
499        assert_eq!(result.annotations.len(), 1);
500        assert_eq!(result.annotations[0].line, 5);
501    }
502
503    #[test]
504    fn collect_entities_from_markdown_extracts_headings_bold_code() {
505        let dir = tempfile::tempdir().unwrap();
506        let file = dir.path().join("test.md");
507        fs::write(
508            &file,
509            "# Architecture\n\nThe **AuditResult** struct uses `scan_skills` internally.\n",
510        )
511        .unwrap();
512        let entities = collect_entities_from_markdown(&file).unwrap();
513        assert!(entities.contains("Architecture"));
514        assert!(entities.contains("AuditResult"));
515        assert!(entities.contains("scan_skills"));
516    }
517
518    #[test]
519    fn guess_annotation_kind_symbols() {
520        assert!(matches!(
521            guess_annotation_kind("scan_skills"),
522            AnnotationKind::Symbol
523        ));
524        assert!(matches!(
525            guess_annotation_kind("std::path"),
526            AnnotationKind::Symbol
527        ));
528    }
529
530    #[test]
531    fn guess_annotation_kind_headings() {
532        assert!(matches!(
533            guess_annotation_kind("Audit Result"),
534            AnnotationKind::Heading
535        ));
536    }
537
538    #[test]
539    fn guess_annotation_kind_bold() {
540        assert!(matches!(
541            guess_annotation_kind("AuditResult"),
542            AnnotationKind::Bold
543        ));
544    }
545
546    #[test]
547    fn find_project_root_uses_markdown_ancestors() {
548        let dir = tempfile::tempdir().unwrap();
549        fs::create_dir_all(dir.path().join(".tsift")).unwrap();
550        let file = dir.path().join("docs/README.md");
551        fs::create_dir_all(file.parent().unwrap()).unwrap();
552        fs::write(&file, "alpha_helper should be annotated.\n").unwrap();
553
554        let root = find_project_root_for_path(&file).unwrap();
555
556        assert_eq!(root.unwrap(), dir.path());
557    }
558
559    #[test]
560    fn resolve_project_root_or_canonical_path_promotes_nested_subdir_to_tsift_root() {
561        let dir = tempfile::tempdir().unwrap();
562        fs::create_dir_all(dir.path().join(".tsift")).unwrap();
563        let nested = dir.path().join("src/nested");
564        fs::create_dir_all(&nested).unwrap();
565
566        let root = resolve_project_root_or_canonical_path(&nested).unwrap();
567
568        assert_eq!(root, dir.path());
569    }
570
571    #[test]
572    fn resolve_project_root_or_canonical_path_promotes_nested_workspace_subdir_to_gitmodules_root()
573    {
574        let dir = tempfile::tempdir().unwrap();
575        fs::write(
576            dir.path().join(".gitmodules"),
577            r#"[submodule "src/alpha"]
578	path = src/alpha
579	url = https://example.com/alpha
580"#,
581        )
582        .unwrap();
583        let nested = dir.path().join("docs/nested");
584        fs::create_dir_all(&nested).unwrap();
585
586        let root = resolve_project_root_or_canonical_path(&nested).unwrap();
587
588        assert_eq!(root, dir.path());
589    }
590
591    #[test]
592    fn resolve_project_root_or_canonical_path_stops_at_nested_git_root_before_parent_tsift() {
593        let dir = tempfile::tempdir().unwrap();
594        fs::create_dir_all(dir.path().join(".tsift")).unwrap();
595        let repo = dir.path().join("repo");
596        fs::create_dir_all(repo.join(".git")).unwrap();
597        let nested = repo.join("src/nested");
598        fs::create_dir_all(&nested).unwrap();
599
600        let root = resolve_project_root_or_canonical_path(&nested).unwrap();
601
602        assert_eq!(root, repo);
603    }
604
605    #[test]
606    fn resolve_harness_root_or_canonical_path_prefers_submodule_git_root() {
607        let dir = tempfile::tempdir().unwrap();
608        fs::write(
609            dir.path().join(".gitmodules"),
610            r#"[submodule "src/alpha"]
611	path = src/alpha
612	url = https://example.com/alpha
613"#,
614        )
615        .unwrap();
616        let submodule = dir.path().join("src/alpha");
617        fs::create_dir_all(submodule.join("nested")).unwrap();
618        fs::write(
619            submodule.join(".git"),
620            "gitdir: ../../.git/modules/src/alpha\n",
621        )
622        .unwrap();
623
624        let root = resolve_harness_root_or_canonical_path(&submodule.join("nested")).unwrap();
625
626        assert_eq!(root, submodule);
627    }
628
629    #[test]
630    fn resolve_harness_root_or_canonical_path_falls_back_to_parent_dir_for_files() {
631        let dir = tempfile::tempdir().unwrap();
632        let file = dir.path().join("session.jsonl");
633        fs::write(&file, "{\"message\":\"hi\"}\n").unwrap();
634
635        let root = resolve_harness_root_or_canonical_path(&file).unwrap();
636
637        assert!(file.starts_with(&root));
638        assert!(root.is_dir());
639    }
640
641    #[test]
642    fn collect_entities_from_project_root_index_db() {
643        let dir = tempfile::tempdir().unwrap();
644        create_symbol_index(&dir.path().join(".tsift/index.db"), &["alpha_helper"]);
645
646        let entities = collect_entities_from_index_path(dir.path()).unwrap();
647
648        assert!(entities.contains("alpha_helper"));
649    }
650
651    #[test]
652    fn collect_entities_from_scoped_index_dbs() {
653        let dir = tempfile::tempdir().unwrap();
654        create_symbol_index(
655            &dir.path().join(".tsift/indexes/alpha/index.db"),
656            &["alpha_helper"],
657        );
658        create_symbol_index(
659            &dir.path().join(".tsift/indexes/beta/index.db"),
660            &["beta_helper"],
661        );
662
663        let entities = collect_entities_from_index_path(dir.path()).unwrap();
664
665        assert!(entities.contains("alpha_helper"));
666        assert!(entities.contains("beta_helper"));
667    }
668
669    #[test]
670    fn collect_entities_from_explicit_indexes_dir() {
671        let dir = tempfile::tempdir().unwrap();
672        create_symbol_index(
673            &dir.path().join(".tsift/indexes/alpha/index.db"),
674            &["alpha_helper"],
675        );
676        create_symbol_index(
677            &dir.path().join(".tsift/indexes/beta/index.db"),
678            &["beta_helper"],
679        );
680
681        let entities =
682            collect_entities_from_index_path(&dir.path().join(".tsift/indexes")).unwrap();
683
684        assert!(entities.contains("alpha_helper"));
685        assert!(entities.contains("beta_helper"));
686    }
687
688    #[test]
689    fn collect_entities_from_explicit_indexes_dir_recurses_nested_scope_ids() {
690        let dir = tempfile::tempdir().unwrap();
691        create_symbol_index(
692            &dir.path().join(".tsift/indexes/pkg/app/foo/index.db"),
693            &["pkg_helper"],
694        );
695        create_symbol_index(
696            &dir.path().join(".tsift/indexes/vendor/foo/index.db"),
697            &["vendor_helper"],
698        );
699
700        let entities =
701            collect_entities_from_index_path(&dir.path().join(".tsift/indexes")).unwrap();
702
703        assert!(entities.contains("pkg_helper"));
704        assert!(entities.contains("vendor_helper"));
705    }
706
707    #[test]
708    fn collect_entities_from_workspace_index_targets_skip_non_federated_scopes() {
709        let dir = tempfile::tempdir().unwrap();
710        let root = dir.path();
711        fs::create_dir_all(root.join(".tsift/indexes/public")).unwrap();
712        fs::create_dir_all(root.join(".tsift/indexes/private")).unwrap();
713        fs::create_dir_all(root.join(".tsift/indexes/isolated")).unwrap();
714        fs::create_dir_all(root.join(".tsift/indexes/nonfed")).unwrap();
715        fs::write(
716            root.join(".gitmodules"),
717            r#"[submodule "src/public"]
718	path = src/public
719	url = https://example.com/public
720[submodule "src/private"]
721	path = src/private
722	url = https://example.com/private
723[submodule "src/isolated"]
724	path = src/isolated
725	url = https://example.com/isolated
726[submodule "src/nonfed"]
727	path = src/nonfed
728	url = https://example.com/nonfed
729"#,
730        )
731        .unwrap();
732        fs::write(
733            root.join(".tsift/config.toml"),
734            r#"
735[overrides.private]
736tier = "private"
737
738[overrides.isolated]
739tier = "isolated"
740
741[overrides.nonfed]
742federation = false
743"#,
744        )
745        .unwrap();
746
747        create_symbol_index(&root.join(".tsift/index.db"), &["root_helper"]);
748        create_symbol_index(
749            &root.join(".tsift/indexes/public/index.db"),
750            &["public_helper"],
751        );
752        create_symbol_index(
753            &root.join(".tsift/indexes/private/index.db"),
754            &["private_helper"],
755        );
756        create_symbol_index(
757            &root.join(".tsift/indexes/isolated/index.db"),
758            &["isolated_helper"],
759        );
760        create_symbol_index(
761            &root.join(".tsift/indexes/nonfed/index.db"),
762            &["nonfed_helper"],
763        );
764
765        for target in [
766            root.to_path_buf(),
767            root.join(".tsift"),
768            root.join(".tsift/indexes"),
769        ] {
770            let entities = collect_entities_from_index_path(&target).unwrap();
771
772            assert!(entities.contains("root_helper"));
773            assert!(entities.contains("public_helper"));
774            assert!(!entities.contains("private_helper"));
775            assert!(!entities.contains("isolated_helper"));
776            assert!(!entities.contains("nonfed_helper"));
777        }
778    }
779
780    #[test]
781    fn collect_entities_from_workspace_root_ignores_repo_root_index_db() {
782        let dir = tempfile::tempdir().unwrap();
783        let root = dir.path();
784        fs::create_dir_all(root.join(".tsift")).unwrap();
785        fs::write(
786            root.join(".gitmodules"),
787            r#"[submodule "src/public"]
788	path = src/public
789	url = https://example.com/public
790"#,
791        )
792        .unwrap();
793        create_symbol_index(&root.join(".tsift/index.db"), &["root_helper"]);
794        let conn = rusqlite::Connection::open(root.join("index.db")).unwrap();
795        conn.execute_batch("CREATE TABLE unrelated (id INTEGER PRIMARY KEY);")
796            .unwrap();
797
798        let entities = collect_entities_from_index_path(root).unwrap();
799
800        assert!(entities.contains("root_helper"));
801    }
802
803    #[test]
804    fn collect_entities_from_explicit_private_scope_dir_keeps_private_entities() {
805        let dir = tempfile::tempdir().unwrap();
806        let root = dir.path();
807        fs::create_dir_all(root.join(".tsift/indexes/private")).unwrap();
808        fs::write(
809            root.join(".gitmodules"),
810            r#"[submodule "src/private"]
811	path = src/private
812	url = https://example.com/private
813"#,
814        )
815        .unwrap();
816        fs::write(
817            root.join(".tsift/config.toml"),
818            r#"
819[overrides.private]
820tier = "private"
821"#,
822        )
823        .unwrap();
824        create_symbol_index(
825            &root.join(".tsift/indexes/private/index.db"),
826            &["private_helper"],
827        );
828
829        let entities =
830            collect_entities_from_index_path(&root.join(".tsift/indexes/private")).unwrap();
831
832        assert!(entities.contains("private_helper"));
833    }
834
835    #[test]
836    fn collect_entities_from_workspace_root_skips_non_federated_scopes() {
837        let dir = tempfile::tempdir().unwrap();
838        let root = dir.path();
839        fs::create_dir_all(root.join(".tsift")).unwrap();
840        fs::write(
841            root.join(".gitmodules"),
842            r#"[submodule "src/public"]
843	path = src/public
844	url = https://example.com/public
845[submodule "src/private"]
846	path = src/private
847	url = https://example.com/private
848[submodule "src/isolated"]
849	path = src/isolated
850	url = https://example.com/isolated
851[submodule "src/nonfed"]
852	path = src/nonfed
853	url = https://example.com/nonfed
854"#,
855        )
856        .unwrap();
857        fs::write(
858            root.join(".tsift/config.toml"),
859            r#"
860[overrides.private]
861tier = "private"
862
863[overrides.isolated]
864tier = "isolated"
865
866[overrides.nonfed]
867federation = false
868"#,
869        )
870        .unwrap();
871
872        create_symbol_index(&root.join(".tsift/index.db"), &["root_helper"]);
873        create_symbol_index(
874            &root.join(".tsift/indexes/public/index.db"),
875            &["public_helper"],
876        );
877        create_symbol_index(
878            &root.join(".tsift/indexes/private/index.db"),
879            &["private_helper"],
880        );
881        create_symbol_index(
882            &root.join(".tsift/indexes/isolated/index.db"),
883            &["isolated_helper"],
884        );
885        create_symbol_index(
886            &root.join(".tsift/indexes/nonfed/index.db"),
887            &["nonfed_helper"],
888        );
889
890        let entities = collect_entities_from_workspace_root(root).unwrap();
891
892        assert!(entities.contains("root_helper"));
893        assert!(entities.contains("public_helper"));
894        assert!(!entities.contains("private_helper"));
895        assert!(!entities.contains("isolated_helper"));
896        assert!(!entities.contains("nonfed_helper"));
897    }
898
899    #[test]
900    fn collect_entities_uses_snapshot_fallback_when_rollback_journal_is_locked() {
901        let dir = tempfile::tempdir().unwrap();
902        let db_path = dir.path().join(".tsift/index.db");
903        create_symbol_index(&db_path, &["alpha_helper"]);
904
905        let conn = rusqlite::Connection::open(&db_path).unwrap();
906        conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
907            .unwrap();
908        fs::write(format!("{}-journal", db_path.display()), "locked").unwrap();
909
910        let entities = collect_entities_from_db(&db_path).unwrap();
911
912        assert!(entities.contains("alpha_helper"));
913    }
914}