use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub(crate) enum ProjectContextError {
#[error("Failed to read context metadata for {path}: {source}")]
Metadata {
path: PathBuf,
source: std::io::Error,
},
#[error("Refusing symlinked context file {path}")]
Symlink { path: PathBuf },
#[error("Context path {path} is not a regular file")]
NotFile { path: PathBuf },
#[error("Context file {path} is too large ({size} bytes, max {max})")]
TooLarge {
path: PathBuf,
size: u64,
max: usize,
},
#[error("Failed to read context file {path}: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("Context file {path} is empty")]
Empty { path: PathBuf },
}
#[derive(Debug, Clone)]
pub struct ProjectContext {
pub instructions: Option<String>,
pub rules_block: Option<String>,
pub source_path: Option<PathBuf>,
pub warnings: Vec<String>,
pub constitution_block: Option<String>,
pub constitution_source_path: Option<PathBuf>,
#[allow(dead_code)] pub project_root: PathBuf,
pub is_trusted: bool,
}
impl ProjectContext {
pub fn empty(project_root: PathBuf) -> Self {
Self {
instructions: None,
rules_block: None,
source_path: None,
warnings: Vec::new(),
constitution_block: None,
constitution_source_path: None,
project_root,
is_trusted: false,
}
}
pub fn has_instructions(&self) -> bool {
self.instructions.is_some()
}
pub fn as_system_block(&self) -> Option<String> {
let instructions_block = self.instructions.as_ref().map(|content| {
let source = self
.source_path
.as_ref()
.map_or_else(|| "project".to_string(), |p| p.display().to_string());
let mut block = format!(
"<project_instructions source=\"{source}\">\n{content}\n</project_instructions>"
);
if let Some(rules) = &self.rules_block {
block.push('\n');
block.push_str(rules);
}
block
});
match (self.constitution_block.as_ref(), instructions_block) {
(Some(constitution), Some(instructions)) => {
Some(format!("{constitution}\n\n{instructions}"))
}
(Some(constitution), None) => {
if let Some(rules) = &self.rules_block {
Some(format!("{constitution}\n\n{rules}"))
} else {
Some(constitution.clone())
}
}
(None, Some(instructions)) => Some(instructions),
(None, None) => {
self.rules_block.clone()
}
}
}
}
#[allow(dead_code)] pub fn merge_contexts(contexts: &[ProjectContext]) -> Option<String> {
let non_empty: Vec<_> = contexts
.iter()
.filter_map(ProjectContext::as_system_block)
.collect();
if non_empty.is_empty() {
None
} else {
Some(non_empty.join("\n\n"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_merge_contexts() {
let mut ctx1 = ProjectContext::empty(PathBuf::from("/a"));
ctx1.instructions = Some("Instructions A".to_string());
ctx1.source_path = Some(PathBuf::from("/a/AGENTS.md"));
let mut ctx2 = ProjectContext::empty(PathBuf::from("/b"));
ctx2.instructions = Some("Instructions B".to_string());
ctx2.source_path = Some(PathBuf::from("/b/AGENTS.md"));
let merged = merge_contexts(&[ctx1, ctx2]).expect("merge");
assert!(merged.contains("Instructions A"));
assert!(merged.contains("Instructions B"));
}
}