mentra 0.6.0

An agent runtime for tool-using LLM applications
Documentation
use std::{
    collections::BTreeMap,
    fs,
    path::{Path, PathBuf},
};

use serde::Deserialize;
use thiserror::Error;

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct SkillLoader {
    skills: BTreeMap<String, SkillEntry>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct SkillEntry {
    description: String,
    body: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SkillLoadError {
    #[error("failed to read skills directory {path}: {message}")]
    ReadDir { path: PathBuf, message: String },
    #[error("failed to read skill file {path}: {message}")]
    ReadFile { path: PathBuf, message: String },
    #[error("invalid skill frontmatter in {path}: {message}")]
    InvalidFrontmatter { path: PathBuf, message: String },
    #[error("duplicate skill name '{name}' in {first_path} and {second_path}")]
    DuplicateSkillName {
        name: String,
        first_path: PathBuf,
        second_path: PathBuf,
    },
}

#[derive(Debug, Clone, Default, Deserialize)]
struct SkillFrontmatter {
    name: Option<String>,
    description: Option<String>,
}

impl SkillLoader {
    pub(crate) fn from_dir(path: impl AsRef<Path>) -> Result<Self, SkillLoadError> {
        let root = path.as_ref().to_path_buf();
        let mut files = Vec::new();
        collect_skill_files(&root, &mut files)?;
        files.sort();

        let mut skills = BTreeMap::new();
        let mut skill_paths = BTreeMap::new();

        for file in files {
            let raw = fs::read_to_string(&file).map_err(|error| SkillLoadError::ReadFile {
                path: file.clone(),
                message: error.to_string(),
            })?;
            let (meta, body) = parse_skill_file(&file, &raw)?;

            let fallback_name = file
                .parent()
                .and_then(Path::file_name)
                .and_then(|value| value.to_str())
                .unwrap_or("skill");
            let name = meta
                .name
                .as_deref()
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .unwrap_or(fallback_name)
                .to_string();

            if let Some(first_path) = skill_paths.insert(name.clone(), file.clone()) {
                return Err(SkillLoadError::DuplicateSkillName {
                    name,
                    first_path,
                    second_path: file,
                });
            }

            let description = meta.description.unwrap_or_default().trim().to_string();
            skills.insert(name, SkillEntry { description, body });
        }

        Ok(Self { skills })
    }

    pub(crate) fn get_descriptions(&self) -> String {
        if self.skills.is_empty() {
            return String::new();
        }

        let mut lines = vec!["Skills available:".to_string()];
        for (name, skill) in &self.skills {
            lines.push(format!("  - {name}: {}", skill.description));
        }
        lines.push(
            "Use the load_skill tool only when one of these skills is relevant to the task."
                .to_string(),
        );
        lines.join("\n")
    }

    pub(crate) fn get_content(&self, name: &str) -> Result<String, String> {
        let Some(skill) = self.skills.get(name) else {
            return Err(format!("Unknown skill '{name}'"));
        };

        let body = skill.body.trim_end_matches(['\n', '\r']);
        Ok(format!("<skill name=\"{name}\">\n{body}\n</skill>"))
    }
}

fn collect_skill_files(path: &Path, files: &mut Vec<PathBuf>) -> Result<(), SkillLoadError> {
    let entries = fs::read_dir(path).map_err(|error| SkillLoadError::ReadDir {
        path: path.to_path_buf(),
        message: error.to_string(),
    })?;

    for entry in entries {
        let entry = entry.map_err(|error| SkillLoadError::ReadDir {
            path: path.to_path_buf(),
            message: error.to_string(),
        })?;
        let entry_path = entry.path();
        let file_type = entry.file_type().map_err(|error| SkillLoadError::ReadDir {
            path: entry_path.clone(),
            message: error.to_string(),
        })?;

        if file_type.is_dir() {
            collect_skill_files(&entry_path, files)?;
        } else if file_type.is_file() && entry.file_name() == "SKILL.md" {
            files.push(entry_path);
        }
    }

    Ok(())
}

fn parse_skill_file(path: &Path, raw: &str) -> Result<(SkillFrontmatter, String), SkillLoadError> {
    let Some(opening_len) = raw
        .strip_prefix("---\r\n")
        .map(|_| 5)
        .or_else(|| raw.strip_prefix("---\n").map(|_| 4))
    else {
        return Ok((SkillFrontmatter::default(), raw.to_string()));
    };

    let rest = &raw[opening_len..];
    let mut cursor = 0usize;
    for segment in rest.split_inclusive('\n') {
        let line = segment.trim_end_matches(['\n', '\r']);
        if line == "---" {
            let frontmatter = &rest[..cursor];
            let body = &rest[cursor + segment.len()..];
            let meta = serde_yaml_ng::from_str(frontmatter).map_err(|error| {
                SkillLoadError::InvalidFrontmatter {
                    path: path.to_path_buf(),
                    message: error.to_string(),
                }
            })?;
            return Ok((meta, body.to_string()));
        }
        cursor += segment.len();
    }

    if rest[cursor..].trim_end_matches('\r') == "---" {
        let frontmatter = &rest[..cursor];
        let meta = serde_yaml_ng::from_str(frontmatter).map_err(|error| {
            SkillLoadError::InvalidFrontmatter {
                path: path.to_path_buf(),
                message: error.to_string(),
            }
        })?;
        return Ok((meta, String::new()));
    }

    Err(SkillLoadError::InvalidFrontmatter {
        path: path.to_path_buf(),
        message: "missing closing frontmatter delimiter".to_string(),
    })
}

#[cfg(test)]
mod tests {
    use std::{
        fs,
        path::{Path, PathBuf},
        sync::atomic::{AtomicU64, Ordering},
        time::{SystemTime, UNIX_EPOCH},
    };

    use super::{SkillLoadError, SkillLoader};

    static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(1);

    #[test]
    fn parses_frontmatter_and_strips_it_from_content() {
        let root = temp_skills_dir("frontmatter");
        write_skill(
            &root,
            "git",
            "---\nname: git\ndescription: Git helpers\n---\nStep 1\nStep 2\n",
        );

        let loader = SkillLoader::from_dir(&root).expect("load skills");

        assert_eq!(
            loader.get_descriptions(),
            "Skills available:\n  - git: Git helpers\nUse the load_skill tool only when one of these skills is relevant to the task."
        );
        assert_eq!(
            loader.get_content("git").expect("git skill"),
            "<skill name=\"git\">\nStep 1\nStep 2\n</skill>"
        );
    }

    #[test]
    fn falls_back_to_directory_name_when_name_is_missing() {
        let root = temp_skills_dir("fallback-name");
        write_skill(
            &root,
            "pdf",
            "---\ndescription: Process PDFs\n---\nRead pages\n",
        );

        let loader = SkillLoader::from_dir(&root).expect("load skills");

        assert!(loader.get_descriptions().contains("  - pdf: Process PDFs"));
        assert!(loader.get_content("pdf").is_ok());
    }

    #[test]
    fn renders_descriptions_in_sorted_order() {
        let root = temp_skills_dir("sorted");
        write_skill(
            &root,
            "b-skill",
            "---\nname: zebra\ndescription: Last\n---\nB\n",
        );
        write_skill(
            &root,
            "a-skill",
            "---\nname: alpha\ndescription: First\n---\nA\n",
        );

        let loader = SkillLoader::from_dir(&root).expect("load skills");

        assert_eq!(
            loader.get_descriptions(),
            "Skills available:\n  - alpha: First\n  - zebra: Last\nUse the load_skill tool only when one of these skills is relevant to the task."
        );
    }

    #[test]
    fn rejects_duplicate_skill_names() {
        let root = temp_skills_dir("duplicate");
        write_skill(&root, "one", "---\nname: shared\n---\nA\n");
        write_skill(&root, "two", "---\nname: shared\n---\nB\n");

        let error = SkillLoader::from_dir(&root).expect_err("duplicate error");

        assert!(matches!(
            error,
            SkillLoadError::DuplicateSkillName { ref name, .. } if name == "shared"
        ));
    }

    #[test]
    fn rejects_malformed_frontmatter() {
        let root = temp_skills_dir("invalid-frontmatter");
        write_skill(&root, "broken", "---\nname: [oops\n---\nBody\n");

        let error = SkillLoader::from_dir(&root).expect_err("frontmatter error");

        assert!(matches!(error, SkillLoadError::InvalidFrontmatter { .. }));
        assert!(error.to_string().contains("invalid skill frontmatter"));
    }

    fn temp_skills_dir(label: &str) -> PathBuf {
        let unique = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
        let timestamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time")
            .as_nanos();
        let path =
            std::env::temp_dir().join(format!("mentra-skill-tests-{label}-{timestamp}-{unique}"));
        fs::create_dir_all(&path).expect("create temp dir");
        path
    }

    fn write_skill(root: &Path, name: &str, content: &str) {
        let skill_dir = root.join(name);
        fs::create_dir_all(&skill_dir).expect("create skill dir");
        fs::write(skill_dir.join("SKILL.md"), content).expect("write skill");
    }
}