use crate::skills::model::DocKind;
use std::path::{Path, PathBuf};
const MAX_WALK_DEPTH: usize = 6;
pub struct Target {
pub path: PathBuf,
pub kind: DocKind,
pub skill_root: Option<PathBuf>,
}
pub fn classify(path: &Path) -> Option<DocKind> {
let name = path.file_name()?.to_string_lossy();
match name.as_ref() {
"SKILL.md" => Some(DocKind::Skill),
"CLAUDE.md" | "AGENTS.md" | "GEMINI.md" | "copilot-instructions.md" => {
Some(DocKind::Context)
}
".cursorrules" => Some(DocKind::Rules),
_ if name.ends_with(".mdc") => Some(DocKind::Rules),
_ => None,
}
}
pub fn known_targets() -> Vec<Target> {
let mut out = vec![
Target {
path: PathBuf::from("CLAUDE.md"),
kind: DocKind::Context,
skill_root: None,
},
Target {
path: PathBuf::from("AGENTS.md"),
kind: DocKind::Context,
skill_root: None,
},
Target {
path: PathBuf::from("GEMINI.md"),
kind: DocKind::Context,
skill_root: None,
},
Target {
path: PathBuf::from(".cursorrules"),
kind: DocKind::Rules,
skill_root: None,
},
Target {
path: PathBuf::from(".github").join("copilot-instructions.md"),
kind: DocKind::Context,
skill_root: None,
},
];
for t in walk_targets(Path::new(".claude").join("skills").as_path()) {
out.push(t);
}
for t in walk_targets(Path::new(".cursor").join("rules").as_path()) {
out.push(t);
}
if let Some(home) = dirs::home_dir() {
out.push(Target {
path: home.join(".claude").join("CLAUDE.md"),
kind: DocKind::Context,
skill_root: None,
});
out.push(Target {
path: home.join(".codex").join("AGENTS.md"),
kind: DocKind::Context,
skill_root: None,
});
for t in walk_targets(&home.join(".claude").join("skills")) {
out.push(t);
}
for t in walk_targets(&home.join(".claude").join("plugins").join("cache")) {
out.push(t);
}
}
out
}
pub fn existing_targets() -> Vec<Target> {
let mut targets: Vec<Target> = known_targets()
.into_iter()
.filter(|t| t.path.is_file())
.collect();
add_aux_targets(&mut targets);
targets
}
pub fn resolve_explicit(paths: &[PathBuf]) -> (Vec<Target>, Vec<PathBuf>) {
let mut targets = Vec::new();
let mut missing = Vec::new();
for p in paths {
if p.is_file() {
let kind = classify(p).unwrap_or(DocKind::Context);
let skill_root = if kind == DocKind::Skill {
p.parent().map(Path::to_path_buf)
} else {
None
};
targets.push(Target {
path: p.clone(),
kind,
skill_root,
});
} else if p.is_dir() {
let mut walked = walk_targets(p);
if walked.is_empty() {
missing.push(p.clone());
} else {
add_aux_targets(&mut walked);
targets.extend(walked);
}
} else {
missing.push(p.clone());
}
}
(targets, missing)
}
fn walk_targets(root: &Path) -> Vec<Target> {
let mut out = Vec::new();
walk_inner(root, 0, &mut out);
out
}
fn walk_inner(dir: &Path, depth: usize, out: &mut Vec<Target>) {
if std::fs::symlink_metadata(dir)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return;
}
if depth > MAX_WALK_DEPTH {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if meta.file_type().is_symlink() {
continue; }
if meta.is_dir() {
walk_inner(&path, depth + 1, out);
} else if meta.is_file() {
if let Some(kind) = classify(&path) {
let skill_root = if kind == DocKind::Skill {
path.parent().map(Path::to_path_buf)
} else {
None
};
out.push(Target {
path,
kind,
skill_root,
});
}
}
}
}
pub fn add_aux_targets(targets: &mut Vec<Target>) {
use std::collections::BTreeSet;
let roots: BTreeSet<PathBuf> = targets
.iter()
.filter(|t| t.kind == DocKind::Skill)
.filter_map(|t| t.skill_root.clone())
.collect();
for t in targets.iter_mut() {
if t.skill_root.is_some() || !matches!(t.kind, DocKind::Context | DocKind::Rules) {
continue;
}
if let Some(owner) = roots
.iter()
.filter(|r| t.path.starts_with(r))
.max_by_key(|r| r.as_os_str().len())
{
t.skill_root = Some(owner.clone());
}
}
for root in roots {
let mut aux = Vec::new();
aux_walk(&root, &root, 0, &mut aux);
targets.extend(aux);
}
}
fn aux_walk(root: &Path, dir: &Path, depth: usize, out: &mut Vec<Target>) {
if depth > MAX_WALK_DEPTH {
return;
}
if std::fs::symlink_metadata(dir)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if meta.file_type().is_symlink() {
continue;
}
if meta.is_dir() {
if std::fs::symlink_metadata(path.join("SKILL.md"))
.map(|m| m.is_file())
.unwrap_or(false)
{
continue;
}
aux_walk(root, &path, depth + 1, out);
} else if meta.is_file() && classify(&path).is_none() {
out.push(Target {
path,
kind: DocKind::Aux,
skill_root: Some(root.to_path_buf()),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skills::model::DocKind;
use std::fs;
use std::path::PathBuf;
fn tmp() -> PathBuf {
let d = std::env::temp_dir().join(format!(
"vallum_skills_disc_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn classify_recognizes_names() {
assert_eq!(
classify(std::path::Path::new("SKILL.md")),
Some(DocKind::Skill)
);
assert_eq!(
classify(std::path::Path::new("CLAUDE.md")),
Some(DocKind::Context)
);
assert_eq!(
classify(std::path::Path::new("AGENTS.md")),
Some(DocKind::Context)
);
assert_eq!(
classify(std::path::Path::new(".cursorrules")),
Some(DocKind::Rules)
);
assert_eq!(
classify(std::path::Path::new("rules.mdc")),
Some(DocKind::Rules)
);
assert_eq!(classify(std::path::Path::new("README.md")), None);
}
#[test]
fn known_targets_nonempty() {
assert!(!known_targets().is_empty());
}
#[test]
fn resolve_explicit_file_is_classified() {
let d = tmp();
let f = d.join("CLAUDE.md");
fs::write(&f, "x").unwrap();
let (targets, missing) = resolve_explicit(std::slice::from_ref(&f));
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].kind, DocKind::Context);
assert!(missing.is_empty());
let _ = fs::remove_dir_all(&d);
}
#[test]
fn resolve_explicit_dir_walks_recognized_files() {
let d = tmp();
fs::create_dir_all(d.join("my-skill")).unwrap();
fs::write(d.join("my-skill").join("SKILL.md"), "x").unwrap();
fs::write(d.join("noise.txt"), "x").unwrap();
let (targets, missing) = resolve_explicit(std::slice::from_ref(&d));
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].kind, DocKind::Skill);
assert!(missing.is_empty());
let _ = fs::remove_dir_all(&d);
}
#[test]
fn resolve_explicit_empty_dir_is_reported_missing() {
let d = tmp();
let (targets, missing) = resolve_explicit(std::slice::from_ref(&d));
assert!(targets.is_empty());
assert_eq!(missing, vec![d.clone()]);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn resolve_explicit_absent_path_is_reported_missing() {
let p = PathBuf::from("/no/such/skills/here-xyz");
let (targets, missing) = resolve_explicit(std::slice::from_ref(&p));
assert!(targets.is_empty());
assert_eq!(missing, vec![p]);
}
#[test]
fn symlinked_walk_root_is_not_followed() {
let d = tmp();
let real = d.join("real");
fs::create_dir_all(&real).unwrap();
fs::write(real.join("SKILL.md"), "x").unwrap();
let link = d.join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let (targets, missing) = resolve_explicit(std::slice::from_ref(&link));
assert!(targets.is_empty(), "symlinked root must not be walked");
assert_eq!(missing, vec![link]);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn aux_targets_collected_under_skill_root() {
let d = tmp();
fs::create_dir_all(d.join("my-skill").join("scripts")).unwrap();
fs::write(d.join("my-skill").join("SKILL.md"), "x").unwrap();
fs::write(d.join("my-skill").join("payload.txt"), "x").unwrap();
fs::write(d.join("my-skill").join("scripts").join("run.py"), "x").unwrap();
let (targets, missing) = resolve_explicit(std::slice::from_ref(&d));
assert!(missing.is_empty());
let aux: Vec<_> = targets.iter().filter(|t| t.kind == DocKind::Aux).collect();
assert_eq!(aux.len(), 2, "payload.txt + scripts/run.py");
for t in &aux {
assert_eq!(t.skill_root.as_deref(), Some(d.join("my-skill").as_path()));
}
let skill: Vec<_> = targets
.iter()
.filter(|t| t.kind == DocKind::Skill)
.collect();
assert_eq!(skill.len(), 1);
assert_eq!(
skill[0].skill_root.as_deref(),
Some(d.join("my-skill").as_path())
);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn no_aux_collection_without_skill_md() {
let d = tmp();
fs::write(d.join("CLAUDE.md"), "x").unwrap();
fs::write(d.join("random.txt"), "x").unwrap();
let (targets, _missing) = resolve_explicit(std::slice::from_ref(&d));
assert!(targets.iter().all(|t| t.kind != DocKind::Aux));
let _ = fs::remove_dir_all(&d);
}
#[test]
fn explicit_skill_md_file_does_not_pull_aux_siblings() {
let d = tmp();
fs::create_dir_all(d.join("s")).unwrap();
let f = d.join("s").join("SKILL.md");
fs::write(&f, "x").unwrap();
fs::write(d.join("s").join("payload.txt"), "x").unwrap();
let (targets, _m) = resolve_explicit(std::slice::from_ref(&f));
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].kind, DocKind::Skill);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn nested_skill_root_files_belong_to_nearest_root_only() {
let d = tmp();
fs::create_dir_all(d.join("outer").join("inner")).unwrap();
fs::write(d.join("outer").join("SKILL.md"), "x").unwrap();
fs::write(d.join("outer").join("a.txt"), "x").unwrap();
fs::write(d.join("outer").join("inner").join("SKILL.md"), "x").unwrap();
fs::write(d.join("outer").join("inner").join("b.txt"), "x").unwrap();
let (targets, _m) = resolve_explicit(std::slice::from_ref(&d));
let aux: Vec<_> = targets.iter().filter(|t| t.kind == DocKind::Aux).collect();
assert_eq!(
aux.len(),
2,
"each file exactly once: {:?}",
aux.iter().map(|t| &t.path).collect::<Vec<_>>()
);
let b = aux
.iter()
.find(|t| t.path.ends_with("b.txt"))
.expect("b.txt collected");
assert_eq!(
b.skill_root.as_deref(),
Some(d.join("outer").join("inner").as_path()),
"b.txt owned by nearest root"
);
let a = aux
.iter()
.find(|t| t.path.ends_with("a.txt"))
.expect("a.txt collected");
assert_eq!(a.skill_root.as_deref(), Some(d.join("outer").as_path()));
let _ = fs::remove_dir_all(&d);
}
#[test]
fn context_file_inside_skill_package_gets_skill_root() {
let d = tmp();
fs::create_dir_all(d.join("pkg")).unwrap();
fs::write(d.join("pkg").join("SKILL.md"), "x").unwrap();
fs::write(d.join("pkg").join("AGENTS.md"), "x").unwrap();
fs::write(d.join("CLAUDE.md"), "x").unwrap(); let (targets, _m) = resolve_explicit(std::slice::from_ref(&d));
let agents = targets
.iter()
.find(|t| t.path.ends_with("AGENTS.md"))
.expect("AGENTS.md found");
assert_eq!(
agents.skill_root.as_deref(),
Some(d.join("pkg").as_path()),
"in-package context file joins its skill"
);
let top = targets
.iter()
.find(|t| t.path.ends_with("CLAUDE.md"))
.expect("CLAUDE.md found");
assert_eq!(top.skill_root, None, "top-level context file stays unowned");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn aux_walk_skips_symlinked_files() {
let d = tmp();
fs::create_dir_all(d.join("s")).unwrap();
fs::write(d.join("s").join("SKILL.md"), "x").unwrap();
fs::write(d.join("outside.txt"), "x").unwrap();
std::os::unix::fs::symlink(d.join("outside.txt"), d.join("s").join("link.txt")).unwrap();
let (targets, _m) = resolve_explicit(std::slice::from_ref(&d));
assert!(targets.iter().all(|t| t.kind != DocKind::Aux));
let _ = fs::remove_dir_all(&d);
}
#[test]
fn symlinked_skill_md_does_not_hide_a_subdir_from_aux_scan() {
let d = tmp();
fs::create_dir_all(d.join("s").join("payload")).unwrap();
fs::write(d.join("s").join("SKILL.md"), "x").unwrap();
std::os::unix::fs::symlink(
d.join("s").join("SKILL.md"),
d.join("s").join("payload").join("SKILL.md"),
)
.unwrap();
fs::write(d.join("s").join("payload").join("evil.txt"), "x").unwrap();
let (targets, _m) = resolve_explicit(std::slice::from_ref(&d));
let aux: Vec<_> = targets.iter().filter(|t| t.kind == DocKind::Aux).collect();
assert!(
aux.iter().any(|t| t.path.ends_with("evil.txt")),
"symlinked SKILL.md must not exclude the payload dir from the outer walk: {:?}",
aux.iter().map(|t| &t.path).collect::<Vec<_>>()
);
assert_eq!(
aux.iter()
.find(|t| t.path.ends_with("evil.txt"))
.unwrap()
.skill_root
.as_deref(),
Some(d.join("s").as_path())
);
let _ = fs::remove_dir_all(&d);
}
}