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 if before.ends_with('`') && after.starts_with('`') {
115 return true;
116 }
117 if before.ends_with("**") && after.starts_with("**") {
119 return true;
120 }
121 if before.ends_with('[') && after.starts_with("](") {
123 return true;
124 }
125 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 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 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 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}