use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use thiserror::Error;
const MAX_CONTEXT_SIZE: usize = 20_000;
const HEAD_SIZE: usize = 10_000;
const TAIL_SIZE: usize = 10_000;
const AGENTS_MD: &str = "AGENTS.md";
#[derive(Debug, Error)]
pub enum ContextError {
#[error("I/O error: {0}")]
IoError(#[from] io::Error),
#[error("path not found: {0}")]
PathNotFound(PathBuf),
}
pub type ContextResult<T> = Result<T, ContextError>;
pub struct ContextLoader {
workspace_root: PathBuf,
enabled: bool,
global_dir: Option<PathBuf>,
}
impl ContextLoader {
#[must_use]
pub fn new(workspace_root: PathBuf) -> Self {
Self {
workspace_root,
enabled: true,
global_dir: None,
}
}
#[must_use]
pub fn with_no_context(mut self) -> Self {
self.enabled = false;
self
}
#[must_use]
pub fn with_global_dir(mut self, global_dir: PathBuf) -> Self {
self.global_dir = Some(global_dir);
self
}
pub fn load(&self) -> ContextResult<String> {
if !self.enabled {
return Ok(String::new());
}
let mut parts: Vec<String> = Vec::new();
if let Some(global_path) = self.global_agents_path()
&& let Some(content) = self.read_if_present(&global_path)?
&& !content.trim().is_empty()
{
parts.push(self.format_section(&global_path, &content));
}
let mut current: Option<&Path> = Some(&self.workspace_root);
while let Some(dir) = current {
let agents_path = dir.join(AGENTS_MD);
if let Some(content) = self.read_if_present(&agents_path)?
&& !content.trim().is_empty()
{
parts.push(self.format_section(&agents_path, &content));
}
if dir.join(".git").exists() {
break;
}
current = dir.parent();
}
let combined = parts.join("\n");
Ok(Self::apply_size_limit(&combined))
}
fn global_agents_path(&self) -> Option<PathBuf> {
if let Some(dir) = &self.global_dir {
return Some(dir.join(AGENTS_MD));
}
let home = std::env::var("HOME").ok()?;
let mut path = PathBuf::from(home);
path.push(".talos");
path.push(AGENTS_MD);
Some(path)
}
fn read_if_present(&self, path: &Path) -> ContextResult<Option<String>> {
match fs::read_to_string(path) {
Ok(content) => Ok(Some(content)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(ContextError::IoError(e)),
}
}
fn format_section(&self, path: &Path, content: &str) -> String {
format!("--- AGENTS.md from {} ---\n{}", path.display(), content)
}
fn apply_size_limit(content: &str) -> String {
let char_count = content.chars().count();
if char_count <= MAX_CONTEXT_SIZE {
return content.to_string();
}
let chars: Vec<char> = content.chars().collect();
let mut result = String::with_capacity(HEAD_SIZE + TAIL_SIZE + 3);
result.extend(chars.iter().take(HEAD_SIZE));
result.push_str("\n...\n");
let tail_start = char_count.saturating_sub(TAIL_SIZE);
result.extend(chars.iter().skip(tail_start));
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_agents_md(dir: &Path, content: &str) {
let path = dir.join(AGENTS_MD);
fs::write(path, content).expect("failed to write AGENTS.md");
}
fn create_git_root(dir: &Path) {
let git_dir = dir.join(".git");
fs::create_dir(git_dir).expect("failed to create .git directory");
}
#[test]
fn test_load_single_agents_md() {
let temp_dir = TempDir::new().expect("failed to create temp dir");
create_agents_md(temp_dir.path(), "# Project Rules\nBe helpful.");
let loader = ContextLoader::new(temp_dir.path().to_path_buf());
let context = loader.load().expect("load failed");
assert!(context.contains("# Project Rules"));
assert!(context.contains("Be helpful."));
assert!(context.contains("AGENTS.md from"));
}
#[test]
fn test_load_multiple_agents_md_from_parent_dirs() {
let temp_dir = TempDir::new().expect("failed to create temp dir");
let sub_dir = temp_dir.path().join("sub");
fs::create_dir(&sub_dir).expect("failed to create sub directory");
create_agents_md(temp_dir.path(), "# Root Rules\nRoot content.");
create_agents_md(&sub_dir, "# Sub Rules\nSub content.");
let loader = ContextLoader::new(sub_dir);
let context = loader.load().expect("load failed");
assert!(context.contains("# Root Rules"));
assert!(context.contains("# Sub Rules"));
let sub_idx = context.find("# Sub Rules").expect("sub rules not found");
let root_idx = context.find("# Root Rules").expect("root rules not found");
assert!(sub_idx < root_idx, "sub should appear before root");
}
#[test]
fn test_global_agents_md_loading() {
let temp_dir = TempDir::new().expect("failed to create temp dir");
let talos_dir = temp_dir.path().join(".talos");
fs::create_dir(&talos_dir).expect("failed to create .talos directory");
create_agents_md(&talos_dir, "# Global Rules\nGlobal content.");
let loader = ContextLoader::new(temp_dir.path().join("project")).with_global_dir(talos_dir);
let context = loader.load().expect("load failed");
assert!(context.contains("# Global Rules"));
assert!(context.contains("Global content."));
}
#[test]
fn test_size_limit_truncation() {
let temp_dir = TempDir::new().expect("failed to create temp dir");
let global_dir = TempDir::new().expect("failed to create global dir");
let head_content = "A".repeat(15_000);
let tail_content = "B".repeat(15_000);
let full_content = format!("{}{}", head_content, tail_content);
create_agents_md(temp_dir.path(), &full_content);
let loader = ContextLoader::new(temp_dir.path().to_path_buf())
.with_global_dir(global_dir.path().to_path_buf());
let context = loader.load().expect("load failed");
let char_count = context.chars().count();
assert!(
char_count <= MAX_CONTEXT_SIZE + 20,
"context should be near size limit, got {} chars",
char_count
);
assert!(context.contains(&"A".repeat(100)));
assert!(context.ends_with(&"B".repeat(100)));
assert!(context.contains("\n...\n"));
}
#[test]
fn test_no_context_disables_loading() {
let temp_dir = TempDir::new().expect("failed to create temp dir");
create_agents_md(temp_dir.path(), "# Should Not Load");
let loader = ContextLoader::new(temp_dir.path().to_path_buf()).with_no_context();
let context = loader.load().expect("load failed");
assert!(context.is_empty());
}
#[test]
fn test_missing_agents_md_skipped_gracefully() {
let temp_dir = TempDir::new().expect("failed to create temp dir");
let fake_home = TempDir::new().expect("failed to create fake home");
let loader = ContextLoader::new(temp_dir.path().to_path_buf())
.with_global_dir(fake_home.path().to_path_buf());
let context = loader.load().expect("load failed");
assert!(context.is_empty());
}
#[test]
fn test_git_root_detection() {
let temp_dir = TempDir::new().expect("failed to create temp dir");
let sub_dir = temp_dir.path().join("sub");
let deep_dir = sub_dir.join("deep");
fs::create_dir_all(&deep_dir).expect("failed to create directories");
create_agents_md(temp_dir.path(), "# Root");
create_agents_md(&sub_dir, "# Sub");
create_agents_md(&deep_dir, "# Deep");
create_git_root(temp_dir.path());
let loader = ContextLoader::new(deep_dir);
let context = loader.load().expect("load failed");
assert!(context.contains("# Root"));
assert!(context.contains("# Sub"));
assert!(context.contains("# Deep"));
}
#[test]
fn test_git_root_stops_walking() {
let temp_dir = TempDir::new().expect("failed to create temp dir");
let sub_dir = temp_dir.path().join("sub");
fs::create_dir(&sub_dir).expect("failed to create sub directory");
create_agents_md(temp_dir.path(), "# Git Root");
create_agents_md(&sub_dir, "# Sub Dir");
create_git_root(temp_dir.path());
let loader = ContextLoader::new(sub_dir);
let context = loader.load().expect("load failed");
assert!(context.contains("# Git Root"));
assert!(context.contains("# Sub Dir"));
}
#[test]
fn test_empty_agents_md_skipped() {
let temp_dir = TempDir::new().expect("failed to create temp dir");
let global_dir = TempDir::new().expect("failed to create global dir");
fs::write(temp_dir.path().join(AGENTS_MD), "").expect("failed to write empty file");
let loader = ContextLoader::new(temp_dir.path().to_path_buf())
.with_global_dir(global_dir.path().to_path_buf());
let context = loader.load().expect("load failed");
assert!(context.is_empty());
}
#[test]
fn test_whitespace_only_agents_md_skipped() {
let temp_dir = TempDir::new().expect("failed to create temp dir");
fs::write(temp_dir.path().join(AGENTS_MD), " \n\n ")
.expect("failed to write whitespace file");
let loader = ContextLoader::new(temp_dir.path().to_path_buf());
let context = loader.load().expect("load failed");
assert!(context.is_empty());
}
#[test]
fn test_apply_size_limit_exact_boundary() {
let content = "X".repeat(MAX_CONTEXT_SIZE);
let result = ContextLoader::apply_size_limit(&content);
assert_eq!(result.chars().count(), MAX_CONTEXT_SIZE);
}
#[test]
fn test_apply_size_limit_one_over() {
let content = "X".repeat(MAX_CONTEXT_SIZE + 5_000);
let result = ContextLoader::apply_size_limit(&content);
assert!(result.contains("..."));
assert!(result.chars().count() < content.chars().count());
}
}