use anyhow::{Context, Result};
use globset::GlobBuilder;
use ignore::WalkBuilder;
use reqwest::blocking::Client;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
const INSTRUCTION_FILES: &[&str] = &[
"AGENTS.md",
"CLAUDE.md",
".github/copilot-instructions.md",
"CONTEXT.md",
];
pub fn resolve_nearby_instructions(
workspace_root: &Path,
config_dir: &Path,
file_path: &Path,
) -> Result<Vec<(PathBuf, String)>> {
let mut results = Vec::new();
let mut seen = HashSet::new();
let target = file_path
.canonicalize()
.unwrap_or_else(|_| file_path.to_path_buf());
let root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
let system_paths = system_paths(workspace_root, config_dir, &[])?;
let system_set: HashSet<_> = system_paths
.iter()
.map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
.collect();
let mut current = target.parent().unwrap_or(&target);
while current.starts_with(&root) {
for file_name in INSTRUCTION_FILES {
let candidate = current.join(file_name);
let canonical = candidate
.canonicalize()
.unwrap_or_else(|_| candidate.clone());
if canonical == target {
continue;
}
if system_set.contains(&canonical) {
continue;
}
if seen.contains(&canonical) {
continue;
}
if candidate.exists()
&& let Ok(content) = fs::read_to_string(&candidate)
&& !content.trim().is_empty()
{
seen.insert(canonical);
results.push((
candidate.clone(),
format!("Instructions from: {}\n{}", candidate.display(), content),
));
}
}
if current == root {
break;
}
current = current.parent().unwrap_or(current);
}
Ok(results)
}
pub fn system_paths(
workspace_root: &Path,
config_dir: &Path,
instructions: &[String],
) -> Result<Vec<PathBuf>> {
let mut paths = Vec::new();
let mut seen = HashSet::new();
let mut push_unique = |path: PathBuf| {
let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
if seen.insert(canonical.clone()) {
paths.push(canonical);
}
};
if let Some(project_path) = find_project_instruction(workspace_root)? {
push_unique(project_path);
}
let global_path = config_dir.join("AGENTS.md");
if global_path.exists() {
push_unique(global_path);
}
for raw in instructions {
if raw.starts_with("http://") || raw.starts_with("https://") {
continue;
}
let resolved = resolve_instruction_paths(workspace_root, raw)?;
for path in resolved {
push_unique(path);
}
}
Ok(paths)
}
pub fn system_prompt_and_sources(
workspace_root: &Path,
config_dir: &Path,
instructions: &[String],
) -> Result<(String, Vec<String>)> {
let mut sections = Vec::new();
let mut sources = Vec::new();
let paths = system_paths(workspace_root, config_dir, instructions)?;
for path in paths {
if let Ok(content) = fs::read_to_string(&path)
&& !content.trim().is_empty()
{
sections.push(format!(
"Instructions from: {}\n{}",
path.display(),
content
));
sources.push(path.display().to_string());
}
}
for url in instructions
.iter()
.filter(|item| item.starts_with("http://") || item.starts_with("https://"))
{
if let Ok(content) = fetch_remote(url)
&& !content.trim().is_empty()
{
sections.push(format!("Instructions from: {}\n{}", url, content));
sources.push(url.clone());
}
}
Ok((sections.join("\n\n"), sources))
}
pub fn system_prompt_and_sources_with_cache(
workspace_root: &Path,
config_dir: &Path,
instructions: &[String],
cache: &HashMap<String, String>,
) -> Result<(String, Vec<String>, HashMap<String, String>)> {
let mut sections = Vec::new();
let mut sources = Vec::new();
let mut new_cache = cache.clone();
let paths = system_paths(workspace_root, config_dir, instructions)?;
for path in paths {
let path_str = path.display().to_string();
if let Some(cached_content) = cache.get(&path_str) {
if !cached_content.trim().is_empty() {
sections.push(format!(
"Instructions from: {}\n{}",
path.display(),
cached_content
));
sources.push(path_str);
}
} else {
if let Ok(content) = fs::read_to_string(&path)
&& !content.trim().is_empty()
{
new_cache.insert(path_str.clone(), content.clone());
sections.push(format!(
"Instructions from: {}\n{}",
path.display(),
content
));
sources.push(path_str);
}
}
}
for url in instructions
.iter()
.filter(|item| item.starts_with("http://") || item.starts_with("https://"))
{
if let Some(cached_content) = cache.get(url) {
if !cached_content.trim().is_empty() {
sections.push(format!("Instructions from: {}\n{}", url, cached_content));
sources.push(url.clone());
}
} else {
if let Ok(content) = fetch_remote(url)
&& !content.trim().is_empty()
{
new_cache.insert(url.clone(), content.clone());
sections.push(format!("Instructions from: {}\n{}", url, content));
sources.push(url.clone());
}
}
}
Ok((sections.join("\n\n"), sources, new_cache))
}
pub fn system_prompt(
workspace_root: &Path,
config_dir: &Path,
instructions: &[String],
) -> Result<String> {
Ok(system_prompt_and_sources(workspace_root, config_dir, instructions)?.0)
}
fn find_project_instruction(workspace_root: &Path) -> Result<Option<PathBuf>> {
for ancestor in workspace_root.ancestors() {
for file_name in INSTRUCTION_FILES {
let candidate = ancestor.join(file_name);
if candidate.exists() {
return Ok(Some(candidate));
}
}
}
Ok(None)
}
fn resolve_instruction_paths(workspace_root: &Path, raw: &str) -> Result<Vec<PathBuf>> {
let raw = raw.trim();
if raw.is_empty() {
return Ok(Vec::new());
}
let raw = if let Some(stripped) = raw.strip_prefix("~/") {
dirs::home_dir()
.map(|dir| dir.join(stripped))
.unwrap_or_else(|| PathBuf::from(raw))
} else {
PathBuf::from(raw)
};
if raw.is_absolute() {
if contains_glob(&raw) {
return glob_absolute(&raw);
}
if raw.exists() {
return Ok(vec![raw]);
}
return Ok(Vec::new());
}
if contains_glob(&raw) {
return glob_relative(workspace_root, &raw);
}
let candidate = workspace_root.join(&raw);
if candidate.exists() {
return Ok(vec![candidate]);
}
Ok(Vec::new())
}
fn contains_glob(path: &Path) -> bool {
let text = path.to_string_lossy();
text.contains('*') || text.contains('?') || text.contains('[')
}
fn glob_relative(workspace_root: &Path, pattern: &Path) -> Result<Vec<PathBuf>> {
let matcher = GlobBuilder::new(&pattern.to_string_lossy())
.literal_separator(false)
.build()
.context("invalid glob pattern")?
.compile_matcher();
let mut results = Vec::new();
let walker = WalkBuilder::new(workspace_root)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.follow_links(false)
.build();
for entry in walker {
let entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
};
if !entry
.file_type()
.map(|file_type| file_type.is_file())
.unwrap_or(false)
{
continue;
}
let path = entry.path();
if let Ok(rel) = path.strip_prefix(workspace_root) {
let candidate = rel.to_string_lossy();
if matcher.is_match(&*candidate) {
results.push(path.to_path_buf());
}
}
}
results.sort();
Ok(results)
}
fn glob_absolute(pattern: &Path) -> Result<Vec<PathBuf>> {
let matcher = GlobBuilder::new(&pattern.to_string_lossy())
.literal_separator(false)
.build()
.context("invalid glob pattern")?
.compile_matcher();
let mut results = Vec::new();
let root = Path::new("/");
let walker = WalkBuilder::new(root)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.follow_links(false)
.build();
for entry in walker {
let entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
};
if !entry
.file_type()
.map(|file_type| file_type.is_file())
.unwrap_or(false)
{
continue;
}
let path = entry.path();
let candidate = path.to_string_lossy();
if matcher.is_match(&*candidate) {
results.push(path.to_path_buf());
}
}
results.sort();
Ok(results)
}
fn fetch_remote(url: &str) -> Result<String> {
let client = Client::builder()
.timeout(Duration::from_secs(5))
.build()
.context("failed to build http client")?;
let response = client
.get(url)
.send()
.context("failed to fetch remote instruction")?;
let status = response.status();
if !status.is_success() {
return Ok(String::new());
}
response
.text()
.context("failed to read remote instruction body")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
fn make_temp_dir() -> Result<PathBuf> {
let dir = std::env::temp_dir().join(format!("tidev-instructions-{}", Uuid::new_v4()));
fs::create_dir_all(&dir).context("failed to create temp dir")?;
Ok(dir)
}
#[test]
fn system_paths_finds_project_agent_file() -> Result<()> {
let workspace = make_temp_dir()?;
fs::write(workspace.join("AGENTS.md"), "# Root")?;
let paths = system_paths(&workspace, &workspace, &[])?;
assert_eq!(paths, vec![workspace.join("AGENTS.md").canonicalize()?]);
Ok(())
}
#[test]
fn system_paths_prefers_project_over_global() -> Result<()> {
let workspace = make_temp_dir()?;
let global = make_temp_dir()?;
fs::write(workspace.join("AGENTS.md"), "# Root")?;
fs::write(global.join("AGENTS.md"), "# Global")?;
let paths = system_paths(&workspace, &global, &[])?;
assert_eq!(
paths,
vec![
workspace.join("AGENTS.md").canonicalize()?,
global.join("AGENTS.md").canonicalize()?,
],
);
Ok(())
}
#[test]
fn system_prompt_loads_config_instructions() -> Result<()> {
let workspace = make_temp_dir()?;
let global = make_temp_dir()?;
let extra = workspace.join("docs");
fs::create_dir_all(&extra)?;
fs::write(extra.join("style.md"), "# Style")?;
let prompt = system_prompt(&workspace, &global, &["docs/style.md".to_string()])?;
assert!(prompt.contains("Instructions from:"));
assert!(prompt.contains("# Style"));
Ok(())
}
#[test]
fn system_paths_finds_github_copilot_instructions() -> Result<()> {
let workspace = make_temp_dir()?;
fs::create_dir_all(workspace.join(".github"))?;
fs::write(
workspace.join(".github").join("copilot-instructions.md"),
"# Copilot",
)?;
let paths = system_paths(&workspace, &workspace, &[])?;
assert_eq!(
paths,
vec![
workspace
.join(".github")
.join("copilot-instructions.md")
.canonicalize()?
]
);
Ok(())
}
#[test]
fn resolve_nearby_instructions_finds_github_copilot_instructions() -> Result<()> {
let workspace = make_temp_dir()?;
let subdir = workspace.join("subdir").join("nested");
fs::create_dir_all(&subdir)?;
fs::create_dir_all(subdir.join(".github"))?;
fs::write(
subdir.join(".github").join("copilot-instructions.md"),
"# Copilot",
)?;
fs::write(subdir.join("file.rs"), "let x = 1;")?;
let config_dir = std::env::temp_dir().join("tidev-test-config-unique");
fs::create_dir_all(&config_dir)?;
let results =
resolve_nearby_instructions(&workspace, &config_dir, &subdir.join("file.rs"))?;
let expected_path = subdir
.join(".github")
.join("copilot-instructions.md")
.canonicalize()?;
let expected_content = format!(
"Instructions from: {}\n{}",
expected_path.display(),
"# Copilot"
);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, expected_path);
assert_eq!(results[0].1, expected_content);
Ok(())
}
#[test]
fn resolve_nearby_instructions_finds_subdirectory_agents() -> Result<()> {
let workspace = make_temp_dir()?;
let subdir = workspace.join("subdir").join("nested");
fs::create_dir_all(&subdir)?;
fs::write(workspace.join("subdir").join("AGENTS.md"), "# Subdir")?;
fs::write(subdir.join("file.rs"), "let x = 1;")?;
let config_dir = std::env::temp_dir().join("tidev-test-config-unique");
fs::create_dir_all(&config_dir)?;
let results =
resolve_nearby_instructions(&workspace, &config_dir, &subdir.join("file.rs"))?;
let expected_path = workspace.join("subdir").join("AGENTS.md").canonicalize()?;
let expected_content = format!(
"Instructions from: {}\n{}",
expected_path.display(),
"# Subdir"
);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, expected_path);
assert_eq!(results[0].1, expected_content);
Ok(())
}
}