vtcode_core/
project_doc.rs1use 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}