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 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 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 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}