1use std::fs;
13use std::io;
14use std::path::{Path, PathBuf};
15use thiserror::Error;
16
17const MAX_CONTEXT_SIZE: usize = 20_000;
19
20const HEAD_SIZE: usize = 10_000;
22
23const TAIL_SIZE: usize = 10_000;
25
26const AGENTS_MD: &str = "AGENTS.md";
28
29#[derive(Debug, Error)]
31pub enum ContextError {
32 #[error("I/O error: {0}")]
34 IoError(#[from] io::Error),
35
36 #[error("path not found: {0}")]
38 PathNotFound(PathBuf),
39}
40
41pub type ContextResult<T> = Result<T, ContextError>;
43
44pub struct ContextLoader {
60 workspace_root: PathBuf,
62 enabled: bool,
64 global_dir: Option<PathBuf>,
67}
68
69impl ContextLoader {
70 #[must_use]
75 pub fn new(workspace_root: PathBuf) -> Self {
76 Self {
77 workspace_root,
78 enabled: true,
79 global_dir: None,
80 }
81 }
82
83 #[must_use]
88 pub fn with_no_context(mut self) -> Self {
89 self.enabled = false;
90 self
91 }
92
93 #[must_use]
99 pub fn with_global_dir(mut self, global_dir: PathBuf) -> Self {
100 self.global_dir = Some(global_dir);
101 self
102 }
103
104 pub fn load(&self) -> ContextResult<String> {
121 if !self.enabled {
122 return Ok(String::new());
123 }
124
125 let mut parts: Vec<String> = Vec::new();
126
127 if let Some(global_path) = self.global_agents_path()
129 && let Some(content) = self.read_if_present(&global_path)?
130 && !content.trim().is_empty()
131 {
132 parts.push(self.format_section(&global_path, &content));
133 }
134
135 let mut current: Option<&Path> = Some(&self.workspace_root);
137 while let Some(dir) = current {
138 let agents_path = dir.join(AGENTS_MD);
139 if let Some(content) = self.read_if_present(&agents_path)?
140 && !content.trim().is_empty()
141 {
142 parts.push(self.format_section(&agents_path, &content));
143 }
144
145 if dir.join(".git").exists() {
147 break;
148 }
149
150 current = dir.parent();
151 }
152
153 let combined = parts.join("\n");
154 Ok(Self::apply_size_limit(&combined))
155 }
156
157 fn global_agents_path(&self) -> Option<PathBuf> {
162 if let Some(dir) = &self.global_dir {
163 return Some(dir.join(AGENTS_MD));
164 }
165 let home = std::env::var("HOME").ok()?;
166 let mut path = PathBuf::from(home);
167 path.push(".talos");
168 path.push(AGENTS_MD);
169 Some(path)
170 }
171
172 fn read_if_present(&self, path: &Path) -> ContextResult<Option<String>> {
178 match fs::read_to_string(path) {
179 Ok(content) => Ok(Some(content)),
180 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
181 Err(e) => Err(ContextError::IoError(e)),
182 }
183 }
184
185 fn format_section(&self, path: &Path, content: &str) -> String {
187 format!("--- AGENTS.md from {} ---\n{}", path.display(), content)
188 }
189
190 fn apply_size_limit(content: &str) -> String {
195 let char_count = content.chars().count();
196 if char_count <= MAX_CONTEXT_SIZE {
197 return content.to_string();
198 }
199
200 let chars: Vec<char> = content.chars().collect();
201 let mut result = String::with_capacity(HEAD_SIZE + TAIL_SIZE + 3);
202
203 result.extend(chars.iter().take(HEAD_SIZE));
205
206 result.push_str("\n...\n");
208
209 let tail_start = char_count.saturating_sub(TAIL_SIZE);
211 result.extend(chars.iter().skip(tail_start));
212
213 result
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use std::fs;
221 use tempfile::TempDir;
222
223 fn create_agents_md(dir: &Path, content: &str) {
225 let path = dir.join(AGENTS_MD);
226 fs::write(path, content).expect("failed to write AGENTS.md");
227 }
228
229 fn create_git_root(dir: &Path) {
231 let git_dir = dir.join(".git");
232 fs::create_dir(git_dir).expect("failed to create .git directory");
233 }
234
235 #[test]
236 fn test_load_single_agents_md() {
237 let temp_dir = TempDir::new().expect("failed to create temp dir");
238 create_agents_md(temp_dir.path(), "# Project Rules\nBe helpful.");
239
240 let loader = ContextLoader::new(temp_dir.path().to_path_buf());
241 let context = loader.load().expect("load failed");
242
243 assert!(context.contains("# Project Rules"));
244 assert!(context.contains("Be helpful."));
245 assert!(context.contains("AGENTS.md from"));
246 }
247
248 #[test]
249 fn test_load_multiple_agents_md_from_parent_dirs() {
250 let temp_dir = TempDir::new().expect("failed to create temp dir");
251 let sub_dir = temp_dir.path().join("sub");
252 fs::create_dir(&sub_dir).expect("failed to create sub directory");
253
254 create_agents_md(temp_dir.path(), "# Root Rules\nRoot content.");
255 create_agents_md(&sub_dir, "# Sub Rules\nSub content.");
256
257 let loader = ContextLoader::new(sub_dir);
258 let context = loader.load().expect("load failed");
259
260 assert!(context.contains("# Root Rules"));
261 assert!(context.contains("# Sub Rules"));
262 let sub_idx = context.find("# Sub Rules").expect("sub rules not found");
264 let root_idx = context.find("# Root Rules").expect("root rules not found");
265 assert!(sub_idx < root_idx, "sub should appear before root");
266 }
267
268 #[test]
269 fn test_global_agents_md_loading() {
270 let temp_dir = TempDir::new().expect("failed to create temp dir");
271 let talos_dir = temp_dir.path().join(".talos");
272 fs::create_dir(&talos_dir).expect("failed to create .talos directory");
273 create_agents_md(&talos_dir, "# Global Rules\nGlobal content.");
274
275 let loader = ContextLoader::new(temp_dir.path().join("project")).with_global_dir(talos_dir);
276 let context = loader.load().expect("load failed");
277
278 assert!(context.contains("# Global Rules"));
279 assert!(context.contains("Global content."));
280 }
281
282 #[test]
283 fn test_size_limit_truncation() {
284 let temp_dir = TempDir::new().expect("failed to create temp dir");
285 let global_dir = TempDir::new().expect("failed to create global dir");
286
287 let head_content = "A".repeat(15_000);
289 let tail_content = "B".repeat(15_000);
290 let full_content = format!("{}{}", head_content, tail_content);
291 create_agents_md(temp_dir.path(), &full_content);
292
293 let loader = ContextLoader::new(temp_dir.path().to_path_buf())
294 .with_global_dir(global_dir.path().to_path_buf());
295 let context = loader.load().expect("load failed");
296
297 let char_count = context.chars().count();
298 assert!(
300 char_count <= MAX_CONTEXT_SIZE + 20,
301 "context should be near size limit, got {} chars",
302 char_count
303 );
304
305 assert!(context.contains(&"A".repeat(100)));
307 assert!(context.ends_with(&"B".repeat(100)));
309 assert!(context.contains("\n...\n"));
311 }
312
313 #[test]
314 fn test_no_context_disables_loading() {
315 let temp_dir = TempDir::new().expect("failed to create temp dir");
316 create_agents_md(temp_dir.path(), "# Should Not Load");
317
318 let loader = ContextLoader::new(temp_dir.path().to_path_buf()).with_no_context();
319 let context = loader.load().expect("load failed");
320
321 assert!(context.is_empty());
322 }
323
324 #[test]
325 fn test_missing_agents_md_skipped_gracefully() {
326 let temp_dir = TempDir::new().expect("failed to create temp dir");
327 let fake_home = TempDir::new().expect("failed to create fake home");
328
329 let loader = ContextLoader::new(temp_dir.path().to_path_buf())
330 .with_global_dir(fake_home.path().to_path_buf());
331 let context = loader.load().expect("load failed");
332
333 assert!(context.is_empty());
334 }
335
336 #[test]
337 fn test_git_root_detection() {
338 let temp_dir = TempDir::new().expect("failed to create temp dir");
339 let sub_dir = temp_dir.path().join("sub");
340 let deep_dir = sub_dir.join("deep");
341 fs::create_dir_all(&deep_dir).expect("failed to create directories");
342
343 create_agents_md(temp_dir.path(), "# Root");
344 create_agents_md(&sub_dir, "# Sub");
345 create_agents_md(&deep_dir, "# Deep");
346 create_git_root(temp_dir.path());
347
348 let loader = ContextLoader::new(deep_dir);
351 let context = loader.load().expect("load failed");
352
353 assert!(context.contains("# Root"));
354 assert!(context.contains("# Sub"));
355 assert!(context.contains("# Deep"));
356 }
357
358 #[test]
359 fn test_git_root_stops_walking() {
360 let temp_dir = TempDir::new().expect("failed to create temp dir");
361 let sub_dir = temp_dir.path().join("sub");
362 fs::create_dir(&sub_dir).expect("failed to create sub directory");
363
364 create_agents_md(temp_dir.path(), "# Git Root");
365 create_agents_md(&sub_dir, "# Sub Dir");
366 create_git_root(temp_dir.path());
367
368 let loader = ContextLoader::new(sub_dir);
369 let context = loader.load().expect("load failed");
370
371 assert!(context.contains("# Git Root"));
373 assert!(context.contains("# Sub Dir"));
374 }
375
376 #[test]
377 fn test_empty_agents_md_skipped() {
378 let temp_dir = TempDir::new().expect("failed to create temp dir");
379 let global_dir = TempDir::new().expect("failed to create global dir");
380 fs::write(temp_dir.path().join(AGENTS_MD), "").expect("failed to write empty file");
382
383 let loader = ContextLoader::new(temp_dir.path().to_path_buf())
384 .with_global_dir(global_dir.path().to_path_buf());
385 let context = loader.load().expect("load failed");
386
387 assert!(context.is_empty());
388 }
389
390 #[test]
391 fn test_whitespace_only_agents_md_skipped() {
392 let temp_dir = TempDir::new().expect("failed to create temp dir");
393 fs::write(temp_dir.path().join(AGENTS_MD), " \n\n ")
394 .expect("failed to write whitespace file");
395
396 let loader = ContextLoader::new(temp_dir.path().to_path_buf());
397 let context = loader.load().expect("load failed");
398
399 assert!(context.is_empty());
400 }
401
402 #[test]
403 fn test_apply_size_limit_exact_boundary() {
404 let content = "X".repeat(MAX_CONTEXT_SIZE);
406 let result = ContextLoader::apply_size_limit(&content);
407 assert_eq!(result.chars().count(), MAX_CONTEXT_SIZE);
408 }
409
410 #[test]
411 fn test_apply_size_limit_one_over() {
412 let content = "X".repeat(MAX_CONTEXT_SIZE + 5_000);
414 let result = ContextLoader::apply_size_limit(&content);
415 assert!(result.contains("..."));
417 assert!(result.chars().count() < content.chars().count());
418 }
419}