Skip to main content

vtcode_core/
project_doc.rs

1use std::fmt::Write as _;
2use std::future::Future;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use serde::Serialize;
7
8use crate::instructions::{
9    InstructionBundle, InstructionDiscoveryOptions, InstructionSegment,
10    extract_instruction_highlights, read_instruction_bundle, render_instruction_summary_markdown,
11};
12use crate::persistent_memory::{
13    MEMORY_FILENAME, MEMORY_SUMMARY_FILENAME, PersistentMemoryExcerpt, extract_memory_highlights,
14    read_persistent_memory_excerpt,
15};
16use crate::skills::model::SkillMetadata;
17use crate::utils::file_utils::canonicalize_with_context;
18use vtcode_config::core::AgentConfig;
19
20pub const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
21pub const PERSISTENT_MEMORY_SEPARATOR: &str = "\n\n--- persistent-memory ---\n\n";
22const PROJECT_DOC_SUMMARY_TITLE: &str = "PROJECT DOCUMENTATION";
23const PROJECT_DOC_TRUNCATION_NOTE: &str = "Some instruction files exceeded the configured prompt budget and were indexed instead of fully inlined.";
24const PERSISTENT_MEMORY_TRUNCATION_NOTE: &str =
25    "Persistent memory was truncated to the configured startup excerpt budget.";
26const PERSISTENT_MEMORY_HIGHLIGHT_LIMIT: usize = 3;
27
28#[derive(Debug, Clone, Serialize)]
29pub struct ProjectDocBundle {
30    pub contents: String,
31    pub sources: Vec<PathBuf>,
32    pub segments: Vec<InstructionSegment>,
33    pub truncated: bool,
34    pub bytes_read: usize,
35}
36
37impl ProjectDocBundle {
38    pub fn highlights(&self, limit: usize) -> Vec<String> {
39        extract_instruction_highlights(&self.segments, limit)
40    }
41}
42
43pub struct ProjectDocOptions<'a> {
44    pub current_dir: &'a Path,
45    pub project_root: &'a Path,
46    pub home_dir: Option<&'a Path>,
47    pub extra_instruction_files: &'a [String],
48    pub fallback_filenames: &'a [String],
49    pub exclude_patterns: &'a [String],
50    pub match_paths: &'a [PathBuf],
51    pub import_max_depth: usize,
52    pub max_bytes: usize,
53}
54
55#[derive(Debug, Clone, Serialize)]
56pub struct InstructionAppendixBundle {
57    pub contents: String,
58    pub project_doc: Option<ProjectDocBundle>,
59    pub persistent_memory: Option<PersistentMemoryExcerpt>,
60    pub project_root: PathBuf,
61    pub home_dir: Option<PathBuf>,
62}
63
64pub async fn read_project_doc_with_options(
65    options: &ProjectDocOptions<'_>,
66) -> Result<Option<ProjectDocBundle>> {
67    if options.max_bytes == 0 {
68        return Ok(None);
69    }
70
71    match read_instruction_bundle(
72        &InstructionDiscoveryOptions {
73            current_dir: options.current_dir,
74            project_root: options.project_root,
75            home_dir: options.home_dir,
76            extra_patterns: options.extra_instruction_files,
77            fallback_filenames: options.fallback_filenames,
78            exclude_patterns: options.exclude_patterns,
79            match_paths: options.match_paths,
80            import_max_depth: options.import_max_depth,
81        },
82        options.max_bytes,
83    )
84    .await?
85    {
86        Some(bundle) => Ok(Some(convert_bundle(bundle))),
87        None => Ok(None),
88    }
89}
90
91pub async fn read_project_doc(cwd: &Path, max_bytes: usize) -> Result<Option<ProjectDocBundle>> {
92    if max_bytes == 0 {
93        return Ok(None);
94    }
95
96    let project_root = resolve_project_root(cwd).unwrap_or_else(|_| cwd.to_path_buf());
97    let home_dir = dirs::home_dir();
98
99    read_project_doc_with_options(&ProjectDocOptions {
100        current_dir: cwd,
101        project_root: &project_root,
102        home_dir: home_dir.as_deref(),
103        extra_instruction_files: &[],
104        fallback_filenames: &[],
105        exclude_patterns: &[],
106        match_paths: &[],
107        import_max_depth: 5,
108        max_bytes,
109    })
110    .await
111}
112
113pub fn get_user_instructions<'a>(
114    config: &'a AgentConfig,
115    active_dir: &'a Path,
116    _skills: Option<&'a [SkillMetadata]>,
117) -> impl Future<Output = Option<String>> + 'a {
118    build_instruction_appendix(config, active_dir)
119}
120
121pub fn build_instruction_appendix<'a>(
122    config: &'a AgentConfig,
123    active_dir: &'a Path,
124) -> impl Future<Output = Option<String>> + 'a {
125    build_instruction_appendix_with_context(config, active_dir, &[])
126}
127
128pub async fn build_instruction_appendix_with_context(
129    config: &AgentConfig,
130    active_dir: &Path,
131    match_paths: &[PathBuf],
132) -> Option<String> {
133    load_instruction_appendix(config, active_dir, match_paths)
134        .await
135        .map(|bundle| bundle.contents)
136}
137
138pub async fn load_instruction_appendix(
139    config: &AgentConfig,
140    active_dir: &Path,
141    match_paths: &[PathBuf],
142) -> Option<InstructionAppendixBundle> {
143    let project_root =
144        resolve_project_root(active_dir).unwrap_or_else(|_| active_dir.to_path_buf());
145    let home_dir = dirs::home_dir();
146    let bundle = read_project_doc_with_options(&ProjectDocOptions {
147        current_dir: active_dir,
148        project_root: &project_root,
149        home_dir: home_dir.as_deref(),
150        extra_instruction_files: &config.instruction_files,
151        fallback_filenames: &config.project_doc_fallback_filenames,
152        exclude_patterns: &config.instruction_excludes,
153        match_paths,
154        import_max_depth: config.instruction_import_max_depth,
155        max_bytes: config.instruction_max_bytes,
156    })
157    .await
158    .ok()
159    .flatten();
160    let persistent_memory =
161        read_persistent_memory_excerpt(&config.persistent_memory, &project_root)
162            .await
163            .ok()
164            .flatten();
165
166    let contents = render_instruction_appendix(
167        config.user_instructions.as_deref(),
168        bundle.as_ref(),
169        persistent_memory.as_ref(),
170        &project_root,
171        home_dir.as_deref(),
172    )?;
173
174    Some(InstructionAppendixBundle {
175        contents,
176        project_doc: bundle,
177        persistent_memory,
178        project_root,
179        home_dir,
180    })
181}
182
183pub fn render_instruction_appendix(
184    user_instructions: Option<&str>,
185    bundle: Option<&ProjectDocBundle>,
186    persistent_memory: Option<&PersistentMemoryExcerpt>,
187    project_root: &Path,
188    home_dir: Option<&Path>,
189) -> Option<String> {
190    let mut section = String::with_capacity(1024);
191
192    if let Some(user_inst) = user_instructions.map(str::trim)
193        && !user_inst.is_empty()
194    {
195        section.push_str(user_inst);
196    }
197
198    if let Some(bundle) = bundle
199        && !bundle.segments.is_empty()
200    {
201        if !section.is_empty() {
202            section.push_str(PROJECT_DOC_SEPARATOR);
203        }
204
205        section.push_str(
206            render_instruction_summary_markdown(
207                PROJECT_DOC_SUMMARY_TITLE,
208                &bundle.segments,
209                bundle.truncated,
210                project_root,
211                home_dir,
212                6,
213                PROJECT_DOC_TRUNCATION_NOTE,
214            )
215            .trim_end(),
216        );
217    }
218
219    if let Some(memory_section) =
220        persistent_memory.and_then(render_persistent_memory_summary_markdown)
221    {
222        if !section.is_empty() {
223            section.push_str(PERSISTENT_MEMORY_SEPARATOR);
224        }
225
226        section.push_str(memory_section.trim_end());
227    }
228
229    if section.is_empty() {
230        None
231    } else {
232        Some(section)
233    }
234}
235
236fn render_persistent_memory_summary_markdown(memory: &PersistentMemoryExcerpt) -> Option<String> {
237    let highlights = extract_memory_highlights(&memory.contents, PERSISTENT_MEMORY_HIGHLIGHT_LIMIT);
238    if highlights.is_empty() && memory.contents.trim().is_empty() {
239        return None;
240    }
241
242    let mut section = String::with_capacity(512);
243    section.push_str("## PERSISTENT MEMORY\n\n");
244    section.push_str("### Files\n");
245    let _ = writeln!(section, "- `{MEMORY_SUMMARY_FILENAME}`: startup summary");
246    let _ = writeln!(section, "- `{MEMORY_FILENAME}`: durable registry");
247
248    if !highlights.is_empty() {
249        section.push_str("\n### Key points\n");
250        for highlight in highlights {
251            let _ = writeln!(section, "- {highlight}");
252        }
253    }
254
255    section.push_str(
256        "\n### On-demand loading\n- Open `memory_summary.md` or `MEMORY.md` when exact wording matters.\n",
257    );
258
259    if memory.truncated {
260        let _ = writeln!(section, "\n_{PERSISTENT_MEMORY_TRUNCATION_NOTE}_");
261    }
262
263    section.push('\n');
264    Some(section)
265}
266
267pub fn merge_project_docs_with_skills(
268    project_doc: Option<String>,
269    skills_section: Option<String>,
270) -> Option<String> {
271    match (project_doc, skills_section) {
272        (Some(doc), Some(skills)) => Some(format!("{}\n\n{}", doc, skills)),
273        (Some(doc), None) => Some(doc),
274        (None, Some(skills)) => Some(skills),
275        (None, None) => None,
276    }
277}
278
279fn convert_bundle(bundle: InstructionBundle) -> ProjectDocBundle {
280    let contents = bundle.combined_text();
281    let segments = bundle.segments;
282    let sources = segments
283        .iter()
284        .map(|segment| segment.source.path.clone())
285        .collect::<Vec<_>>();
286
287    ProjectDocBundle {
288        contents,
289        sources,
290        segments,
291        truncated: bundle.truncated,
292        bytes_read: bundle.bytes_read,
293    }
294}
295
296fn resolve_project_root(cwd: &Path) -> Result<PathBuf> {
297    let mut cursor = canonicalize_with_context(cwd, "working directory")?;
298
299    loop {
300        let git_marker = cursor.join(".git");
301        match std::fs::metadata(&git_marker) {
302            Ok(_) => return Ok(cursor),
303            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
304            Err(err) => {
305                return Err(err).with_context(|| {
306                    format!(
307                        "Failed to inspect potential git root {}",
308                        git_marker.display()
309                    )
310                });
311            }
312        }
313
314        match cursor.parent() {
315            Some(parent) => {
316                cursor = parent.to_path_buf();
317            }
318            None => return Ok(cursor),
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::instructions::{InstructionScope, InstructionSource, InstructionSourceKind};
327    use tempfile::tempdir;
328
329    fn write_doc(dir: &Path, content: &str) -> Result<()> {
330        std::fs::write(dir.join("AGENTS.md"), content).context("write AGENTS.md")?;
331        Ok(())
332    }
333
334    #[tokio::test]
335    async fn returns_none_when_no_docs_present() {
336        let tmp = tempdir().expect("failed to unwrap");
337        let result = read_project_doc(tmp.path(), 4096)
338            .await
339            .expect("failed to unwrap");
340        assert!(result.is_none());
341    }
342
343    #[tokio::test]
344    async fn reads_doc_within_limit() {
345        let tmp = tempdir().expect("failed to unwrap");
346        write_doc(tmp.path(), "hello world").expect("write doc");
347
348        let result = read_project_doc(tmp.path(), 4096)
349            .await
350            .expect("failed to unwrap")
351            .expect("failed to unwrap");
352        assert_eq!(result.contents, "hello world");
353        assert_eq!(result.bytes_read, "hello world".len());
354    }
355
356    #[tokio::test]
357    async fn truncates_when_limit_exceeded() {
358        let tmp = tempdir().expect("failed to unwrap");
359        let content = "A".repeat(64);
360        write_doc(tmp.path(), &content).expect("write doc");
361
362        let result = read_project_doc(tmp.path(), 16)
363            .await
364            .expect("failed to unwrap")
365            .expect("failed to unwrap");
366        assert!(result.truncated);
367        assert_eq!(result.contents.len(), 16);
368    }
369
370    #[tokio::test]
371    async fn reads_docs_from_repo_root_downwards() {
372        let repo = tempdir().expect("failed to unwrap");
373        std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("failed to unwrap");
374        write_doc(repo.path(), "root doc").expect("write doc");
375
376        let nested = repo.path().join("nested/sub");
377        std::fs::create_dir_all(&nested).expect("failed to unwrap");
378        write_doc(&nested, "nested doc").expect("write doc");
379
380        let bundle = read_project_doc_with_options(&ProjectDocOptions {
381            current_dir: &nested,
382            project_root: repo.path(),
383            home_dir: None,
384            extra_instruction_files: &[],
385            fallback_filenames: &[],
386            exclude_patterns: &[],
387            match_paths: &[],
388            import_max_depth: 5,
389            max_bytes: 4096,
390        })
391        .await
392        .expect("failed to unwrap")
393        .expect("failed to unwrap");
394        assert!(bundle.contents.contains("root doc"));
395        assert!(bundle.contents.contains("nested doc"));
396        assert_eq!(bundle.sources.len(), 2);
397    }
398
399    #[tokio::test]
400    async fn instruction_appendix_uses_instruction_hierarchy_scope_and_budget() {
401        let repo = tempdir().expect("repo");
402        std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("write git");
403        write_doc(repo.path(), "root doc").expect("write root doc");
404
405        let nested = repo.path().join("nested/sub");
406        std::fs::create_dir_all(&nested).expect("create nested");
407        write_doc(&nested, "nested doc").expect("write nested doc");
408
409        let extra_dir = repo.path().join("docs");
410        std::fs::create_dir_all(&extra_dir).expect("create docs");
411        std::fs::write(extra_dir.join("guidelines.md"), "extra doc").expect("write extra doc");
412
413        let config = AgentConfig {
414            user_instructions: Some("user note".to_string()),
415            instruction_files: vec!["docs/*.md".to_string()],
416            instruction_max_bytes: 4096,
417            project_doc_max_bytes: 1,
418            ..Default::default()
419        };
420
421        let appendix = build_instruction_appendix_with_context(
422            &config,
423            &nested,
424            &[repo.path().join("nested/sub/file.rs")],
425        )
426        .await
427        .expect("instruction appendix");
428
429        assert!(appendix.starts_with("user note"));
430        assert!(appendix.contains("--- project-doc ---"));
431        assert!(appendix.contains("### Instruction map"));
432        assert!(appendix.contains("AGENTS.md (workspace AGENTS)"));
433        assert!(appendix.contains("docs/guidelines.md (custom extra instructions)"));
434        assert!(appendix.contains("nested/sub/AGENTS.md (workspace AGENTS)"));
435        assert!(appendix.contains("root doc"));
436        assert!(appendix.contains("extra doc"));
437        assert!(appendix.contains("nested doc"));
438    }
439
440    #[tokio::test]
441    async fn instruction_appendix_returns_none_when_empty() {
442        let tmp = tempdir().expect("tmp");
443        let appendix = build_instruction_appendix(&AgentConfig::default(), tmp.path()).await;
444        assert!(appendix.is_none());
445    }
446
447    #[tokio::test]
448    async fn instruction_appendix_marks_truncation() {
449        let repo = tempdir().expect("repo");
450        std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("write git");
451        write_doc(
452            repo.path(),
453            "- Root summary\n\nThis detail should stay out of the prompt appendix.\n",
454        )
455        .expect("write doc");
456
457        let config = AgentConfig {
458            instruction_max_bytes: 16,
459            ..Default::default()
460        };
461
462        let appendix = build_instruction_appendix(&config, repo.path())
463            .await
464            .expect("instruction appendix");
465
466        assert!(appendix.contains("## PROJECT DOCUMENTATION"));
467        assert!(appendix.contains("### Instruction map"));
468        assert!(appendix.contains("### On-demand loading"));
469        assert!(appendix.contains("Some instruction files exceeded the configured prompt budget"));
470    }
471
472    #[tokio::test]
473    async fn includes_extra_instruction_files() {
474        let repo = tempdir().expect("failed to unwrap");
475        write_doc(repo.path(), "root doc").expect("write doc");
476        let docs = repo.path().join("docs");
477        std::fs::create_dir_all(&docs).expect("failed to unwrap");
478        let extra = docs.join("guidelines.md");
479        std::fs::write(&extra, "extra doc").expect("failed to unwrap");
480
481        let bundle = read_project_doc_with_options(&ProjectDocOptions {
482            current_dir: repo.path(),
483            project_root: repo.path(),
484            home_dir: None,
485            extra_instruction_files: &["docs/*.md".to_owned()],
486            fallback_filenames: &[],
487            exclude_patterns: &[],
488            match_paths: &[],
489            import_max_depth: 5,
490            max_bytes: 4096,
491        })
492        .await
493        .expect("failed to unwrap")
494        .expect("failed to unwrap");
495
496        assert!(bundle.contents.contains("root doc"));
497        assert!(bundle.contents.contains("extra doc"));
498        assert_eq!(bundle.sources.len(), 2);
499    }
500
501    #[test]
502    fn highlights_extract_bullets() {
503        let bundle = ProjectDocBundle {
504            contents: "- First\n- Second\n".to_owned(),
505            sources: Vec::new(),
506            segments: vec![InstructionSegment {
507                source: InstructionSource {
508                    path: PathBuf::from("AGENTS.md"),
509                    scope: InstructionScope::Workspace,
510                    kind: InstructionSourceKind::Agents,
511                    matched: false,
512                },
513                contents: "- First\n- Second\n".to_owned(),
514            }],
515            truncated: false,
516            bytes_read: 0,
517        };
518        let highlights = bundle.highlights(1);
519        assert_eq!(highlights, vec!["First".to_owned()]);
520    }
521
522    #[tokio::test]
523    async fn renders_compact_instruction_appendix() {
524        let repo = tempdir().expect("failed to unwrap");
525        std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("failed to unwrap");
526        write_doc(
527            repo.path(),
528            "- Root summary\n\nFollow the repository-level guidance first.\n",
529        )
530        .expect("write doc");
531
532        let nested = repo.path().join("nested/sub");
533        std::fs::create_dir_all(&nested).expect("failed to unwrap");
534        write_doc(
535            &nested,
536            "- Nested summary\n\nFollow the nested guidance last.\n",
537        )
538        .expect("write doc");
539
540        let instructions = get_user_instructions(&AgentConfig::default(), &nested, None)
541            .await
542            .expect("expected instructions");
543
544        assert!(instructions.contains("### Instruction map"));
545        assert!(instructions.contains("AGENTS.md (workspace AGENTS)"));
546        assert!(instructions.contains("nested/sub/AGENTS.md (workspace AGENTS)"));
547        assert!(instructions.contains("Root summary"));
548        assert!(instructions.contains("Nested summary"));
549        assert!(instructions.contains("### Key points"));
550        assert!(instructions.contains("### On-demand loading"));
551    }
552
553    #[tokio::test]
554    async fn instruction_appendix_includes_persistent_memory_after_authored_guidance() {
555        let repo = tempdir().expect("repo");
556        std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("git marker");
557        std::fs::write(repo.path().join(".vtcode-project"), "repo").expect("project name");
558        write_doc(repo.path(), "root doc").expect("write root doc");
559
560        let memory_dir = repo.path().join(".memory-root");
561        let config = AgentConfig {
562            persistent_memory: vtcode_config::core::PersistentMemoryConfig {
563                enabled: true,
564                directory_override: Some(memory_dir.display().to_string()),
565                ..Default::default()
566            },
567            ..Default::default()
568        };
569
570        let project_memory_dir = memory_dir.join("projects").join("repo").join("memory");
571        std::fs::create_dir_all(&project_memory_dir).expect("memory dir");
572        std::fs::write(
573            project_memory_dir.join("memory_summary.md"),
574            "# VT Code Memory Summary\n\n- remembered detail\n",
575        )
576        .expect("write memory summary");
577
578        let appendix = build_instruction_appendix(&config, repo.path())
579            .await
580            .expect("instruction appendix");
581
582        let project_doc_idx = appendix.find("root doc").expect("project doc");
583        let memory_idx = appendix.find("remembered detail").expect("memory detail");
584        assert!(project_doc_idx < memory_idx);
585        assert!(appendix.contains("--- persistent-memory ---"));
586        assert!(appendix.contains("### Files"));
587        assert!(appendix.contains("### On-demand loading"));
588        assert!(appendix.contains("memory_summary.md"));
589        assert!(appendix.contains("MEMORY.md"));
590        assert!(!appendix.contains("# VT Code Memory Summary"));
591    }
592
593    #[tokio::test]
594    async fn instruction_appendix_keeps_persistent_memory_compact() {
595        let repo = tempdir().expect("repo");
596        std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("git marker");
597        std::fs::write(repo.path().join(".vtcode-project"), "repo").expect("project name");
598
599        let memory_dir = repo.path().join(".memory-root");
600        let config = AgentConfig {
601            persistent_memory: vtcode_config::core::PersistentMemoryConfig {
602                enabled: true,
603                directory_override: Some(memory_dir.display().to_string()),
604                ..Default::default()
605            },
606            ..Default::default()
607        };
608
609        let project_memory_dir = memory_dir.join("projects").join("repo").join("memory");
610        std::fs::create_dir_all(&project_memory_dir).expect("memory dir");
611        std::fs::write(
612            project_memory_dir.join("memory_summary.md"),
613            "# VT Code Memory Summary\n\n- keep changes surgical\n- run ./scripts/check.sh\n- use cargo nextest for targeted tests\n- prefer docs/ARCHITECTURE.md for orientation\n- extra detail that should stay out of the prompt body\n",
614        )
615        .expect("write memory summary");
616
617        let appendix = build_instruction_appendix(&config, repo.path())
618            .await
619            .expect("instruction appendix");
620        let approx_tokens = appendix.len() / 4;
621
622        assert!(appendix.contains("### Key points"));
623        assert!(appendix.contains("Open `memory_summary.md` or `MEMORY.md`"));
624        assert!(approx_tokens < 120, "got ~{} tokens", approx_tokens);
625    }
626
627    #[tokio::test]
628    async fn instruction_appendix_stays_summary_sized() {
629        let repo = tempdir().expect("repo");
630        std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").expect("git marker");
631        write_doc(
632            repo.path(),
633            "- run ./scripts/check.sh\n- avoid adding to vtcode-core\n- use Conventional Commits\n- start with docs/ARCHITECTURE.md\n",
634        )
635        .expect("write root doc");
636
637        let appendix = build_instruction_appendix(&AgentConfig::default(), repo.path())
638            .await
639            .expect("instruction appendix");
640        let approx_tokens = appendix.len() / 4;
641
642        assert!(appendix.contains("### Instruction map"));
643        assert!(appendix.contains("### Key points"));
644        assert!(appendix.contains("### On-demand loading"));
645        assert!(approx_tokens < 140, "got ~{} tokens", approx_tokens);
646    }
647}