llman 0.0.48

A tool for managing LLM application rules(prompts) ...
Documentation
use crate::skills::catalog::types::SkillCandidate;
use anyhow::Result;
use ignore::WalkBuilder;
use serde_yaml::Value;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

pub fn discover_skills(root: &Path) -> Result<Vec<SkillCandidate>> {
    discover_skills_with_global_ignore(root, None)
}

fn discover_skills_with_global_ignore(
    root: &Path,
    global_ignore: Option<&Path>,
) -> Result<Vec<SkillCandidate>> {
    let mut candidates = Vec::new();
    if !root.exists() {
        return Ok(candidates);
    }

    let mut seen_dirs: HashSet<PathBuf> = HashSet::new();
    let store_dir = root.join("store");
    let mut builder = WalkBuilder::new(root);
    builder
        .hidden(false)
        .follow_links(false)
        .git_ignore(true)
        .git_exclude(true)
        .require_git(false)
        .filter_entry(move |entry| entry.path() != store_dir);
    if let Some(ignore_path) = global_ignore {
        builder.git_global(false).current_dir(root);
        if let Some(err) = builder.add_ignore(ignore_path) {
            return Err(err.into());
        }
    } else {
        builder.git_global(true);
    }
    let walker = builder.build();

    for entry in walker {
        let entry = entry?;
        let path = entry.path();
        if path.file_name().is_some_and(|name| name == "SKILL.md") {
            if !skill_file_exists(path) {
                continue;
            }
            let Some(skill_dir) = path.parent() else {
                continue;
            };
            record_skill_dir(skill_dir, path, &mut seen_dirs, &mut candidates);
            continue;
        }
        if entry
            .file_type()
            .is_some_and(|file_type| file_type.is_symlink())
            && is_symlink_dir(path)
            && let Some(skill_file) = resolve_symlink_skill_file(path)
        {
            record_skill_dir(path, &skill_file, &mut seen_dirs, &mut candidates);
        }
    }

    Ok(candidates)
}

fn record_skill_dir(
    skill_dir: &Path,
    skill_file: &Path,
    seen_dirs: &mut HashSet<PathBuf>,
    candidates: &mut Vec<SkillCandidate>,
) {
    let canonical = match fs::canonicalize(skill_dir) {
        Ok(path) => path,
        Err(_) => return,
    };
    if !seen_dirs.insert(canonical) {
        return;
    }
    let skill_id = resolve_skill_id(skill_dir, skill_file);
    candidates.push(SkillCandidate {
        skill_id,
        skill_dir: skill_dir.to_path_buf(),
    });
}

fn resolve_skill_id(skill_dir: &Path, skill_file: &Path) -> String {
    let fallback = skill_dir
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("skill")
        .to_string();
    let Some(frontmatter_name) = read_frontmatter_name(skill_file) else {
        return fallback;
    };
    let slug = slugify(&frontmatter_name);
    if slug.is_empty() { fallback } else { slug }
}

fn read_frontmatter_name(path: &Path) -> Option<String> {
    let content = fs::read_to_string(path).ok()?;
    let mut lines = content.lines();
    if lines.next()? != "---" {
        return None;
    }
    let mut yaml = String::new();
    for line in lines {
        if line.trim() == "---" {
            break;
        }
        yaml.push_str(line);
        yaml.push('\n');
    }
    if yaml.trim().is_empty() {
        return None;
    }
    let parsed: Value = serde_yaml::from_str(&yaml).ok()?;
    parsed
        .get("name")
        .and_then(|value| value.as_str())
        .map(|value| value.to_string())
}

fn is_symlink_dir(path: &Path) -> bool {
    fs::symlink_metadata(path)
        .map(|meta| meta.file_type().is_symlink())
        .unwrap_or(false)
}

fn resolve_symlink_skill_file(path: &Path) -> Option<PathBuf> {
    let meta = fs::metadata(path).ok()?;
    if !meta.is_dir() {
        return None;
    }
    let skill_file = path.join("SKILL.md");
    if skill_file_exists(&skill_file) {
        Some(skill_file)
    } else {
        None
    }
}

fn skill_file_exists(path: &Path) -> bool {
    if let Ok(meta) = fs::symlink_metadata(path) {
        if meta.file_type().is_symlink() {
            return fs::metadata(path).map(|m| m.is_file()).unwrap_or(false);
        }
        return meta.is_file();
    }
    false
}

pub fn slugify(input: &str) -> String {
    let mut out = String::new();
    let mut prev_dash = false;
    for ch in input.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            prev_dash = false;
        } else if !prev_dash {
            out.push('-');
            prev_dash = true;
        }
    }
    let trimmed = out.trim_matches('-');
    let mut truncated = trimmed.chars().take(64).collect::<String>();
    if truncated.ends_with('-') {
        truncated = truncated.trim_end_matches('-').to_string();
    }
    truncated
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn test_slugify() {
        assert_eq!(slugify("Slint GUI Expert"), "slint-gui-expert");
        assert_eq!(slugify("***"), "");
    }

    #[test]
    fn test_skill_id_fallback() {
        let temp = TempDir::new().expect("temp dir");
        let skill_dir = temp.path().join("MySkill");
        fs::create_dir_all(&skill_dir).expect("create dir");
        let skill_file = skill_dir.join("SKILL.md");
        fs::write(&skill_file, "# no frontmatter").expect("write file");
        let id = resolve_skill_id(&skill_dir, &skill_file);
        assert_eq!(id, "MySkill");
    }

    #[cfg(unix)]
    #[test]
    fn test_discover_respects_ignore_and_symlink_skill() {
        use std::os::unix::fs as unix_fs;

        let temp = TempDir::new().expect("temp dir");
        let root = temp.path().join("source");
        fs::create_dir_all(&root).expect("create source");
        fs::write(root.join(".gitignore"), "ignored-skill/\n").expect("write gitignore");

        let ignored = root.join("ignored-skill");
        fs::create_dir_all(&ignored).expect("create ignored skill");
        fs::write(ignored.join("SKILL.md"), "# ignored").expect("write skill");

        let kept = root.join("kept-skill");
        fs::create_dir_all(&kept).expect("create kept skill");
        fs::write(kept.join("SKILL.md"), "---\nname: Keep Me\n---\n").expect("write skill");

        let linked = root.join("linked-skill");
        fs::create_dir_all(&linked).expect("create linked skill");
        fs::write(linked.join("SKILL.md"), "---\nname: Linked Skill\n---\n").expect("write skill");

        let symlinked = root.join("symlink-skill");
        unix_fs::symlink(&linked, &symlinked).expect("create symlink dir");

        let template = root.join("template-skill.md");
        fs::write(&template, "---\nname: File Linked\n---\n").expect("write template");
        let symlink_file_dir = root.join("symlink-file-skill");
        fs::create_dir_all(&symlink_file_dir).expect("create symlink file dir");
        unix_fs::symlink(&template, symlink_file_dir.join("SKILL.md"))
            .expect("create symlink file");

        let mut discovered = discover_skills(&root).expect("discover skills");
        discovered.sort_by(|a, b| a.skill_id.cmp(&b.skill_id));
        assert_eq!(discovered.len(), 3);
        assert_eq!(discovered[0].skill_id, "file-linked");
        assert_eq!(discovered[1].skill_id, "keep-me");
        assert_eq!(discovered[2].skill_id, "linked-skill");
    }

    #[test]
    fn test_discover_respects_global_ignore() {
        let temp = TempDir::new().expect("temp dir");
        let global_ignore = temp.path().join("global-ignore");
        fs::write(&global_ignore, "global-skill/\n").expect("write global ignore");

        let root = temp.path().join("source");
        fs::create_dir_all(&root).expect("create source");
        let ignored = root.join("global-skill");
        fs::create_dir_all(&ignored).expect("create ignored skill");
        fs::write(ignored.join("SKILL.md"), "# ignored").expect("write skill");

        let kept = root.join("kept-skill");
        fs::create_dir_all(&kept).expect("create kept skill");
        fs::write(kept.join("SKILL.md"), "# kept").expect("write skill");

        let discovered = discover_skills_with_global_ignore(&root, Some(&global_ignore))
            .expect("discover skills");
        assert_eq!(discovered.len(), 1);
        assert_eq!(discovered[0].skill_id, "kept-skill");
    }
}