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 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,
},
Target {
path: PathBuf::from("AGENTS.md"),
kind: DocKind::Context,
},
Target {
path: PathBuf::from("GEMINI.md"),
kind: DocKind::Context,
},
Target {
path: PathBuf::from(".cursorrules"),
kind: DocKind::Rules,
},
Target {
path: PathBuf::from(".github").join("copilot-instructions.md"),
kind: DocKind::Context,
},
];
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,
});
out.push(Target {
path: home.join(".codex").join("AGENTS.md"),
kind: DocKind::Context,
});
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> {
known_targets()
.into_iter()
.filter(|t| t.path.is_file())
.collect()
}
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);
targets.push(Target {
path: p.clone(),
kind,
});
} else if p.is_dir() {
let walked = walk_targets(p);
if walked.is_empty() {
missing.push(p.clone());
} else {
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) {
out.push(Target { path, kind });
}
}
}
}
#[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);
}
}