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