use std::path::Path;
use crate::brain::skills::split_frontmatter;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DirectiveTier {
Always,
Conditional(String),
OnDemand(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectiveFile {
pub rel_path: String,
pub tier: DirectiveTier,
}
const ROOT_FILES: &[&str] = &[
"AGENTS.md",
"CLAUDE.md",
"CLAUDE.local.md",
"GEMINI.md",
".cursorrules",
".windsurfrules",
];
const NESTED_FILES: &[&str] = &[
".claude/CLAUDE.md",
".github/copilot-instructions.md",
".opencode/AGENTS.md",
];
const RULE_DIRS: &[(&str, &[&str])] = &[
(".clinerules", &["md", "txt"]),
(".claude/rules", &["md"]),
(".cursor/rules", &["mdc"]),
];
enum FrontmatterKind {
Cursor,
Paths,
}
pub fn discover(root: &Path) -> Vec<DirectiveFile> {
if !root.is_dir() {
return Vec::new();
}
let mut found: Vec<DirectiveFile> = Vec::new();
for name in ROOT_FILES {
if root.join(name).is_file() {
found.push(DirectiveFile {
rel_path: (*name).to_string(),
tier: DirectiveTier::Always,
});
}
}
for rel in NESTED_FILES {
if root.join(rel).is_file() {
found.push(DirectiveFile {
rel_path: (*rel).to_string(),
tier: DirectiveTier::Always,
});
}
}
let clinerules = root.join(".clinerules");
if clinerules.is_file() {
found.push(DirectiveFile {
rel_path: ".clinerules".to_string(),
tier: DirectiveTier::Always,
});
} else if clinerules.is_dir() {
collect_rule_dir(
root,
&clinerules,
&["md", "txt"],
&FrontmatterKind::Paths,
&mut found,
);
}
let claude_rules = root.join(".claude").join("rules");
if claude_rules.is_dir() {
collect_rule_dir(
root,
&claude_rules,
&["md"],
&FrontmatterKind::Paths,
&mut found,
);
}
let cursor_rules = root.join(".cursor").join("rules");
if cursor_rules.is_dir() {
collect_rule_dir(
root,
&cursor_rules,
&["mdc"],
&FrontmatterKind::Cursor,
&mut found,
);
}
found.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
found.dedup_by(|a, b| a.rel_path == b.rel_path);
found
}
pub fn directives_mtime(root: &Path) -> Option<std::time::SystemTime> {
if !root.is_dir() {
return None;
}
let mut newest: Option<std::time::SystemTime> = None;
let mut consider = |p: &Path| {
if let Ok(m) = std::fs::metadata(p).and_then(|md| md.modified()) {
newest = Some(newest.map_or(m, |n| n.max(m)));
}
};
for name in ROOT_FILES {
consider(&root.join(name));
}
for rel in NESTED_FILES {
consider(&root.join(rel));
}
for (dir, exts) in RULE_DIRS {
let d = root.join(dir);
consider(&d);
for ext in *exts {
let Some(pat) = d
.join("**")
.join(format!("*.{ext}"))
.to_str()
.map(String::from)
else {
continue;
};
let Ok(paths) = glob::glob(&pat) else {
continue;
};
for path in paths.flatten() {
consider(&path);
}
}
}
newest
}
fn collect_rule_dir(
root: &Path,
dir: &Path,
exts: &[&str],
kind: &FrontmatterKind,
out: &mut Vec<DirectiveFile>,
) {
for ext in exts {
let pattern = dir.join("**").join(format!("*.{ext}"));
let Some(pat) = pattern.to_str() else {
continue;
};
let Ok(paths) = glob::glob(pat) else {
continue;
};
for path in paths.flatten() {
if !path.is_file() {
continue;
}
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
out.push(DirectiveFile {
rel_path: rel,
tier: classify(&path, kind),
});
}
}
}
fn classify(path: &Path, kind: &FrontmatterKind) -> DirectiveTier {
let raw = std::fs::read_to_string(path).unwrap_or_default();
let fm = split_frontmatter(&raw).map(|(f, _)| f).unwrap_or("");
match kind {
FrontmatterKind::Cursor => {
if frontmatter_bool(fm, "alwaysApply") == Some(true) {
return DirectiveTier::Always;
}
if let Some(globs) = frontmatter_list(fm, "globs") {
return DirectiveTier::Conditional(globs);
}
if let Some(desc) = frontmatter_scalar(fm, "description") {
return DirectiveTier::OnDemand(desc);
}
DirectiveTier::Always
}
FrontmatterKind::Paths => match frontmatter_list(fm, "paths") {
Some(paths) => DirectiveTier::Conditional(paths),
None => DirectiveTier::Always,
},
}
}
fn field(fm: &str, key: &str) -> Option<(String, Vec<String>)> {
let lines: Vec<&str> = fm.lines().collect();
for (i, line) in lines.iter().enumerate() {
let Some(rest) = line
.trim_start()
.strip_prefix(key)
.and_then(|r| r.strip_prefix(':'))
else {
continue;
};
let inline = rest.trim().to_string();
let mut block = Vec::new();
if inline.is_empty() {
for next in &lines[i + 1..] {
let t = next.trim_start();
if let Some(item) = t.strip_prefix("- ") {
block.push(item.trim().to_string());
} else if t.is_empty() {
continue;
} else {
break;
}
}
}
return Some((inline, block));
}
None
}
fn frontmatter_bool(fm: &str, key: &str) -> Option<bool> {
let (inline, _) = field(fm, key)?;
match inline.trim().to_ascii_lowercase().as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
fn frontmatter_scalar(fm: &str, key: &str) -> Option<String> {
let (inline, _) = field(fm, key)?;
let v = clean_scalar(&inline);
if v.is_empty() { None } else { Some(v) }
}
fn frontmatter_list(fm: &str, key: &str) -> Option<String> {
let (inline, block) = field(fm, key)?;
let items: Vec<String> = if inline.is_empty() {
block
.iter()
.map(|s| clean_scalar(s))
.filter(|s| !s.is_empty())
.collect()
} else {
let inner = inline.trim().trim_start_matches('[').trim_end_matches(']');
inner
.split(',')
.map(clean_scalar)
.filter(|s| !s.is_empty())
.collect()
};
if items.is_empty() {
None
} else {
Some(items.join(", "))
}
}
fn clean_scalar(s: &str) -> String {
s.trim()
.trim_matches('"')
.trim_matches('\'')
.trim()
.to_string()
}
pub fn render(root_display: &str, files: &[DirectiveFile]) -> String {
let mut always: Vec<&str> = Vec::new();
let mut conditional: Vec<(&str, &str)> = Vec::new();
let mut on_demand: Vec<(&str, &str)> = Vec::new();
for f in files {
match &f.tier {
DirectiveTier::Always => always.push(&f.rel_path),
DirectiveTier::Conditional(p) => conditional.push((&f.rel_path, p)),
DirectiveTier::OnDemand(d) => on_demand.push((&f.rel_path, d)),
}
}
let mut out = format!("--- Project Directive Files (in {}/) ---\n", root_display);
out.push_str(
"Directive / rule files from other AI coding tools were found in this project. \
They hold project-specific conventions that take precedence over your defaults. \
Load with `read_file` when relevant.\n",
);
if !always.is_empty() {
out.push_str("\nAlways apply (read when working in this project):\n ");
out.push_str(&always.join(", "));
out.push('\n');
}
if !conditional.is_empty() {
out.push_str("\nConditional (read when touching matching files):\n");
for (path, pat) in &conditional {
out.push_str(&format!(" {} (matches: {})\n", path, pat));
}
}
if !on_demand.is_empty() {
out.push_str("\nOn-demand (read when the description matches your task):\n");
for (path, desc) in &on_demand {
out.push_str(&format!(" {}: \"{}\"\n", path, desc));
}
}
out.push('\n');
out
}