Skip to main content

talos_agent/
context.rs

1//! Context loader for AGENTS.md files.
2//!
3//! Loads `AGENTS.md` files from the working directory and parent directories,
4//! concatenates them into system prompt context. Loading order:
5//! 1. Global: `~/.talos/AGENTS.md` (if exists)
6//! 2. Project: walk from `workspace_root` up to git root (or filesystem root),
7//!    loading `AGENTS.md` from each directory
8//!
9//! Total context is capped at 20,000 characters with head/tail truncation
10//! if exceeded.
11
12use std::fs;
13use std::io;
14use std::path::{Path, PathBuf};
15use thiserror::Error;
16
17/// Maximum total context size in characters.
18const MAX_CONTEXT_SIZE: usize = 20_000;
19
20/// Size of the head portion when truncating (first N characters).
21const HEAD_SIZE: usize = 10_000;
22
23/// Size of the tail portion when truncating (last N characters).
24const TAIL_SIZE: usize = 10_000;
25
26/// The filename to look for in each directory.
27const AGENTS_MD: &str = "AGENTS.md";
28
29/// Errors that can occur during context loading.
30#[derive(Debug, Error)]
31pub enum ContextError {
32    /// An I/O error occurred while reading a file.
33    #[error("I/O error: {0}")]
34    IoError(#[from] io::Error),
35
36    /// The specified path was not found.
37    #[error("path not found: {0}")]
38    PathNotFound(PathBuf),
39}
40
41/// Result alias for context operations.
42pub type ContextResult<T> = Result<T, ContextError>;
43
44/// Loads AGENTS.md files from the workspace and parent directories.
45///
46/// The loader walks up from the workspace root to the git root (or filesystem
47/// root), collecting `AGENTS.md` files along the way. It also loads a global
48/// `AGENTS.md` from `~/.talos/` if it exists.
49///
50/// # Example
51///
52/// ```no_run
53/// use talos_agent::context::ContextLoader;
54/// use std::path::PathBuf;
55///
56/// let loader = ContextLoader::new(PathBuf::from("/path/to/project"));
57/// let context = loader.load().unwrap();
58/// ```
59pub struct ContextLoader {
60    /// The workspace root directory to start walking from.
61    workspace_root: PathBuf,
62    /// Whether context loading is enabled.
63    enabled: bool,
64    /// Override directory for the global `AGENTS.md`. When `None`, the global
65    /// file is resolved from `$HOME/.talos`.
66    global_dir: Option<PathBuf>,
67}
68
69impl ContextLoader {
70    /// Creates a new context loader for the given workspace root.
71    ///
72    /// Context loading is enabled by default. Use [`ContextLoader::with_no_context`]
73    /// to disable it.
74    #[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    /// Disables context loading.
84    ///
85    /// When context loading is disabled, [`ContextLoader::load`] returns an
86    /// empty string without reading any files.
87    #[must_use]
88    pub fn with_no_context(mut self) -> Self {
89        self.enabled = false;
90        self
91    }
92
93    /// Overrides the directory used to resolve the global `AGENTS.md`.
94    ///
95    /// When set, the global file is read from `<global_dir>/AGENTS.md` instead
96    /// of the default `$HOME/.talos/AGENTS.md`. Primarily intended for tests so
97    /// they do not need to mutate the process-wide `HOME` environment variable.
98    #[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    /// Loads and concatenates all AGENTS.md files.
105    ///
106    /// Files are loaded in this order:
107    /// 1. Global: `~/.talos/AGENTS.md` (if exists)
108    /// 2. Project: walk from `workspace_root` up to git root (or filesystem root),
109    ///    loading `AGENTS.md` from each directory
110    ///
111    /// Each file is separated by `--- AGENTS.md from {path} ---`.
112    ///
113    /// If the total context exceeds 20,000 characters, it is truncated with
114    /// head (first 10,000) + tail (last 10,000) preservation.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`ContextError::IoError`] if a file exists but cannot be read.
119    /// Missing files are skipped gracefully.
120    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        // 1. Load global AGENTS.md (missing files are skipped gracefully)
128        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        // 2. Walk from workspace_root up to git root (or filesystem root)
136        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            // Stop at git root
146            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    /// Returns the path to the global AGENTS.md file.
158    ///
159    /// When a global directory override is set, it is used directly; otherwise
160    /// the path is derived from `$HOME/.talos`.
161    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    /// Reads a file, returning `None` if it does not exist.
173    ///
174    /// This avoids the race between an `exists()` check and a subsequent read,
175    /// and treats a missing file as "skip" rather than a hard error, matching
176    /// the documented behavior that missing files are skipped gracefully.
177    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    /// Formats a section with a clear separator header.
186    fn format_section(&self, path: &Path, content: &str) -> String {
187        format!("--- AGENTS.md from {} ---\n{}", path.display(), content)
188    }
189
190    /// Applies the size limit to the combined context.
191    ///
192    /// If the context exceeds [`MAX_CONTEXT_SIZE`], it is truncated to preserve
193    /// the first [`HEAD_SIZE`] characters and the last [`TAIL_SIZE`] characters.
194    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        // Head portion
204        result.extend(chars.iter().take(HEAD_SIZE));
205
206        // Truncation indicator
207        result.push_str("\n...\n");
208
209        // Tail portion
210        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    /// Helper to create a temporary directory with an AGENTS.md file.
224    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    /// Helper to create a .git directory to simulate a git root.
230    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        // Sub directory file should appear before root (walk order: sub -> root)
263        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        // Create content that exceeds 20,000 characters
288        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        // Truncated content: head (10,000) + separator (5) + tail (10,000) = 20,005
299        assert!(
300            char_count <= MAX_CONTEXT_SIZE + 20,
301            "context should be near size limit, got {} chars",
302            char_count
303        );
304
305        // The separator header shifts the start; verify head chars are present
306        assert!(context.contains(&"A".repeat(100)));
307        // Tail portion should be preserved
308        assert!(context.ends_with(&"B".repeat(100)));
309        // Truncation indicator should be present
310        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        // Walking stops at .git; verify git root file IS included
349
350        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        // Both should be found since we walk from sub_dir up to git root
372        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        // Create an empty AGENTS.md in the project root
381        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        // Content exactly at the limit should not be truncated
405        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        // Content significantly over the limit should be truncated
413        let content = "X".repeat(MAX_CONTEXT_SIZE + 5_000);
414        let result = ContextLoader::apply_size_limit(&content);
415        // Should have head + tail + truncation indicator
416        assert!(result.contains("..."));
417        assert!(result.chars().count() < content.chars().count());
418    }
419}