use std::path::{Path, PathBuf};
use super::registry::SkillRegistry;
const MAX_WALK_DEPTH: usize = 8;
pub fn search_dirs(root: Option<&Path>, extra: &[PathBuf]) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = extra.to_vec();
let cwd = root
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let mut current = cwd.as_path();
let mut depth = 0usize;
loop {
dirs.push(current.join(".opencode").join("skills"));
dirs.push(current.join(".claude").join("skills"));
if current.join(".git").exists() {
break;
}
depth += 1;
if depth >= MAX_WALK_DEPTH {
break;
}
match current.parent() {
Some(parent) if parent != current => current = parent,
_ => break,
}
}
if let Some(home) = std::env::var_os("HOME") {
dirs.push(
PathBuf::from(home)
.join(".config")
.join("heartbit")
.join("skills"),
);
}
dirs
}
pub fn catalog_for(root: Option<&Path>, extra: &[PathBuf]) -> Option<String> {
let dirs = search_dirs(root, extra);
SkillRegistry::discover(&dirs).system_prompt_section()
}
pub fn catalog_from_dirs(dirs: &[PathBuf]) -> Option<String> {
if dirs.is_empty() {
return None;
}
SkillRegistry::discover(dirs).system_prompt_section()
}
#[cfg(test)]
mod tests {
use super::*;
fn write_skill(parent: &Path, name: &str, description: &str) {
let dir = parent.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: {description}\n---\n# {name}\n\nbody\n"),
)
.unwrap();
}
#[test]
fn search_dirs_puts_extra_first_then_conventional() {
let extra = vec![PathBuf::from("/custom/skills")];
let dirs = search_dirs(Some(Path::new("/tmp/proj")), &extra);
assert_eq!(dirs[0], PathBuf::from("/custom/skills"), "extra is first");
assert!(
dirs.iter().any(|d| d.ends_with(".claude/skills")),
"conventional .claude/skills present: {dirs:?}"
);
}
#[test]
fn catalog_for_lists_skills_from_extra_dir() {
let tmp = tempfile::tempdir().unwrap();
write_skill(tmp.path(), "alpha", "Does alpha.");
let section = catalog_for(Some(Path::new("/nonexistent")), &[tmp.path().to_path_buf()])
.expect("catalog");
assert!(section.contains("alpha: Does alpha."));
assert!(section.contains("`skill` tool"));
}
#[test]
fn catalog_for_returns_none_when_no_skills() {
let tmp = tempfile::tempdir().unwrap();
let section = catalog_for(Some(tmp.path()), &[]);
assert!(section.is_none());
}
#[test]
fn catalog_from_dirs_is_opt_in_and_explicit() {
assert!(catalog_from_dirs(&[]).is_none());
let tmp = tempfile::tempdir().unwrap();
write_skill(tmp.path(), "alpha", "Does alpha.");
let section = catalog_from_dirs(&[tmp.path().to_path_buf()]).expect("catalog");
assert!(section.contains("alpha: Does alpha."));
}
#[test]
fn catalog_from_dirs_does_not_walk_ancestors() {
let parent = tempfile::tempdir().unwrap();
write_skill(parent.path(), "ancestor-skill", "Injected from parent.");
let child = parent.path().join("child");
std::fs::create_dir_all(&child).unwrap();
let section = catalog_from_dirs(&[child]);
assert!(
section.is_none(),
"catalog_from_dirs must not climb to the parent's skills"
);
}
#[test]
fn catalog_and_search_dirs_agree() {
let tmp = tempfile::tempdir().unwrap();
write_skill(tmp.path(), "shared", "Shared skill.");
let extra = vec![tmp.path().to_path_buf()];
let section = catalog_for(Some(Path::new("/nope")), &extra).expect("catalog");
assert!(section.contains("shared: Shared skill."));
let dirs = search_dirs(Some(Path::new("/nope")), &extra);
let reg = SkillRegistry::discover(&dirs);
assert!(
reg.get("shared").is_some(),
"the advertised skill must be loadable from the same search_dirs"
);
}
}