Skip to main content

vtcode_core/
instructions.rs

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