1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use tokio::sync::{RwLock, mpsc};
13use tower_lsp::Client;
14use tower_lsp::lsp_types::*;
15
16use crate::config::{Config, MarkdownFlavor};
17use crate::discovery::{ExcludeMatchers, MarkdownWalkOptions, is_markdown_extension, path_relative_to};
18use crate::lint_context::LintContext;
19use crate::lsp::types::{IndexState, IndexUpdate};
20use crate::utils::anchor_styles::AnchorStyle;
21use crate::workspace_index::{FileIndex, HeadingIndex, WorkspaceIndex, extract_cross_file_links};
22
23pub(super) fn index_walk_options(config: &Config) -> MarkdownWalkOptions {
31 MarkdownWalkOptions {
32 respect_gitignore: config.global.respect_gitignore,
33 skip_vendor_dirs: true,
34 }
35}
36
37pub struct IndexWorker {
42 rx: mpsc::Receiver<IndexUpdate>,
44 workspace_index: Arc<RwLock<WorkspaceIndex>>,
46 index_state: Arc<RwLock<IndexState>>,
48 client: Client,
50 workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
52 pending: HashMap<PathBuf, (String, Instant)>,
54 debounce_duration: Duration,
56 relint_tx: mpsc::Sender<PathBuf>,
58 rumdl_config: Arc<RwLock<Config>>,
61}
62
63impl IndexWorker {
64 pub fn new(
66 rx: mpsc::Receiver<IndexUpdate>,
67 workspace_index: Arc<RwLock<WorkspaceIndex>>,
68 index_state: Arc<RwLock<IndexState>>,
69 client: Client,
70 workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
71 relint_tx: mpsc::Sender<PathBuf>,
72 rumdl_config: Arc<RwLock<Config>>,
73 ) -> Self {
74 Self {
75 rx,
76 workspace_index,
77 index_state,
78 client,
79 workspace_roots,
80 pending: HashMap::new(),
81 debounce_duration: Duration::from_millis(100),
82 relint_tx,
83 rumdl_config,
84 }
85 }
86
87 pub async fn run(mut self) {
89 let mut debounce_interval = tokio::time::interval(Duration::from_millis(50));
90
91 loop {
92 tokio::select! {
93 msg = self.rx.recv() => {
95 match msg {
96 Some(IndexUpdate::FileChanged { path, content }) => {
97 self.pending.insert(path, (content, Instant::now()));
98 }
99 Some(IndexUpdate::FileDeleted { path }) => {
100 self.handle_file_deleted(&path).await;
101 }
102 Some(IndexUpdate::FullRescan) => {
103 self.full_rescan().await;
104 }
105 Some(IndexUpdate::Shutdown) | None => {
106 log::info!("Index worker shutting down");
107 break;
108 }
109 }
110 }
111
112 _ = debounce_interval.tick() => {
114 self.process_pending_updates().await;
115 }
116 }
117 }
118 }
119
120 async fn process_pending_updates(&mut self) {
122 let now = Instant::now();
123 let ready: Vec<_> = self
124 .pending
125 .iter()
126 .filter(|(_, (_, time))| now.duration_since(*time) >= self.debounce_duration)
127 .map(|(path, _)| path.clone())
128 .collect();
129
130 for path in ready {
131 if let Some((content, _)) = self.pending.remove(&path) {
132 self.update_single_file(&path, &content).await;
133 }
134 }
135 }
136
137 async fn update_single_file(&self, path: &Path, content: &str) {
139 let flavor = self.rumdl_config.read().await.get_flavor_for_file(path);
141 let Ok(file_index) =
142 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Self::build_file_index(content, flavor)))
143 else {
144 log::error!("Panic while indexing {}: skipping", path.display());
145 return;
146 };
147
148 let old_dependents = {
150 let index = self.workspace_index.read().await;
151 index.get_dependents(path)
152 };
153
154 {
156 let mut index = self.workspace_index.write().await;
157 index.update_file(path, file_index);
158 }
159
160 let new_dependents = {
162 let index = self.workspace_index.read().await;
163 index.get_dependents(path)
164 };
165
166 let mut affected: std::collections::HashSet<PathBuf> = old_dependents.into_iter().collect();
168 affected.extend(new_dependents);
169
170 for dep_path in affected {
171 if self.relint_tx.send(dep_path.clone()).await.is_err() {
172 log::warn!("Failed to send re-lint request for {}", dep_path.display());
173 }
174 }
175 }
176
177 pub(super) fn build_file_index(content: &str, flavor: MarkdownFlavor) -> FileIndex {
181 let ctx = LintContext::new(content, flavor, None);
182 let mut file_index = FileIndex::new();
183
184 for (line_num, line_info) in ctx.lines.iter().enumerate() {
186 if let Some(heading) = &line_info.heading {
187 let auto_anchor = AnchorStyle::GitHub.generate_fragment(&heading.text);
188 let is_setext = matches!(
189 heading.style,
190 crate::lint_context::types::HeadingStyle::Setext1
191 | crate::lint_context::types::HeadingStyle::Setext2
192 );
193
194 file_index.add_heading(HeadingIndex {
195 text: heading.text.clone(),
196 auto_anchor,
197 custom_anchor: heading.custom_id.clone(),
198 line: line_num + 1, is_setext,
200 });
201 }
202 }
203
204 let links = extract_cross_file_links(&ctx);
207 for link in links.relative {
208 file_index.add_cross_file_link(link);
209 }
210 for link in links.root_relative {
211 file_index.add_root_relative_link(link);
212 }
213
214 file_index
215 }
216
217 async fn handle_file_deleted(&self, path: &Path) {
219 let dependents = {
224 let index = self.workspace_index.read().await;
225 index.get_dependents(path)
226 };
227
228 {
230 let mut index = self.workspace_index.write().await;
231 index.remove_file(path);
232 }
233
234 for dep_path in dependents {
236 if self.relint_tx.send(dep_path.clone()).await.is_err() {
237 log::warn!("Failed to send re-lint request for {}", dep_path.display());
238 }
239 }
240 }
241
242 async fn full_rescan(&mut self) {
244 self.pending.clear();
246
247 let roots = self.workspace_roots.read().await.clone();
249 let (options, excludes) = {
250 let config = self.rumdl_config.read().await;
251 (
252 index_walk_options(&config),
253 ExcludeMatchers::new(&config.global.exclude),
254 )
255 };
256 for (pattern, error) in &excludes.invalid {
257 log::warn!("Invalid exclude pattern '{pattern}': {error}");
258 }
259 let files = scan_markdown_files(&roots, options, excludes).await;
260 let total = files.len();
261
262 {
267 let current: std::collections::HashSet<PathBuf> = files.iter().cloned().collect();
268 let removed = self.workspace_index.write().await.retain_only(¤t);
269 if removed > 0 {
270 log::info!("Workspace rescan evicted {removed} stale index entries");
271 }
272 }
273
274 if total == 0 {
275 *self.index_state.write().await = IndexState::Ready;
276 return;
277 }
278
279 *self.index_state.write().await = IndexState::Building {
281 progress: 0.0,
282 files_indexed: 0,
283 total_files: total,
284 };
285
286 self.report_progress_begin(total).await;
288
289 for (i, path) in files.iter().enumerate() {
291 if let Ok(content) = tokio::fs::read_to_string(path).await {
292 let flavor = self.rumdl_config.read().await.get_flavor_for_file(path);
293 let file_index = Self::build_file_index(&content, flavor);
294
295 let mut index = self.workspace_index.write().await;
296 index.update_file(path, file_index);
297 }
298
299 if i % 10 == 0 || i == total - 1 {
301 let progress = ((i + 1) as f32 / total as f32) * 100.0;
302 *self.index_state.write().await = IndexState::Building {
303 progress,
304 files_indexed: i + 1,
305 total_files: total,
306 };
307 self.report_progress_update(i + 1, total).await;
308 }
309 }
310
311 *self.index_state.write().await = IndexState::Ready;
313 self.report_progress_done().await;
314
315 log::info!("Workspace indexing complete: {total} files indexed");
316 }
317
318 async fn report_progress_begin(&self, total: usize) {
320 let token = NumberOrString::String("rumdl-index".to_string());
321
322 if self
324 .client
325 .send_request::<request::WorkDoneProgressCreate>(WorkDoneProgressCreateParams { token: token.clone() })
326 .await
327 .is_err()
328 {
329 log::debug!("Client does not support work done progress");
330 return;
331 }
332
333 self.client
335 .send_notification::<notification::Progress>(ProgressParams {
336 token,
337 value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(WorkDoneProgressBegin {
338 title: "Indexing workspace".to_string(),
339 cancellable: Some(false),
340 message: Some(format!("Scanning {total} markdown files...")),
341 percentage: Some(0),
342 })),
343 })
344 .await;
345 }
346
347 async fn report_progress_update(&self, indexed: usize, total: usize) {
349 let token = NumberOrString::String("rumdl-index".to_string());
350 let percentage = ((indexed as f32 / total as f32) * 100.0) as u32;
351
352 self.client
353 .send_notification::<notification::Progress>(ProgressParams {
354 token,
355 value: ProgressParamsValue::WorkDone(WorkDoneProgress::Report(WorkDoneProgressReport {
356 cancellable: Some(false),
357 message: Some(format!("Indexed {indexed}/{total} files")),
358 percentage: Some(percentage),
359 })),
360 })
361 .await;
362 }
363
364 async fn report_progress_done(&self) {
366 let token = NumberOrString::String("rumdl-index".to_string());
367
368 self.client
369 .send_notification::<notification::Progress>(ProgressParams {
370 token,
371 value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd {
372 message: Some("Indexing complete".to_string()),
373 })),
374 })
375 .await;
376 }
377}
378
379async fn scan_markdown_files(
386 roots: &[PathBuf],
387 options: MarkdownWalkOptions,
388 excludes: ExcludeMatchers,
389) -> Vec<PathBuf> {
390 let roots = roots.to_vec();
391 tokio::task::spawn_blocking(move || collect_markdown_files(&roots, &options, &excludes))
392 .await
393 .unwrap_or_else(|e| {
394 log::warn!("Workspace scan task failed: {e}");
395 Vec::new()
396 })
397}
398
399fn collect_markdown_files(
403 roots: &[PathBuf],
404 options: &MarkdownWalkOptions,
405 excludes: &ExcludeMatchers,
406) -> Vec<PathBuf> {
407 let mut files = Vec::new();
408
409 for root in roots {
410 for result in crate::discovery::markdown_walk_builder(root, options).build() {
411 match result {
412 Ok(entry) => {
413 let path = entry.path();
414 if entry.file_type().is_some_and(|t| t.is_file())
415 && let Some(ext) = path.extension()
416 && is_markdown_extension(ext)
417 && !excluded_relative_to_root(excludes, path, root)
418 {
419 files.push(path.to_path_buf());
420 }
421 }
422 Err(e) => log::warn!("Error scanning {}: {}", root.display(), e),
423 }
424 }
425 }
426
427 files
428}
429
430fn excluded_relative_to_root(excludes: &ExcludeMatchers, path: &Path, root: &Path) -> bool {
434 if excludes.is_empty() {
435 return false;
436 }
437 excludes.excludes_file(path_relative_to(path, root).as_deref(), path)
438}
439
440pub(super) fn path_is_ignored_for_index(
459 roots: &[PathBuf],
460 path: &Path,
461 options: &MarkdownWalkOptions,
462 excludes: &ExcludeMatchers,
463) -> bool {
464 let Some(root) = roots
467 .iter()
468 .filter(|r| path.starts_with(r))
469 .max_by_key(|r| r.components().count())
470 else {
471 return false;
472 };
473
474 if options.skip_vendor_dirs
479 && let Ok(rel) = path.strip_prefix(root)
480 && rel.components().any(
481 |c| matches!(c, std::path::Component::Normal(name) if name == ".git" || name == "node_modules" || name == "target"),
482 )
483 {
484 return true;
485 }
486
487 if excluded_relative_to_root(excludes, path, root) {
489 return true;
490 }
491
492 let target = path.to_path_buf();
493 let mut builder = crate::discovery::markdown_walk_builder(root, options);
494 builder.filter_entry(move |entry| target.starts_with(entry.path()));
500 for entry in builder.build().flatten() {
501 if entry.path() == path {
502 return false;
503 }
504 }
505 true
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn test_build_file_index() {
514 let content = r#"
515# Main Heading
516
517Some text.
518
519## Sub Heading {#sub}
520
521More text with [link](./other.md#section).
522"#;
523
524 let index = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::default());
525
526 assert_eq!(index.headings.len(), 2);
527 assert_eq!(index.headings[0].text, "Main Heading");
528 assert!(index.headings[0].custom_anchor.is_none());
529
530 assert_eq!(index.headings[1].text, "Sub Heading");
532 assert_eq!(index.headings[1].custom_anchor, Some("sub".to_string()));
533
534 assert_eq!(index.cross_file_links.len(), 1);
535 assert_eq!(index.cross_file_links[0].target_path, "./other.md");
536 assert_eq!(index.cross_file_links[0].fragment, "section");
537 }
538
539 #[test]
540 fn test_build_file_index_respects_flavor() {
541 let content = "# Real\n\n# -8<- [start:section]\n";
545
546 let standard = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::Standard);
547 assert_eq!(
548 standard.headings.len(),
549 2,
550 "Standard treats the snippet line as a heading"
551 );
552
553 let mkdocs = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::MkDocs);
554 assert_eq!(mkdocs.headings.len(), 1, "MkDocs excludes the snippet marker");
555 assert_eq!(mkdocs.headings[0].text, "Real");
556 }
557
558 #[test]
559 fn test_build_file_index_column_positions() {
560 let content = "See [link](./file.md) here.\n";
562
563 let index = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::default());
564
565 assert_eq!(index.cross_file_links.len(), 1);
566 assert_eq!(index.cross_file_links[0].target_path, "./file.md");
567 assert_eq!(index.cross_file_links[0].line, 1);
568 assert_eq!(index.cross_file_links[0].column, 12);
570 }
571
572 #[test]
573 fn test_build_file_index_multiple_links() {
574 let content = "First [a](./a.md) and [b](./b.md#section) links.\n";
575
576 let index = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::default());
577
578 assert_eq!(index.cross_file_links.len(), 2);
579
580 assert_eq!(index.cross_file_links[0].target_path, "./a.md");
582 assert_eq!(index.cross_file_links[0].column, 11);
583
584 assert_eq!(index.cross_file_links[1].target_path, "./b.md");
586 assert_eq!(index.cross_file_links[1].fragment, "section");
587 assert_eq!(index.cross_file_links[1].column, 27);
588 }
589
590 #[test]
591 fn test_collect_markdown_files_respects_gitignore() {
592 use std::fs;
593
594 let dir = tempfile::tempdir().unwrap();
595 let root = dir.path();
596
597 fs::write(root.join("README.md"), "# Readme\n").unwrap();
599 fs::write(root.join(".gitignore"), "build/\nignored.md\n").unwrap();
600 fs::write(root.join("ignored.md"), "# Ignored\n").unwrap();
601 fs::create_dir(root.join("build")).unwrap();
602 fs::write(root.join("build").join("generated.md"), "# Generated\n").unwrap();
603
604 fs::create_dir(root.join("node_modules")).unwrap();
606 fs::write(root.join("node_modules").join("dep.md"), "# Dep\n").unwrap();
607
608 let mut files = collect_markdown_files(
609 &[root.to_path_buf()],
610 &index_walk_options(&Config::default()),
611 &ExcludeMatchers::new(&[]),
612 );
613 files.sort();
614
615 let names: Vec<String> = files
616 .iter()
617 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
618 .collect();
619
620 assert_eq!(names, vec!["README.md".to_string()]);
621 }
622
623 #[test]
624 fn test_collect_markdown_files_applies_config_excludes() {
625 use std::fs;
626
627 let dir = tempfile::tempdir().unwrap();
628 let root = dir.path();
629
630 fs::write(root.join("README.md"), "# Readme\n").unwrap();
631 fs::create_dir(root.join("drafts")).unwrap();
632 fs::write(root.join("drafts").join("wip.md"), "# WIP\n").unwrap();
633
634 let excludes = ExcludeMatchers::new(&["drafts".to_string()]);
637 let names: Vec<String> = collect_markdown_files(
638 &[root.to_path_buf()],
639 &index_walk_options(&Config::default()),
640 &excludes,
641 )
642 .iter()
643 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
644 .collect();
645
646 assert_eq!(names, vec!["README.md".to_string()]);
647 }
648
649 #[test]
650 fn test_collect_markdown_files_honors_absolute_exclude_patterns() {
651 use std::fs;
652
653 let dir = tempfile::tempdir().unwrap();
654 let root = crate::discovery::canonicalize_for_matching(dir.path()).unwrap();
658
659 fs::write(root.join("README.md"), "# Readme\n").unwrap();
660 fs::create_dir(root.join("drafts")).unwrap();
661 fs::write(root.join("drafts").join("wip.md"), "# WIP\n").unwrap();
662
663 let pattern = format!("{}/drafts", root.to_string_lossy().replace('\\', "/"));
666 let names: Vec<String> = collect_markdown_files(
667 std::slice::from_ref(&root),
668 &index_walk_options(&Config::default()),
669 &ExcludeMatchers::new(&[pattern]),
670 )
671 .iter()
672 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
673 .collect();
674
675 assert_eq!(names, vec!["README.md".to_string()]);
676 }
677
678 #[test]
679 fn test_collect_markdown_files_can_disable_gitignore() {
680 use std::fs;
681
682 let dir = tempfile::tempdir().unwrap();
683 let root = dir.path();
684
685 fs::write(root.join(".gitignore"), "ignored.md\n").unwrap();
686 fs::write(root.join("ignored.md"), "# Ignored\n").unwrap();
687
688 let mut config = Config::default();
689 config.global.respect_gitignore = false;
690 let names: Vec<String> = collect_markdown_files(
691 &[root.to_path_buf()],
692 &index_walk_options(&config),
693 &ExcludeMatchers::new(&[]),
694 )
695 .iter()
696 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
697 .collect();
698
699 assert_eq!(names, vec!["ignored.md".to_string()]);
700 }
701
702 #[test]
703 fn test_collect_markdown_files_includes_hidden_files() {
704 use std::fs;
705
706 let dir = tempfile::tempdir().unwrap();
707 let root = dir.path();
708
709 fs::create_dir(root.join(".github")).unwrap();
710 fs::write(root.join(".github").join("PULL_REQUEST_TEMPLATE.md"), "# PR\n").unwrap();
711 fs::write(root.join("README.md"), "# Readme\n").unwrap();
712
713 let mut names: Vec<String> = collect_markdown_files(
714 &[root.to_path_buf()],
715 &index_walk_options(&Config::default()),
716 &ExcludeMatchers::new(&[]),
717 )
718 .iter()
719 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
720 .collect();
721 names.sort();
722
723 assert_eq!(
725 names,
726 vec!["PULL_REQUEST_TEMPLATE.md".to_string(), "README.md".to_string()]
727 );
728 }
729
730 #[test]
731 fn test_collect_markdown_files_finds_nested_markdown() {
732 use std::fs;
733
734 let dir = tempfile::tempdir().unwrap();
735 let root = dir.path();
736
737 fs::write(root.join("top.md"), "# Top\n").unwrap();
738 fs::create_dir(root.join("docs")).unwrap();
739 fs::write(root.join("docs").join("guide.markdown"), "# Guide\n").unwrap();
740 fs::write(root.join("docs").join("notes.txt"), "not markdown\n").unwrap();
741
742 let mut names: Vec<String> = collect_markdown_files(
743 &[root.to_path_buf()],
744 &index_walk_options(&Config::default()),
745 &ExcludeMatchers::new(&[]),
746 )
747 .iter()
748 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
749 .collect();
750 names.sort();
751
752 assert_eq!(names, vec!["guide.markdown".to_string(), "top.md".to_string()]);
753 }
754
755 #[test]
756 fn test_path_is_ignored_for_index() {
757 use std::fs;
758
759 let dir = tempfile::tempdir().unwrap();
760 let root = dir.path().to_path_buf();
761 fs::write(root.join(".gitignore"), "build/\ndraft.md\n").unwrap();
762
763 fs::write(root.join("README.md"), "").unwrap();
766 fs::write(root.join("draft.md"), "").unwrap();
767 fs::write(root.join(".hidden.md"), "").unwrap();
768 fs::create_dir(root.join("docs")).unwrap();
769 fs::write(root.join("docs").join("guide.md"), "").unwrap();
770 fs::create_dir(root.join("build")).unwrap();
771 fs::write(root.join("build").join("out.md"), "").unwrap();
772
773 let roots = vec![root.clone()];
774 let options = index_walk_options(&Config::default());
775 let no_excludes = ExcludeMatchers::new(&[]);
776
777 assert!(!path_is_ignored_for_index(
779 &roots,
780 &root.join("README.md"),
781 &options,
782 &no_excludes
783 ));
784 assert!(!path_is_ignored_for_index(
785 &roots,
786 &root.join("docs/guide.md"),
787 &options,
788 &no_excludes
789 ));
790
791 assert!(path_is_ignored_for_index(
793 &roots,
794 &root.join("draft.md"),
795 &options,
796 &no_excludes
797 ));
798 assert!(path_is_ignored_for_index(
799 &roots,
800 &root.join("build/out.md"),
801 &options,
802 &no_excludes
803 ));
804
805 assert!(!path_is_ignored_for_index(
807 &roots,
808 &root.join(".hidden.md"),
809 &options,
810 &no_excludes
811 ));
812
813 assert!(path_is_ignored_for_index(
816 &roots,
817 &root.join("node_modules/dep.md"),
818 &options,
819 &no_excludes
820 ));
821 assert!(path_is_ignored_for_index(
822 &roots,
823 &root.join("target/doc.md"),
824 &options,
825 &no_excludes
826 ));
827
828 let excludes = ExcludeMatchers::new(&["docs".to_string()]);
830 assert!(path_is_ignored_for_index(
831 &roots,
832 &root.join("docs/guide.md"),
833 &options,
834 &excludes
835 ));
836 assert!(!path_is_ignored_for_index(
837 &roots,
838 &root.join("README.md"),
839 &options,
840 &excludes
841 ));
842
843 let outside = dir.path().parent().unwrap().join("elsewhere.md");
845 assert!(!path_is_ignored_for_index(&roots, &outside, &options, &no_excludes));
846 }
847
848 #[test]
849 fn test_path_is_ignored_for_index_honors_nested_gitignore() {
850 use std::fs;
851
852 let dir = tempfile::tempdir().unwrap();
853 let root = dir.path().to_path_buf();
854 fs::create_dir(root.join("docs")).unwrap();
855 fs::write(root.join("docs").join(".gitignore"), "generated.md\n").unwrap();
856 fs::write(root.join("docs").join("generated.md"), "").unwrap();
857 fs::write(root.join("docs").join("manual.md"), "").unwrap();
858
859 let roots = vec![root.clone()];
860 let options = index_walk_options(&Config::default());
861 let no_excludes = ExcludeMatchers::new(&[]);
862
863 assert!(path_is_ignored_for_index(
864 &roots,
865 &root.join("docs/generated.md"),
866 &options,
867 &no_excludes
868 ));
869 assert!(!path_is_ignored_for_index(
870 &roots,
871 &root.join("docs/manual.md"),
872 &options,
873 &no_excludes
874 ));
875 }
876
877 #[test]
878 fn test_path_is_ignored_for_index_workspace_under_target_dir() {
879 use std::fs;
880
881 let dir = tempfile::tempdir().unwrap();
884 let root = dir.path().join("target").join("my-docs");
885 fs::create_dir_all(&root).unwrap();
886 fs::write(root.join("README.md"), "").unwrap();
887 fs::create_dir(root.join("target")).unwrap();
888 fs::write(root.join("target").join("out.md"), "").unwrap();
889
890 let roots = vec![root.clone()];
891 let options = index_walk_options(&Config::default());
892 let no_excludes = ExcludeMatchers::new(&[]);
893
894 assert!(!path_is_ignored_for_index(
897 &roots,
898 &root.join("README.md"),
899 &options,
900 &no_excludes
901 ));
902 assert!(path_is_ignored_for_index(
904 &roots,
905 &root.join("target/out.md"),
906 &options,
907 &no_excludes
908 ));
909 }
910}