use std::path::{Path, PathBuf};
use crate::agents::AgentRegistry;
use crate::types::{AGENTS_DIR, AgentConfig, InstallScope, SKILLS_SUBDIR};
#[must_use]
pub fn sanitize_name(name: &str) -> String {
let sanitized: String = name
.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_' {
c
} else {
'-'
}
})
.collect();
let mut collapsed = String::with_capacity(sanitized.len());
let mut prev_hyphen = false;
for ch in sanitized.chars() {
if ch == '-' {
if !prev_hyphen {
collapsed.push(ch);
}
prev_hyphen = true;
} else {
collapsed.push(ch);
prev_hyphen = false;
}
}
let trimmed = collapsed.trim_matches(|c: char| c == '.' || c == '-');
if trimmed.is_empty() {
return "unnamed-skill".to_owned();
}
if trimmed.len() <= 255 {
return trimmed.to_owned();
}
let mut end = 255;
while !trimmed.is_char_boundary(end) {
end -= 1;
}
trimmed[..end].to_owned()
}
pub(super) fn is_path_safe(base_path: &Path, target_path: &Path) -> bool {
crate::path_util::normalize_absolute(target_path)
.starts_with(crate::path_util::normalize_absolute(base_path))
}
#[must_use]
pub fn canonical_skills_dir(scope: InstallScope, cwd: &Path) -> PathBuf {
let base = match scope {
InstallScope::Global => dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")),
InstallScope::Project => cwd.to_path_buf(),
};
base.join(AGENTS_DIR).join(SKILLS_SUBDIR)
}
#[must_use]
pub fn agent_base_dir(
agent: &AgentConfig,
registry: &AgentRegistry,
scope: InstallScope,
cwd: &Path,
) -> PathBuf {
if registry.is_universal(&agent.name) {
return canonical_skills_dir(scope, cwd);
}
match scope {
InstallScope::Global => agent
.global_skills_dir
.clone()
.unwrap_or_else(|| cwd.join(&agent.skills_dir)),
InstallScope::Project => cwd.join(&agent.skills_dir),
}
}
#[must_use]
pub fn get_canonical_path(skill_name: &str, scope: InstallScope, cwd: &Path) -> PathBuf {
canonical_skills_dir(scope, cwd).join(sanitize_name(skill_name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_basic() {
assert_eq!(sanitize_name("My Skill Name"), "my-skill-name");
assert_eq!(sanitize_name("../../evil"), "evil");
assert_eq!(sanitize_name("hello_world.v2"), "hello_world.v2");
}
#[test]
fn sanitize_empty_and_dots() {
assert_eq!(sanitize_name("..."), "unnamed-skill");
assert_eq!(sanitize_name(""), "unnamed-skill");
assert_eq!(sanitize_name("---"), "unnamed-skill");
}
#[test]
fn sanitize_consecutive_hyphens_collapsed() {
assert_eq!(sanitize_name("a b c"), "a-b-c");
assert_eq!(sanitize_name("a---b"), "a-b");
}
#[test]
fn sanitize_unicode() {
assert_eq!(sanitize_name("日本語スキル"), "unnamed-skill");
assert_eq!(sanitize_name("café-skill"), "caf-skill");
}
#[test]
fn sanitize_truncates_at_255() {
let long = "a".repeat(300);
let result = sanitize_name(&long);
assert!(result.len() <= 255);
assert_eq!(result.len(), 255);
}
#[test]
fn path_safe_rejects_traversal() {
let base = Path::new("/home/user/.agents/skills");
let evil = base.join("../../etc/passwd");
assert!(!is_path_safe(base, &evil));
}
#[test]
fn path_safe_accepts_child() {
let base = Path::new("/home/user/.agents/skills");
let child = base.join("my-skill");
assert!(is_path_safe(base, &child));
}
#[test]
fn path_safe_accepts_nested_child() {
let base = Path::new("/home/user/.agents/skills");
let nested = base.join("group/sub-skill");
assert!(is_path_safe(base, &nested));
}
#[test]
fn path_safe_rejects_sibling() {
let base = Path::new("/home/user/.agents/skills");
let sibling = Path::new("/home/user/.agents/other");
assert!(!is_path_safe(base, sibling));
}
#[test]
fn canonical_skills_dir_project() {
let cwd = Path::new("/project");
let dir = canonical_skills_dir(InstallScope::Project, cwd);
assert_eq!(dir, PathBuf::from("/project/.agents/skills"));
}
#[test]
fn get_canonical_path_sanitizes() {
let cwd = Path::new("/project");
let path = get_canonical_path("My Skill!", InstallScope::Project, cwd);
assert_eq!(path, PathBuf::from("/project/.agents/skills/my-skill"));
}
}