vtcode_core/
project_doc.rs

1use std::fs::File;
2use std::io::{self, Read};
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use serde::Serialize;
7use tracing::warn;
8
9const DOC_FILENAME: &str = "AGENTS.md";
10pub const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
11
12#[derive(Debug, Clone, Serialize)]
13pub struct ProjectDocBundle {
14    pub contents: String,
15    pub sources: Vec<PathBuf>,
16    pub truncated: bool,
17    pub bytes_read: usize,
18}
19
20impl ProjectDocBundle {
21    pub fn highlights(&self, limit: usize) -> Vec<String> {
22        if limit == 0 {
23            return Vec::new();
24        }
25
26        self.contents
27            .lines()
28            .filter_map(|line| {
29                let trimmed = line.trim();
30                if trimmed.starts_with('-') {
31                    let highlight = trimmed.trim_start_matches('-').trim();
32                    if !highlight.is_empty() {
33                        return Some(highlight.to_string());
34                    }
35                }
36                None
37            })
38            .take(limit)
39            .collect()
40    }
41}
42
43pub fn read_project_doc(cwd: &Path, max_bytes: usize) -> Result<Option<ProjectDocBundle>> {
44    if max_bytes == 0 {
45        return Ok(None);
46    }
47
48    let paths = discover_project_doc_paths(cwd)?;
49    if paths.is_empty() {
50        return Ok(None);
51    }
52
53    let mut remaining = max_bytes;
54    let mut truncated = false;
55    let mut parts: Vec<String> = Vec::new();
56    let mut sources: Vec<PathBuf> = Vec::new();
57    let mut total_bytes = 0usize;
58
59    for path in paths {
60        if remaining == 0 {
61            truncated = true;
62            break;
63        }
64
65        let file = match File::open(&path) {
66            Ok(file) => file,
67            Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
68            Err(err) => {
69                return Err(err).with_context(|| {
70                    format!("Failed to open project documentation at {}", path.display())
71                });
72            }
73        };
74
75        let metadata = file
76            .metadata()
77            .with_context(|| format!("Failed to read metadata for {}", path.display()))?;
78
79        let mut reader = io::BufReader::new(file).take(remaining as u64);
80        let mut data = Vec::new();
81        reader.read_to_end(&mut data).with_context(|| {
82            format!(
83                "Failed to read project documentation from {}",
84                path.display()
85            )
86        })?;
87
88        if metadata.len() as usize > remaining {
89            truncated = true;
90            warn!(
91                "Project doc `{}` exceeds remaining budget ({} bytes) - truncating.",
92                path.display(),
93                remaining
94            );
95        }
96
97        if data.iter().all(|byte| byte.is_ascii_whitespace()) {
98            remaining = remaining.saturating_sub(data.len());
99            continue;
100        }
101
102        let text = String::from_utf8_lossy(&data).to_string();
103        if !text.trim().is_empty() {
104            total_bytes += data.len();
105            remaining = remaining.saturating_sub(data.len());
106            sources.push(path);
107            parts.push(text);
108        }
109    }
110
111    if parts.is_empty() {
112        Ok(None)
113    } else {
114        let contents = parts.join("\n\n");
115        Ok(Some(ProjectDocBundle {
116            contents,
117            sources,
118            truncated,
119            bytes_read: total_bytes,
120        }))
121    }
122}
123
124pub fn discover_project_doc_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
125    let mut dir = cwd.to_path_buf();
126    if let Ok(canonical) = dir.canonicalize() {
127        dir = canonical;
128    }
129
130    let mut chain: Vec<PathBuf> = vec![dir.clone()];
131    let mut git_root: Option<PathBuf> = None;
132    let mut cursor = dir.clone();
133
134    while let Some(parent) = cursor.parent() {
135        let git_marker = cursor.join(".git");
136        match std::fs::metadata(&git_marker) {
137            Ok(_) => {
138                git_root = Some(cursor.clone());
139                break;
140            }
141            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
142            Err(err) => {
143                return Err(err).with_context(|| {
144                    format!(
145                        "Failed to inspect potential git root {}",
146                        git_marker.display()
147                    )
148                });
149            }
150        }
151
152        chain.push(parent.to_path_buf());
153        cursor = parent.to_path_buf();
154    }
155
156    let search_dirs: Vec<PathBuf> = if let Some(root) = git_root {
157        let mut dirs = Vec::new();
158        let mut saw_root = false;
159        for path in chain.iter().rev() {
160            if !saw_root {
161                if path == &root {
162                    saw_root = true;
163                } else {
164                    continue;
165                }
166            }
167            dirs.push(path.clone());
168        }
169        dirs
170    } else {
171        vec![dir]
172    };
173
174    let mut found = Vec::new();
175    for directory in search_dirs {
176        let candidate = directory.join(DOC_FILENAME);
177        match std::fs::symlink_metadata(&candidate) {
178            Ok(metadata) => {
179                let kind = metadata.file_type();
180                if kind.is_file() || kind.is_symlink() {
181                    found.push(candidate);
182                }
183            }
184            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
185            Err(err) => {
186                return Err(err).with_context(|| {
187                    format!(
188                        "Failed to inspect project doc candidate {}",
189                        candidate.display()
190                    )
191                });
192            }
193        }
194    }
195
196    Ok(found)
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use tempfile::TempDir;
203
204    fn write_doc(dir: &Path, content: &str) {
205        std::fs::write(dir.join(DOC_FILENAME), content).unwrap();
206    }
207
208    #[test]
209    fn returns_none_when_no_docs_present() {
210        let tmp = TempDir::new().unwrap();
211        let result = read_project_doc(tmp.path(), 4096).unwrap();
212        assert!(result.is_none());
213    }
214
215    #[test]
216    fn reads_doc_within_limit() {
217        let tmp = TempDir::new().unwrap();
218        write_doc(tmp.path(), "hello world");
219
220        let result = read_project_doc(tmp.path(), 4096).unwrap().unwrap();
221        assert_eq!(result.contents, "hello world");
222        assert_eq!(result.bytes_read, "hello world".len());
223    }
224
225    #[test]
226    fn truncates_when_limit_exceeded() {
227        let tmp = TempDir::new().unwrap();
228        let content = "A".repeat(64);
229        write_doc(tmp.path(), &content);
230
231        let result = read_project_doc(tmp.path(), 16).unwrap().unwrap();
232        assert!(result.truncated);
233        assert_eq!(result.contents.len(), 16);
234    }
235
236    #[test]
237    fn reads_docs_from_repo_root_downwards() {
238        let repo = TempDir::new().unwrap();
239        std::fs::write(repo.path().join(".git"), "gitdir: /tmp/git").unwrap();
240        write_doc(repo.path(), "root doc");
241
242        let nested = repo.path().join("nested/sub");
243        std::fs::create_dir_all(&nested).unwrap();
244        write_doc(&nested, "nested doc");
245
246        let bundle = read_project_doc(&nested, 4096).unwrap().unwrap();
247        assert!(bundle.contents.contains("root doc"));
248        assert!(bundle.contents.contains("nested doc"));
249        assert_eq!(bundle.sources.len(), 2);
250    }
251
252    #[test]
253    fn extracts_highlights() {
254        let bundle = ProjectDocBundle {
255            contents: "- First\n- Second\n".to_string(),
256            sources: Vec::new(),
257            truncated: false,
258            bytes_read: 0,
259        };
260        let highlights = bundle.highlights(1);
261        assert_eq!(highlights, vec!["First".to_string()]);
262    }
263}