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::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/// Defines the scope from which an instruction source originates.
24#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
25#[serde(tag = "scope", rename_all = "snake_case")]
26pub enum InstructionScope {
27    /// User-level instructions from the canonical config root or a compatibility root.
28    User,
29    /// Workspace-level instructions from the project root or subdirectories.
30    Workspace,
31    /// Custom instructions from explicit glob patterns.
32    Custom,
33}
34
35/// The kind of instruction file discovered on disk.
36#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum InstructionSourceKind {
39    /// An `AGENTS.md` or `CLAUDE.md` file.
40    Agents,
41    /// A rule file from `.vtcode/rules/` with optional path matching.
42    Rule,
43    /// An extra instruction file matched by a custom glob pattern.
44    Extra,
45}
46
47/// A discovered instruction file with its path, scope, and kind metadata.
48#[derive(Debug, Clone, Serialize)]
49pub struct InstructionSource {
50    /// Canonical path to the instruction file on disk.
51    pub path: PathBuf,
52    /// Scope from which this source was discovered.
53    pub scope: InstructionScope,
54    /// Kind of instruction file (agents, rule, or extra).
55    pub kind: InstructionSourceKind,
56    /// Whether a conditional rule matched the current file context.
57    pub matched: bool,
58}
59
60/// A single instruction segment pairing a source with its expanded text content.
61#[derive(Debug, Clone, Serialize)]
62pub struct InstructionSegment {
63    /// Metadata about where this segment was loaded from.
64    pub source: InstructionSource,
65    /// The instruction text after frontmatter stripping and import expansion.
66    pub contents: String,
67}
68
69/// A complete bundle of instruction segments ready for prompt assembly.
70#[derive(Debug, Clone, Serialize)]
71pub struct InstructionBundle {
72    /// Ordered list of instruction segments (lowest to highest precedence).
73    pub segments: Vec<InstructionSegment>,
74    /// Whether the bundle was truncated due to the byte budget.
75    pub truncated: bool,
76    /// Total bytes read from disk across all segments.
77    pub bytes_read: usize,
78}
79
80impl InstructionBundle {
81    /// Returns `true` if the bundle contains no instruction segments.
82    pub fn is_empty(&self) -> bool {
83        self.segments.is_empty()
84    }
85
86    /// Concatenates all segment contents into a single string separated by blank lines.
87    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    /// Extracts up to `limit` key-point highlights from the bundle's segments.
106    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    // Full segment bodies are inlined below, so a "Key points" highlight list
315    // is a pure echo of content the model already receives (AGENTS.md alone
316    // re-printed ~1.2 KB of the first bullets). Skip it. The map and
317    // precedence sentence only pay for themselves with multiple sources.
318    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    // expand_instruction_contents performs blocking filesystem I/O (read_to_string,
534    // canonicalize) for each instruction file and its recursive imports. Offload
535    // the entire expansion loop to the blocking thread pool.
536    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    // Directory walking performs blocking filesystem I/O (readdir, stat, canonicalize).
698    // Offload the traversal to the blocking thread pool so the async runtime
699    // stays responsive while we discover rule files on disk.
700    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    // Process discovered paths asynchronously: read frontmatter and match patterns.
749    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}