pub mod export;
pub mod instructions;
pub use instructions::{
InstructionDiagnostic, InstructionInventory, InstructionSelection, InstructionSeverity, InstructionSnapshot,
discover_instructions, select_instructions,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
fn hash_content(content: &str) -> u64 {
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
hasher.finish()
}
pub fn scope_depth(scope: &str) -> usize {
if scope == "." || scope.is_empty() { 0 } else { scope.matches('/').count() + 1 }
}
pub const AGENTS_MD_SIZE_CAP: usize = 32_768;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ContextSource {
pub path: PathBuf,
pub scope: String,
pub content: String,
pub content_hash: u64,
pub truncated: bool,
pub byte_count: usize,
}
impl ContextSource {
pub fn summary(&self) -> String {
let path_display = self.path.display().to_string();
match self.truncated {
true => format!("loaded {path_display} (truncated, {} bytes)", self.byte_count),
false => format!("loaded {path_display}"),
}
}
}
pub fn discover_workspace_root(cwd: &Path) -> PathBuf {
match Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(cwd)
.output()
{
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
let root = stdout.trim();
let root = if root.is_empty() { cwd.to_path_buf() } else { PathBuf::from(root) };
root.canonicalize().unwrap_or(root)
}
_ => cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()),
}
}
pub fn load_agents_md(workspace_root: &Path) -> Option<ContextSource> {
let path = workspace_root.join("AGENTS.md");
let metadata = fs::metadata(&path).ok()?;
let byte_count = metadata.len() as usize;
let content = fs::read_to_string(&path).ok()?;
let content_hash = hash_content(&content);
let (content, truncated) = if byte_count > AGENTS_MD_SIZE_CAP {
let mut capped = content.into_bytes();
capped.truncate(AGENTS_MD_SIZE_CAP);
(
trim_to_char_boundary(&String::from_utf8_lossy(&capped), AGENTS_MD_SIZE_CAP),
true,
)
} else {
(content, false)
};
Some(ContextSource { path, scope: String::from("."), content, content_hash, truncated, byte_count })
}
pub fn trim_to_char_boundary(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_string();
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_string()
}