1use hashbrown::HashSet;
2use serde::{Deserialize, Serialize};
3use std::fmt::Write as _;
4use std::path::{Path, PathBuf};
5use tokio::io;
6
7use crate::utils::file_utils::canonicalize_with_context;
8use anyhow::{Context, Result, anyhow};
9use glob::{Pattern, glob};
10use tracing::warn;
11use vtcode_commons::VtCodePaths;
12use vtcode_commons::canonicalize;
13use vtcode_commons::canonicalize_async;
14use vtcode_commons::walk::build_walker_single_threaded;
15
16const AGENTS_FILENAME: &str = "AGENTS.md";
17const AGENTS_OVERRIDE_FILENAME: &str = "AGENTS.override.md";
18const CLAUDE_FILENAME: &str = "CLAUDE.md";
19const GLOBAL_CONFIG_DIRECTORY: &str = ".config/vtcode";
20const RULES_DIRECTORY: &str = ".vtcode/rules";
21const IMPORT_PROBE_NAME: &str = "__vtcode_instruction_probe__";
22
23#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
25#[serde(tag = "scope", rename_all = "snake_case")]
26pub enum InstructionScope {
27 User,
29 Workspace,
31 Custom,
33}
34
35#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum InstructionSourceKind {
39 Agents,
41 Rule,
43 Extra,
45}
46
47#[derive(Debug, Clone, Serialize)]
49pub struct InstructionSource {
50 pub path: PathBuf,
52 pub scope: InstructionScope,
54 pub kind: InstructionSourceKind,
56 pub matched: bool,
58}
59
60#[derive(Debug, Clone, Serialize)]
62pub struct InstructionSegment {
63 pub source: InstructionSource,
65 pub contents: String,
67}
68
69#[derive(Debug, Clone, Serialize)]
71pub struct InstructionBundle {
72 pub segments: Vec<InstructionSegment>,
74 pub truncated: bool,
76 pub bytes_read: usize,
78}
79
80impl InstructionBundle {
81 pub fn is_empty(&self) -> bool {
83 self.segments.is_empty()
84 }
85
86 pub fn combined_text(&self) -> String {
88 let capacity = self
89 .segments
90 .iter()
91 .map(|segment| segment.contents.len())
92 .sum::<usize>()
93 .saturating_add(self.segments.len().saturating_sub(1) * 2);
94 let mut output = String::with_capacity(capacity);
95 for (index, segment) in self.segments.iter().enumerate() {
96 if index > 0 {
97 output.push_str("\n\n");
98 }
99
100 output.push_str(&segment.contents);
101 }
102 output
103 }
104
105 pub fn highlights(&self, limit: usize) -> Vec<String> {
107 extract_instruction_highlights(&self.segments, limit)
108 }
109}
110
111#[derive(Debug, Clone)]
112pub struct InstructionDiscoveryOptions<'a> {
113 pub current_dir: &'a Path,
114 pub project_root: &'a Path,
115 pub home_dir: Option<&'a Path>,
116 pub extra_patterns: &'a [String],
117 pub fallback_filenames: &'a [String],
118 pub exclude_patterns: &'a [String],
119 pub match_paths: &'a [PathBuf],
120 pub import_max_depth: usize,
121}
122
123#[derive(Debug, Clone, Default, Deserialize)]
124struct RuleFrontmatter {
125 #[serde(default)]
126 paths: Vec<String>,
127}
128
129#[derive(Debug, Clone)]
130struct RuleDescriptor {
131 patterns: Vec<String>,
132 specificity: usize,
133}
134
135#[derive(Debug, Clone)]
136struct MatchCandidate {
137 relative_path: String,
138 is_dir: bool,
139}
140
141#[derive(Debug, Clone)]
142struct MatchContext {
143 candidates: Vec<MatchCandidate>,
144}
145
146impl MatchContext {
147 async fn new(project_root: &Path, match_paths: &[PathBuf]) -> Self {
148 let mut seen = HashSet::new();
149 let mut candidates = Vec::with_capacity(match_paths.len());
150 let canonical_root = canonicalize_async(project_root).await.ok();
151
152 for raw_path in match_paths {
153 let candidate = if raw_path.is_absolute() {
154 raw_path.to_path_buf()
155 } else {
156 project_root.join(raw_path)
157 };
158
159 let normalized = canonicalize_async(&candidate).await.unwrap_or_else(|_| candidate.clone());
160 let relative = normalized
161 .strip_prefix(project_root)
162 .ok()
163 .or_else(|| canonical_root.as_ref().and_then(|root| normalized.strip_prefix(root).ok()))
164 .or_else(|| candidate.strip_prefix(project_root).ok())
165 .or_else(|| canonical_root.as_ref().and_then(|root| candidate.strip_prefix(root).ok()));
166 let Some(relative) = relative else {
167 continue;
168 };
169
170 let relative = relative.display().to_string();
171 if relative.is_empty() {
172 continue;
173 }
174
175 let is_dir = tokio::fs::metadata(&normalized).await.is_ok_and(|metadata| metadata.is_dir());
176 let key = format!("{relative}:{is_dir}");
177 if seen.insert(key) {
178 candidates.push(MatchCandidate { relative_path: relative, is_dir });
179 }
180 }
181
182 candidates.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
183 Self { candidates }
184 }
185
186 fn matches_any(&self, patterns: &[String]) -> bool {
187 if patterns.is_empty() {
188 return false;
189 }
190
191 patterns.iter().any(|raw_pattern| {
192 let trimmed = raw_pattern.trim();
193 if trimmed.is_empty() {
194 return false;
195 }
196
197 let Ok(pattern) = Pattern::new(trimmed) else {
198 warn!("Ignoring invalid instruction rule path pattern `{trimmed}`");
199 return false;
200 };
201
202 self.candidates
203 .iter()
204 .any(|candidate| pattern_matches_candidate(&pattern, candidate))
205 })
206 }
207}
208
209#[derive(Debug, Clone)]
210struct ExclusionMatcher {
211 patterns: Vec<Pattern>,
212}
213
214impl ExclusionMatcher {
215 async fn compile(project_root: &Path, home_dir: Option<&Path>, raw_patterns: &[String]) -> Result<Self> {
216 let mut patterns = Vec::with_capacity(raw_patterns.len());
217 for raw in raw_patterns {
218 let trimmed = raw.trim();
219 if trimmed.is_empty() {
220 continue;
221 }
222 let resolved = resolve_pattern(trimmed, project_root, home_dir).await?;
223 let pattern = Pattern::new(&resolved)
224 .with_context(|| format!("Failed to compile instruction exclude pattern `{trimmed}`"))?;
225 patterns.push(pattern);
226 }
227
228 Ok(Self { patterns })
229 }
230
231 fn matches(&self, path: &Path) -> bool {
232 self.patterns.iter().any(|pattern| {
233 pattern.matches_path(path)
234 || pattern.matches_path_with(
235 path,
236 glob::MatchOptions {
237 case_sensitive: true,
238 require_literal_separator: false,
239 require_literal_leading_dot: false,
240 },
241 )
242 })
243 }
244}
245
246pub fn extract_instruction_highlights(segments: &[InstructionSegment], limit: usize) -> Vec<String> {
247 if limit == 0 {
248 return Vec::new();
249 }
250
251 let mut highlights = Vec::with_capacity(limit);
252 for segment in segments {
253 let mut found_bullet = false;
254 for line in segment.contents.lines() {
255 if highlights.len() >= limit {
256 break;
257 }
258
259 let trimmed = line.trim();
260 if trimmed.starts_with('-') {
261 let highlight = trimmed.trim_start_matches('-').trim();
262 if !highlight.is_empty() {
263 highlights.push(highlight.to_string());
264 found_bullet = true;
265 }
266 }
267 }
268
269 if highlights.len() >= limit {
270 break;
271 }
272
273 if found_bullet {
274 continue;
275 }
276
277 for line in segment.contents.lines() {
278 if highlights.len() >= limit {
279 break;
280 }
281
282 let trimmed = line.trim();
283 if trimmed.is_empty() {
284 continue;
285 }
286
287 let fallback = trimmed.trim_start_matches('#').trim();
288 if !fallback.is_empty() {
289 highlights.push(fallback.to_string());
290 break;
291 }
292 }
293
294 if highlights.len() >= limit {
295 break;
296 }
297 }
298
299 highlights
300}
301
302pub fn render_instruction_markdown(
303 title: &str,
304 segments: &[InstructionSegment],
305 truncated: bool,
306 project_root: &Path,
307 home_dir: Option<&Path>,
308 truncation_note: &str,
309) -> String {
310 let combined_len = segments.iter().map(|segment| segment.contents.len()).sum::<usize>();
311 let mut section = String::with_capacity(combined_len.saturating_add(512));
312 let _ = writeln!(section, "## {title}\n");
313
314 let show_map = segments.len() > 1;
319 if show_map {
320 section.push_str(
321 "Instructions are listed from lowest to highest precedence. When conflicts exist, defer to the later entries.\n\n",
322 );
323 section.push_str("### Instruction map\n");
324 for (index, segment) in segments.iter().enumerate() {
325 let _ = writeln!(
326 section,
327 "- {}. {} ({})",
328 index + 1,
329 format_instruction_path(&segment.source.path, project_root, home_dir),
330 instruction_source_label(&segment.source),
331 );
332 }
333 }
334
335 if !segments.is_empty() {
336 for (index, segment) in segments.iter().enumerate() {
337 let _ = writeln!(
338 section,
339 "\n### {}. {} ({})\n",
340 index + 1,
341 format_instruction_path(&segment.source.path, project_root, home_dir),
342 instruction_source_label(&segment.source),
343 );
344 section.push_str(segment.contents.trim());
345 section.push('\n');
346 }
347 }
348
349 if truncated && !truncation_note.is_empty() {
350 let _ = writeln!(section, "\n_{truncation_note}_");
351 }
352
353 section.push('\n');
354 section
355}
356
357pub fn instruction_scope_label(scope: &InstructionScope) -> &'static str {
358 match scope {
359 InstructionScope::User => "user",
360 InstructionScope::Workspace => "workspace",
361 InstructionScope::Custom => "custom",
362 }
363}
364
365pub fn instruction_source_label(source: &InstructionSource) -> String {
366 match source.kind {
367 InstructionSourceKind::Agents => {
368 format!("{} {}", instruction_scope_label(&source.scope), instruction_file_label(&source.path),)
369 }
370 InstructionSourceKind::Extra => {
371 format!("{} extra instructions", instruction_scope_label(&source.scope))
372 }
373 InstructionSourceKind::Rule if source.matched => {
374 format!("{} matched rule", instruction_scope_label(&source.scope))
375 }
376 InstructionSourceKind::Rule => {
377 format!("{} rule", instruction_scope_label(&source.scope))
378 }
379 }
380}
381
382fn instruction_file_label(path: &Path) -> &'static str {
383 if path
384 .file_name()
385 .and_then(|name| name.to_str())
386 .is_some_and(|name| name.eq_ignore_ascii_case(CLAUDE_FILENAME))
387 {
388 "CLAUDE"
389 } else {
390 "AGENTS"
391 }
392}
393
394pub fn format_instruction_path(path: &Path, project_root: &Path, home_dir: Option<&Path>) -> String {
395 if let Ok(relative) = path.strip_prefix(project_root) {
396 let display = relative.display().to_string();
397 if !display.is_empty() {
398 return display;
399 }
400
401 if let Some(name) = path.file_name().and_then(|value| value.to_str()) {
402 return name.to_string();
403 }
404 }
405
406 if let Some(home) = home_dir
407 && let Ok(relative) = path.strip_prefix(home)
408 {
409 let display = relative.display().to_string();
410 if display.is_empty() {
411 return "~".to_string();
412 }
413
414 return format!("~/{display}");
415 }
416
417 path.display().to_string()
418}
419
420pub async fn discover_instruction_sources(options: &InstructionDiscoveryOptions<'_>) -> Result<Vec<InstructionSource>> {
421 let mut sources = Vec::with_capacity(16);
422 let mut seen_paths = HashSet::new();
423 let excludes = ExclusionMatcher::compile(options.project_root, options.home_dir, options.exclude_patterns).await?;
424 let match_context = MatchContext::new(options.project_root, options.match_paths).await;
425
426 if let Some(home) = options.home_dir {
427 for candidate in user_instruction_candidates(home, options.fallback_filenames) {
428 if let Some(path) = normalize_instruction_candidate(&candidate, &excludes).await?
429 && seen_paths.insert(path.clone())
430 {
431 sources.push(InstructionSource {
432 path,
433 scope: InstructionScope::User,
434 kind: InstructionSourceKind::Agents,
435 matched: false,
436 });
437 }
438 }
439
440 let (user_unconditional_rules, user_matched_rules) =
441 discover_rule_sources(user_rules_roots(home), InstructionScope::User, &match_context, &excludes).await?;
442 for source in user_unconditional_rules.into_iter().chain(user_matched_rules) {
443 if seen_paths.insert(source.path.clone()) {
444 sources.push(source);
445 }
446 }
447 }
448
449 let extra_paths =
450 expand_instruction_patterns(options.project_root, options.home_dir, options.extra_patterns, &excludes).await?;
451 for path in extra_paths {
452 if seen_paths.insert(path.clone()) {
453 sources.push(InstructionSource {
454 path,
455 scope: InstructionScope::Custom,
456 kind: InstructionSourceKind::Extra,
457 matched: false,
458 });
459 }
460 }
461
462 let root = canonicalize_async(options.project_root)
463 .await
464 .with_context(|| format!("Failed to canonicalize project root {}", options.project_root.display()))?;
465 let mut cursor = canonicalize_async(options.current_dir)
466 .await
467 .with_context(|| format!("Failed to canonicalize working directory {}", options.current_dir.display()))?;
468 if !cursor.starts_with(&root) {
469 cursor = root.clone();
470 }
471
472 let mut workspace_levels = Vec::with_capacity(4);
473 loop {
474 let chosen_paths =
475 select_workspace_instruction_candidates(&cursor, options.fallback_filenames, &excludes).await?;
476
477 let mut level_sources = Vec::with_capacity(chosen_paths.len());
478 for path in chosen_paths {
479 if seen_paths.insert(path.clone()) {
480 level_sources.push(InstructionSource {
481 path,
482 scope: InstructionScope::Workspace,
483 kind: InstructionSourceKind::Agents,
484 matched: false,
485 });
486 }
487 }
488 if !level_sources.is_empty() {
489 workspace_levels.push(level_sources);
490 }
491
492 if cursor == root {
493 break;
494 }
495
496 cursor = cursor
497 .parent()
498 .map(Path::to_path_buf)
499 .ok_or_else(|| anyhow!("Reached filesystem root before encountering project root"))?;
500 }
501
502 workspace_levels.reverse();
503 sources.extend(workspace_levels.into_iter().flatten());
504
505 let (workspace_unconditional_rules, workspace_matched_rules) =
506 discover_rule_sources(vec![root.join(RULES_DIRECTORY)], InstructionScope::Workspace, &match_context, &excludes)
507 .await?;
508 for source in workspace_unconditional_rules.into_iter().chain(workspace_matched_rules) {
509 if seen_paths.insert(source.path.clone()) {
510 sources.push(source);
511 }
512 }
513
514 Ok(sources)
515}
516
517pub async fn read_instruction_bundle(
518 options: &InstructionDiscoveryOptions<'_>,
519 max_bytes: usize,
520) -> Result<Option<InstructionBundle>> {
521 if max_bytes == 0 {
522 return Ok(None);
523 }
524
525 let sources = discover_instruction_sources(options).await?;
526 if sources.is_empty() {
527 return Ok(None);
528 }
529
530 let allowed_import_roots = allowed_import_roots(options.project_root, options.home_dir).await?;
531 let import_max_depth = options.import_max_depth.max(1);
532
533 let (segments, truncated, bytes_read) = tokio::task::spawn_blocking(move || {
537 let mut remaining = max_bytes;
538 let mut segments = Vec::with_capacity(sources.len());
539 let mut truncated = false;
540 let mut bytes_read = 0usize;
541 let mut seen_imports = HashSet::new();
542
543 for source in sources {
544 if remaining == 0 {
545 truncated = true;
546 break;
547 }
548
549 let contents = match expand_instruction_contents(
550 &source.path,
551 &source.kind,
552 &allowed_import_roots,
553 import_max_depth,
554 &mut seen_imports,
555 0,
556 &mut Vec::new(),
557 )? {
558 Some(contents) => contents,
559 None => continue,
560 };
561
562 if contents.len() > remaining {
563 truncated = true;
564 }
565
566 let slice_len = contents.len().min(remaining);
567 let visible = String::from_utf8_lossy(&contents.as_bytes()[..slice_len]).to_string();
568 if visible.trim().is_empty() {
569 remaining = remaining.saturating_sub(slice_len);
570 continue;
571 }
572
573 bytes_read += slice_len;
574 remaining = remaining.saturating_sub(slice_len);
575 segments.push(InstructionSegment { source, contents: visible });
576 }
577
578 Ok::<_, anyhow::Error>((segments, truncated, bytes_read))
579 })
580 .await
581 .context("Instruction expansion task panicked")??;
582
583 if segments.is_empty() {
584 Ok(None)
585 } else {
586 Ok(Some(InstructionBundle { segments, truncated, bytes_read }))
587 }
588}
589
590fn user_instruction_candidates(home: &Path, fallback_filenames: &[String]) -> Vec<PathBuf> {
591 let mut candidates = Vec::new();
592 for root in user_global_instruction_roots(home) {
593 candidates.extend(instruction_candidates_for_dir(&root, fallback_filenames));
594 }
595 candidates
596}
597
598fn user_rules_roots(home: &Path) -> Vec<PathBuf> {
599 user_global_instruction_roots(home)
600 .into_iter()
601 .map(|root| root.join("rules"))
602 .collect()
603}
604
605fn user_global_instruction_roots(home: &Path) -> Vec<PathBuf> {
606 let canonical_config = VtCodePaths::resolve()
607 .ok()
608 .filter(|_paths| dirs::home_dir().as_deref() == Some(home))
609 .map(|paths| paths.config_dir().to_path_buf())
610 .unwrap_or_else(|| home.join(GLOBAL_CONFIG_DIRECTORY));
611 let legacy = VtCodePaths::resolve()
612 .ok()
613 .filter(|_paths| dirs::home_dir().as_deref() == Some(home))
614 .map(|paths| paths.legacy_dir().to_path_buf())
615 .unwrap_or_else(|| home.join(".vtcode"));
616
617 let mut roots = vec![
618 home.to_path_buf(),
619 legacy,
620 home.join(GLOBAL_CONFIG_DIRECTORY),
621 canonical_config,
622 ];
623 roots.dedup();
624 roots
625}
626
627fn instruction_candidates_for_dir(dir: &Path, fallback_filenames: &[String]) -> Vec<PathBuf> {
628 let mut candidates = Vec::with_capacity(2 + fallback_filenames.len());
629 candidates.push(dir.join(AGENTS_OVERRIDE_FILENAME));
630 candidates.push(dir.join(AGENTS_FILENAME));
631 for name in fallback_filenames {
632 let trimmed = name.trim();
633 if trimmed.is_empty()
634 || trimmed.eq_ignore_ascii_case(AGENTS_FILENAME)
635 || trimmed.eq_ignore_ascii_case(AGENTS_OVERRIDE_FILENAME)
636 {
637 continue;
638 }
639 candidates.push(dir.join(trimmed));
640 }
641 candidates
642}
643
644async fn select_workspace_instruction_candidate(
645 dir: &Path,
646 fallback_filenames: &[String],
647 excludes: &ExclusionMatcher,
648) -> Result<Option<PathBuf>> {
649 for candidate in instruction_candidates_for_dir(dir, fallback_filenames) {
650 if let Some(path) = normalize_instruction_candidate(&candidate, excludes).await? {
651 return Ok(Some(path));
652 }
653 }
654 Ok(None)
655}
656
657async fn select_workspace_instruction_candidates(
658 dir: &Path,
659 fallback_filenames: &[String],
660 excludes: &ExclusionMatcher,
661) -> Result<Vec<PathBuf>> {
662 let mut selected = Vec::with_capacity(2);
663 if let Some(path) = select_workspace_instruction_candidate(dir, fallback_filenames, excludes).await? {
664 selected.push(path);
665 }
666
667 if let Some(path) = normalize_instruction_candidate(&dir.join(CLAUDE_FILENAME), excludes).await?
668 && !selected.iter().any(|existing_path| existing_path == &path)
669 {
670 selected.push(path);
671 }
672
673 Ok(selected)
674}
675
676async fn normalize_instruction_candidate(candidate: &Path, excludes: &ExclusionMatcher) -> Result<Option<PathBuf>> {
677 if !instruction_exists(candidate).await? {
678 return Ok(None);
679 }
680
681 let canonical = canonicalize_async(candidate)
682 .await
683 .with_context(|| format!("Failed to canonicalize instruction candidate {}", candidate.display()))?;
684 if excludes.matches(&canonical) {
685 return Ok(None);
686 }
687
688 Ok(Some(canonical))
689}
690
691async fn discover_rule_sources(
692 rule_roots: Vec<PathBuf>,
693 scope: InstructionScope,
694 match_context: &MatchContext,
695 excludes: &ExclusionMatcher,
696) -> Result<(Vec<InstructionSource>, Vec<InstructionSource>)> {
697 let discovered = {
701 let excludes = excludes.clone();
702 tokio::task::spawn_blocking(move || {
703 let mut paths = Vec::new();
704 let mut seen = HashSet::new();
705
706 for root in &rule_roots {
707 if !root.exists() {
708 continue;
709 }
710
711 for entry in build_walker_single_threaded(root)
712 .follow_links(true)
713 .sort_by_file_name(|a, b| a.cmp(b))
714 .build()
715 .filter_map(std::result::Result::ok)
716 {
717 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
718 continue;
719 }
720
721 if entry.path().extension().and_then(|value| value.to_str()) != Some("md") {
722 continue;
723 }
724
725 if entry
726 .file_name()
727 .to_str()
728 .is_some_and(|name| name.eq_ignore_ascii_case("README.md"))
729 {
730 continue;
731 }
732
733 let path = canonicalize_with_context(entry.path(), "instruction rule")?;
734 if excludes.matches(&path) || !seen.insert(path.clone()) {
735 continue;
736 }
737
738 paths.push(path);
739 }
740 }
741
742 Ok::<_, anyhow::Error>(paths)
743 })
744 .await
745 .context("Rule discovery task panicked")??
746 };
747
748 let mut unconditional = Vec::new();
750 let mut matched = Vec::new();
751
752 for path in discovered {
753 let descriptor = read_rule_descriptor(&path).await?;
754 let is_matched = if descriptor.patterns.is_empty() {
755 false
756 } else {
757 match_context.matches_any(&descriptor.patterns)
758 };
759 let source = InstructionSource {
760 path,
761 scope: scope.clone(),
762 kind: InstructionSourceKind::Rule,
763 matched: is_matched,
764 };
765
766 if descriptor.patterns.is_empty() {
767 unconditional.push(source);
768 } else if is_matched {
769 matched.push((descriptor.specificity, source));
770 }
771 }
772
773 unconditional.sort_by(|left, right| left.path.cmp(&right.path));
774 matched.sort_by(|(left_specificity, left), (right_specificity, right)| {
775 left_specificity.cmp(right_specificity).then(left.path.cmp(&right.path))
776 });
777
778 Ok((unconditional, matched.into_iter().map(|(_, source)| source).collect()))
779}
780
781async fn read_rule_descriptor(path: &Path) -> Result<RuleDescriptor> {
782 let contents = tokio::fs::read_to_string(path)
783 .await
784 .with_context(|| format!("Failed to read instruction rule {}", path.display()))?;
785 let frontmatter = parse_rule_frontmatter(&contents, path)?;
786 let specificity = frontmatter
787 .paths
788 .iter()
789 .map(|pattern| rule_specificity(pattern))
790 .max()
791 .unwrap_or(0);
792
793 Ok(RuleDescriptor { patterns: frontmatter.paths, specificity })
794}
795
796async fn expand_instruction_patterns(
797 project_root: &Path,
798 home_dir: Option<&Path>,
799 patterns: &[String],
800 excludes: &ExclusionMatcher,
801) -> Result<Vec<PathBuf>> {
802 let mut paths = Vec::with_capacity(patterns.len());
803 let mut seen = HashSet::new();
804
805 for pattern in patterns {
806 let resolved = resolve_pattern(pattern, project_root, home_dir).await?;
807 let glob_matches: Vec<PathBuf> = glob(&resolved)
808 .with_context(|| format!("Failed to expand instruction pattern `{pattern}`"))?
809 .filter_map(|entry| match entry {
810 Ok(path) => Some(path),
811 Err(err) => {
812 warn!("Ignoring malformed instruction path for pattern `{pattern}`: {err}");
813 None
814 }
815 })
816 .collect();
817
818 let mut matches = Vec::with_capacity(glob_matches.len());
819 for path in glob_matches {
820 match normalize_instruction_candidate(&path, excludes).await {
821 Ok(Some(canonical)) if seen.insert(canonical.clone()) => matches.push(canonical),
822 Ok(Some(_)) | Ok(None) => {}
823 Err(err) => {
824 warn!("Failed to inspect potential instruction `{}`: {err:#}", path.display());
825 }
826 }
827 }
828
829 if matches.is_empty() {
830 warn!("Instruction pattern `{pattern}` did not match any files");
831 } else {
832 matches.sort();
833 paths.extend(matches);
834 }
835 }
836
837 Ok(paths)
838}
839
840async fn resolve_pattern(pattern: &str, project_root: &Path, home_dir: Option<&Path>) -> Result<String> {
841 if let Some(stripped) = pattern.strip_prefix("~/") {
842 let home = home_dir
843 .ok_or_else(|| anyhow!("Cannot expand `~` in instruction pattern `{pattern}` without a home directory"))?;
844 let resolved = home.join(stripped);
845 if !contains_glob_meta(stripped) && tokio::fs::try_exists(&resolved).await.unwrap_or(false) {
846 return Ok(canonicalize_async(&resolved)
847 .await
848 .with_context(|| format!("Failed to canonicalize instruction pattern {}", resolved.display()))?
849 .to_string_lossy()
850 .into_owned());
851 }
852 return Ok(resolved.to_string_lossy().into_owned());
853 }
854
855 let candidate = Path::new(pattern);
856 let full_path = if candidate.is_absolute() {
857 candidate.to_path_buf()
858 } else {
859 project_root.join(candidate)
860 };
861
862 if !contains_glob_meta(pattern) && tokio::fs::try_exists(&full_path).await.unwrap_or(false) {
863 return Ok(canonicalize_async(&full_path)
864 .await
865 .with_context(|| format!("Failed to canonicalize instruction pattern {}", full_path.display()))?
866 .to_string_lossy()
867 .into_owned());
868 }
869
870 Ok(full_path.to_string_lossy().into_owned())
871}
872
873async fn instruction_exists(path: &Path) -> Result<bool> {
874 match tokio::fs::symlink_metadata(path).await {
875 Ok(metadata) => Ok(metadata.file_type().is_file() || metadata.file_type().is_symlink()),
876 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
877 Err(err) => Err(err).with_context(|| format!("Failed to inspect instruction candidate {}", path.display())),
878 }
879}
880
881fn expand_instruction_contents(
882 path: &Path,
883 kind: &InstructionSourceKind,
884 allowed_roots: &[PathBuf],
885 max_depth: usize,
886 seen_imports: &mut HashSet<PathBuf>,
887 depth: usize,
888 stack: &mut Vec<PathBuf>,
889) -> Result<Option<String>> {
890 let canonical = canonicalize_with_context(path, "instruction source")?;
891 if depth > 0 {
892 if stack.contains(&canonical) {
893 warn!("Skipping cyclic instruction import `{}` while expanding `{}`", canonical.display(), path.display());
894 return Ok(None);
895 }
896 if depth > max_depth {
897 warn!(
898 "Skipping instruction import `{}` because it exceeds the max depth of {}",
899 canonical.display(),
900 max_depth
901 );
902 return Ok(None);
903 }
904 if !seen_imports.insert(canonical.clone()) {
905 return Ok(None);
906 }
907 }
908
909 stack.push(canonical.clone());
910
911 let raw = match std::fs::read_to_string(&canonical) {
912 Ok(contents) => contents,
913 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
914 stack.pop();
915 return Ok(None);
916 }
917 Err(err) => {
918 stack.pop();
919 return Err(err).with_context(|| format!("Failed to open instruction file {}", canonical.display()));
920 }
921 };
922 let contents_without_frontmatter = match kind {
923 InstructionSourceKind::Rule => strip_rule_frontmatter(&raw),
924 InstructionSourceKind::Agents | InstructionSourceKind::Extra => raw,
925 };
926 let sanitized = strip_html_comments(&contents_without_frontmatter);
927 let output = expand_inline_imports(&sanitized, &canonical, allowed_roots, max_depth, seen_imports, depth, stack)?;
928
929 stack.pop();
930
931 if output.trim().is_empty() {
932 Ok(None)
933 } else {
934 Ok(Some(output))
935 }
936}
937
938fn expand_inline_imports(
939 contents: &str,
940 containing_file: &Path,
941 allowed_roots: &[PathBuf],
942 max_depth: usize,
943 seen_imports: &mut HashSet<PathBuf>,
944 depth: usize,
945 stack: &mut Vec<PathBuf>,
946) -> Result<String> {
947 let mut output = String::new();
948 let mut in_code_block = false;
949
950 for line in contents.lines() {
951 let trimmed = line.trim_start();
952 if is_fence_line(trimmed) {
953 in_code_block = !in_code_block;
954 output.push_str(line);
955 output.push('\n');
956 continue;
957 }
958
959 output.push_str(line);
960 output.push('\n');
961
962 if in_code_block {
963 continue;
964 }
965
966 let imports = collect_imports(line);
967 for import in imports {
968 let Some(import_path) = resolve_import_path(&import, containing_file, allowed_roots)? else {
969 continue;
970 };
971
972 let import_kind = infer_instruction_kind(&import_path);
973 let imported = expand_instruction_contents(
974 &import_path,
975 &import_kind,
976 allowed_roots,
977 max_depth,
978 seen_imports,
979 depth + 1,
980 stack,
981 )?;
982 let Some(imported) = imported else {
983 continue;
984 };
985
986 if imported.trim().is_empty() {
987 continue;
988 }
989
990 let _ = writeln!(output, "[Imported from {}]", import_path.display());
991 output.push_str(imported.trim());
992 output.push_str("\n\n");
993 }
994 }
995
996 Ok(output.trim().to_string())
997}
998
999fn infer_instruction_kind(path: &Path) -> InstructionSourceKind {
1000 if path.components().any(|component| component.as_os_str() == "rules") {
1001 InstructionSourceKind::Rule
1002 } else if path.file_name().and_then(|value| value.to_str()) == Some(AGENTS_FILENAME)
1003 || path.file_name().and_then(|value| value.to_str()) == Some(AGENTS_OVERRIDE_FILENAME)
1004 {
1005 InstructionSourceKind::Agents
1006 } else {
1007 InstructionSourceKind::Extra
1008 }
1009}
1010
1011fn parse_rule_frontmatter(contents: &str, path: &Path) -> Result<RuleFrontmatter> {
1012 let Some((frontmatter, _body)) = split_frontmatter(contents) else {
1013 return Ok(RuleFrontmatter::default());
1014 };
1015
1016 serde_saphyr::from_str(frontmatter)
1017 .with_context(|| format!("Failed to parse YAML frontmatter for instruction rule {}", path.display()))
1018}
1019
1020fn strip_rule_frontmatter(contents: &str) -> String {
1021 split_frontmatter(contents)
1022 .map(|(_, body)| body.to_string())
1023 .unwrap_or_else(|| contents.to_string())
1024}
1025
1026fn split_frontmatter(contents: &str) -> Option<(&str, &str)> {
1027 let mut lines = contents.split_inclusive('\n');
1028 let first = lines.next()?;
1029 if first.trim_end() != "---" {
1030 return None;
1031 }
1032
1033 let mut offset = first.len();
1034 for line in lines {
1035 let trimmed = line.trim_end();
1036 if trimmed == "---" || trimmed == "..." {
1037 let body_start = offset + line.len();
1038 let frontmatter = &contents[first.len()..offset];
1039 let body = contents.get(body_start..).unwrap_or_default();
1040 return Some((frontmatter, body));
1041 }
1042 offset += line.len();
1043 }
1044
1045 None
1046}
1047
1048fn pattern_matches_candidate(pattern: &Pattern, candidate: &MatchCandidate) -> bool {
1049 if pattern_matches_path(pattern, candidate) {
1050 return true;
1051 }
1052
1053 zero_directory_pattern_variants(pattern.as_str()).into_iter().any(|variant| {
1054 Pattern::new(&variant)
1055 .ok()
1056 .is_some_and(|variant_pattern| pattern_matches_path(&variant_pattern, candidate))
1057 })
1058}
1059
1060fn pattern_matches_path(pattern: &Pattern, candidate: &MatchCandidate) -> bool {
1061 let path = Path::new(candidate.relative_path.as_str());
1062 if pattern.matches_path(path) || pattern.matches(candidate.relative_path.as_str()) {
1063 return true;
1064 }
1065
1066 if candidate.is_dir {
1067 let probe_path = path.join(IMPORT_PROBE_NAME);
1068 return pattern.matches_path(&probe_path);
1069 }
1070
1071 false
1072}
1073
1074fn zero_directory_pattern_variants(pattern: &str) -> Vec<String> {
1075 let mut variants = Vec::new();
1076 let mut seen = HashSet::new();
1077 collect_zero_directory_variants(pattern, &mut seen, &mut variants);
1078 variants
1079}
1080
1081fn collect_zero_directory_variants(pattern: &str, seen: &mut HashSet<String>, variants: &mut Vec<String>) {
1082 let mut search_start = 0usize;
1083 while let Some(relative_index) = pattern[search_start..].find("**/") {
1084 let index = search_start + relative_index;
1085 let variant = format!("{}{}", &pattern[..index], &pattern[index + 3..]);
1086 if seen.insert(variant.clone()) {
1087 variants.push(variant.clone());
1088 collect_zero_directory_variants(&variant, seen, variants);
1089 }
1090 search_start = index + 3;
1091 }
1092}
1093
1094fn rule_specificity(pattern: &str) -> usize {
1095 pattern
1096 .chars()
1097 .filter(|ch| !matches!(ch, '*' | '?' | '[' | ']' | '{' | '}' | ','))
1098 .count()
1099}
1100
1101fn contains_glob_meta(pattern: &str) -> bool {
1102 pattern.chars().any(|ch| matches!(ch, '*' | '?' | '[' | ']' | '{' | '}'))
1103}
1104
1105fn strip_html_comments(contents: &str) -> String {
1106 let mut output = String::with_capacity(contents.len());
1107 let mut in_code_block = false;
1108 let mut in_comment = false;
1109
1110 for line in contents.lines() {
1111 let trimmed = line.trim_start();
1112 if !in_comment && is_fence_line(trimmed) {
1113 in_code_block = !in_code_block;
1114 output.push_str(line);
1115 output.push('\n');
1116 continue;
1117 }
1118
1119 if in_code_block {
1120 output.push_str(line);
1121 output.push('\n');
1122 continue;
1123 }
1124
1125 let mut cursor = line;
1126 let mut rendered = String::new();
1127 loop {
1128 if in_comment {
1129 if let Some(end) = cursor.find("-->") {
1130 cursor = &cursor[end + 3..];
1131 in_comment = false;
1132 continue;
1133 }
1134 cursor = "";
1135 break;
1136 }
1137
1138 let Some(start) = cursor.find("<!--") else {
1139 rendered.push_str(cursor);
1140 cursor = "";
1141 break;
1142 };
1143
1144 rendered.push_str(&cursor[..start]);
1145 cursor = &cursor[start + 4..];
1146 if let Some(end) = cursor.find("-->") {
1147 cursor = &cursor[end + 3..];
1148 continue;
1149 }
1150
1151 in_comment = true;
1152 cursor = "";
1153 break;
1154 }
1155
1156 if !rendered.is_empty() || !cursor.is_empty() {
1157 output.push_str(rendered.trim_end_matches('\r'));
1158 }
1159 output.push('\n');
1160 }
1161
1162 output
1163}
1164
1165fn is_fence_line(line: &str) -> bool {
1166 let trimmed = line.trim();
1167 trimmed.starts_with("```") || trimmed.starts_with("~~~")
1168}
1169
1170fn collect_imports(contents: &str) -> Vec<String> {
1171 let mut imports = Vec::new();
1172 let mut in_code_block = false;
1173
1174 for line in contents.lines() {
1175 let trimmed = line.trim_start();
1176 if is_fence_line(trimmed) {
1177 in_code_block = !in_code_block;
1178 continue;
1179 }
1180 if in_code_block {
1181 continue;
1182 }
1183
1184 for token in line.split_whitespace() {
1185 let Some(candidate) = token.strip_prefix('@') else {
1186 continue;
1187 };
1188 let trimmed =
1189 candidate.trim_matches(|ch: char| matches!(ch, ')' | '(' | '[' | ']' | '{' | '}' | ',' | ';' | ':'));
1190 let trimmed = trimmed.trim_end_matches('.');
1191 if trimmed.is_empty() {
1192 continue;
1193 }
1194 imports.push(trimmed.to_string());
1195 }
1196 }
1197
1198 imports
1199}
1200
1201fn resolve_import_path(import: &str, containing_file: &Path, allowed_roots: &[PathBuf]) -> Result<Option<PathBuf>> {
1202 let parent = containing_file
1203 .parent()
1204 .ok_or_else(|| anyhow!("Instruction file {} has no parent", containing_file.display()))?;
1205 let home_dir = dirs::home_dir();
1206
1207 let candidate = if let Some(stripped) = import.strip_prefix("~/") {
1208 let Some(home_dir) = home_dir else {
1209 warn!("Skipping instruction import `@{import}` because the home directory is unavailable");
1210 return Ok(None);
1211 };
1212 home_dir.join(stripped)
1213 } else {
1214 let import_path = Path::new(import);
1215 if import_path.is_absolute() {
1216 import_path.to_path_buf()
1217 } else {
1218 parent.join(import_path)
1219 }
1220 };
1221
1222 let canonical = match canonicalize(&candidate) {
1223 Ok(path) => path,
1224 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1225 warn!("Skipping missing instruction import `@{}` referenced from {}", import, containing_file.display());
1226 return Ok(None);
1227 }
1228 Err(err) => {
1229 return Err(err).with_context(|| {
1230 format!("Failed to resolve instruction import `@{}` from {}", import, containing_file.display())
1231 });
1232 }
1233 };
1234
1235 let allowed = allowed_roots.iter().any(|root| canonical.starts_with(root));
1236 if !allowed {
1237 warn!("Skipping instruction import `{}` because it is outside the allowed roots", canonical.display());
1238 return Ok(None);
1239 }
1240
1241 Ok(Some(canonical))
1242}
1243
1244async fn allowed_import_roots(project_root: &Path, home_dir: Option<&Path>) -> Result<Vec<PathBuf>> {
1245 let mut roots = Vec::new();
1246 roots.push(
1247 canonicalize_async(project_root)
1248 .await
1249 .with_context(|| format!("Failed to canonicalize project root import root {}", project_root.display()))?,
1250 );
1251
1252 if let Some(home) = home_dir {
1253 roots.push(
1254 canonicalize_async(home)
1255 .await
1256 .with_context(|| format!("Failed to canonicalize home import root {}", home.display()))?,
1257 );
1258
1259 for user_root in user_global_instruction_roots(home) {
1260 if tokio::fs::try_exists(&user_root).await.unwrap_or(false) {
1261 roots.push(canonicalize_async(&user_root).await.with_context(|| {
1262 format!("Failed to canonicalize VT Code user import root {}", user_root.display())
1263 })?);
1264 }
1265 }
1266 }
1267
1268 roots.sort();
1269 roots.dedup();
1270 Ok(roots)
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275 use super::*;
1276 use tempfile::tempdir;
1277
1278 fn write_doc(dir: &Path, content: &str) -> Result<PathBuf> {
1279 std::fs::create_dir_all(dir)?;
1280 let path = dir.join("AGENTS.md");
1281 std::fs::write(&path, content)?;
1282 Ok(path)
1283 }
1284
1285 fn write_claude_doc(dir: &Path, content: &str) -> Result<PathBuf> {
1286 std::fs::create_dir_all(dir)?;
1287 let path = dir.join("CLAUDE.md");
1288 std::fs::write(&path, content)?;
1289 Ok(path)
1290 }
1291
1292 fn write_rule(dir: &Path, name: &str, content: &str) -> Result<PathBuf> {
1293 std::fs::create_dir_all(dir)?;
1294 let path = dir.join(name);
1295 std::fs::write(&path, content)?;
1296 Ok(path)
1297 }
1298
1299 fn default_options<'a>(
1300 current_dir: &'a Path,
1301 project_root: &'a Path,
1302 home_dir: Option<&'a Path>,
1303 match_paths: &'a [PathBuf],
1304 ) -> InstructionDiscoveryOptions<'a> {
1305 InstructionDiscoveryOptions {
1306 current_dir,
1307 project_root,
1308 home_dir,
1309 extra_patterns: &[],
1310 fallback_filenames: &[],
1311 exclude_patterns: &[],
1312 match_paths,
1313 import_max_depth: 5,
1314 }
1315 }
1316
1317 #[tokio::test]
1318 async fn discovers_user_workspace_and_rule_sources_in_priority_order() -> Result<()> {
1319 let workspace = tempdir()?;
1320 let project_root = workspace.path();
1321 let nested = project_root.join("src/app");
1322 std::fs::create_dir_all(&nested)?;
1323
1324 let home = tempdir()?;
1325 write_doc(&home.path().join(".vtcode"), "user agents")?;
1326 write_rule(&home.path().join(".vtcode/rules"), "shared.md", "# Shared\n- user shared rule\n")?;
1327 write_rule(
1328 &home.path().join(".vtcode/rules"),
1329 "matched.md",
1330 "---\npaths:\n - \"src/**/*.rs\"\n---\n# Matched\n- user matched rule\n",
1331 )?;
1332
1333 write_doc(project_root, "root agents")?;
1334 write_doc(&project_root.join("src"), "nested agents")?;
1335 write_rule(&project_root.join(".vtcode/rules"), "workspace.md", "# Workspace\n- workspace rule\n")?;
1336 write_rule(
1337 &project_root.join(".vtcode/rules"),
1338 "workspace-matched.md",
1339 "---\npaths:\n - \"src/**/*.rs\"\n---\n# Workspace Matched\n- workspace matched rule\n",
1340 )?;
1341
1342 let match_paths = vec![project_root.join("src/main.rs")];
1343 let sources =
1344 discover_instruction_sources(&default_options(&nested, project_root, Some(home.path()), &match_paths))
1345 .await?;
1346
1347 let labels = sources.iter().map(instruction_source_label).collect::<Vec<_>>();
1348
1349 assert_eq!(
1350 labels,
1351 vec![
1352 "user AGENTS",
1353 "user rule",
1354 "user matched rule",
1355 "workspace AGENTS",
1356 "workspace AGENTS",
1357 "workspace rule",
1358 "workspace matched rule",
1359 ]
1360 );
1361
1362 Ok(())
1363 }
1364
1365 #[tokio::test]
1366 async fn discovers_vtcode_home_agents_without_extra_instruction_files() -> Result<()> {
1367 let workspace = tempdir()?;
1368 let project_root = workspace.path();
1369
1370 let home = tempdir()?;
1371 let user_agents_path = write_doc(&home.path().join(".vtcode"), "user agents")?;
1372
1373 let sources =
1374 discover_instruction_sources(&default_options(project_root, project_root, Some(home.path()), &[])).await?;
1375
1376 assert_eq!(sources.len(), 1);
1377 assert_eq!(sources[0].path, canonicalize(user_agents_path)?);
1378 assert_eq!(sources[0].scope, InstructionScope::User);
1379 assert_eq!(sources[0].kind, InstructionSourceKind::Agents);
1380
1381 Ok(())
1382 }
1383
1384 #[tokio::test]
1385 async fn discovers_claude_files_alongside_workspace_agents() -> Result<()> {
1386 let workspace = tempdir()?;
1387 let project_root = canonicalize(workspace.path())?;
1388 let nested = project_root.join("src");
1389 std::fs::create_dir_all(&nested)?;
1390
1391 write_doc(&project_root, "root agents")?;
1392 write_claude_doc(&project_root, "root claude")?;
1393 write_doc(&nested, "nested agents")?;
1394 write_claude_doc(&nested, "nested claude")?;
1395
1396 let sources = discover_instruction_sources(&default_options(&nested, &project_root, None, &[])).await?;
1397 let workspace_paths = sources
1398 .iter()
1399 .filter(|source| matches!(source.scope, InstructionScope::Workspace))
1400 .map(|source| format_instruction_path(&source.path, &project_root, None))
1401 .collect::<Vec<_>>();
1402
1403 assert_eq!(workspace_paths, vec!["AGENTS.md", "CLAUDE.md", "src/AGENTS.md", "src/CLAUDE.md"]);
1404
1405 Ok(())
1406 }
1407
1408 #[tokio::test]
1409 async fn expands_imports_strips_comments_and_matches_rule_paths() -> Result<()> {
1410 let workspace = tempdir()?;
1411 let project_root = workspace.path();
1412 let docs_dir = project_root.join("docs");
1413 std::fs::create_dir_all(&docs_dir)?;
1414 std::fs::write(docs_dir.join("shared.md"), "# Shared\n- imported detail\n")?;
1415
1416 write_doc(project_root, "# Root\n<!-- hidden -->\n- visible\n\nSee @docs/shared.md\n- trailing detail\n")?;
1417 write_rule(
1418 &project_root.join(".vtcode/rules"),
1419 "rust.md",
1420 "---\npaths:\n - \"src/**/*.rs\"\n---\n# Rust\n- rust rule\n",
1421 )?;
1422
1423 let match_paths = vec![project_root.join("src/lib.rs")];
1424 let bundle =
1425 read_instruction_bundle(&default_options(project_root, project_root, None, &match_paths), 16 * 1024)
1426 .await?
1427 .expect("instruction bundle");
1428
1429 assert_eq!(bundle.segments.len(), 2);
1430 let combined = bundle.combined_text();
1431 assert!(combined.contains("- visible"));
1432 assert!(combined.contains("imported detail"));
1433 assert!(combined.contains("- rust rule"));
1434 assert!(!combined.contains("hidden"));
1435 assert!(combined.find("See @docs/shared.md") < combined.find("imported detail"));
1436 assert!(combined.find("imported detail") < combined.find("- trailing detail"));
1437
1438 Ok(())
1439 }
1440
1441 #[tokio::test]
1442 async fn excludes_instruction_paths_with_globs() -> Result<()> {
1443 let workspace = tempdir()?;
1444 let project_root = workspace.path();
1445 let nested = project_root.join("src");
1446 std::fs::create_dir_all(&nested)?;
1447 write_doc(project_root, "root agents")?;
1448 write_doc(&nested, "nested agents")?;
1449
1450 let exclude = vec![project_root.join("src/AGENTS.md").display().to_string()];
1451 let options = InstructionDiscoveryOptions {
1452 exclude_patterns: &exclude,
1453 ..default_options(&nested, project_root, None, &[])
1454 };
1455 let sources = discover_instruction_sources(&options).await?;
1456
1457 assert_eq!(sources.len(), 1);
1458 assert_eq!(sources[0].path.file_name().and_then(|value| value.to_str()).unwrap_or_default(), "AGENTS.md");
1459
1460 Ok(())
1461 }
1462
1463 #[tokio::test]
1464 async fn ignores_rules_readme_files() -> Result<()> {
1465 let workspace = tempdir()?;
1466 let project_root = workspace.path();
1467 std::fs::create_dir_all(project_root.join(".vtcode/rules"))?;
1468 write_rule(&project_root.join(".vtcode/rules"), "README.md", "# Rules\n- should stay out of prompt memory\n")?;
1469 write_rule(&project_root.join(".vtcode/rules"), "rust.md", "# Rust\n- keep changes surgical\n")?;
1470
1471 let sources = discover_instruction_sources(&default_options(project_root, project_root, None, &[])).await?;
1472
1473 assert_eq!(sources.len(), 1);
1474 assert!(
1475 sources[0].path.ends_with("rust.md"),
1476 "sources: {:?}",
1477 sources
1478 .iter()
1479 .map(|source| source.path.display().to_string())
1480 .collect::<Vec<_>>()
1481 );
1482 Ok(())
1483 }
1484
1485 #[test]
1486 fn highlight_fallback_uses_non_bullet_content() {
1487 let segments = vec![InstructionSegment {
1488 source: InstructionSource {
1489 path: PathBuf::from("AGENTS.md"),
1490 scope: InstructionScope::Workspace,
1491 kind: InstructionSourceKind::Agents,
1492 matched: false,
1493 },
1494 contents: "Root summary\n".to_owned(),
1495 }];
1496
1497 let highlights = extract_instruction_highlights(&segments, 1);
1498 assert_eq!(highlights, vec!["Root summary".to_owned()]);
1499 }
1500}