diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-10 22:01:45 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-10 22:01:45 +0200 |
| commit | 5e31a81cd2207f053b8cd8ad84ebe2a2f691b29d (patch) | |
| tree | 5d76811ab0d693c01fa472d41aa2ceaf3bd0b415 /crates/atuin-ai/src/skills | |
| parent | chore: Remove unneeded files (diff) | |
| download | atuin-5e31a81cd2207f053b8cd8ad84ebe2a2f691b29d.zip | |
chore: Remove some unused rust code
Diffstat (limited to 'crates/atuin-ai/src/skills')
| -rw-r--r-- | crates/atuin-ai/src/skills/frontmatter.rs | 233 | ||||
| -rw-r--r-- | crates/atuin-ai/src/skills/mod.rs | 468 | ||||
| -rw-r--r-- | crates/atuin-ai/src/skills/walker.rs | 178 |
3 files changed, 0 insertions, 879 deletions
diff --git a/crates/atuin-ai/src/skills/frontmatter.rs b/crates/atuin-ai/src/skills/frontmatter.rs deleted file mode 100644 index 759dffcc..00000000 --- a/crates/atuin-ai/src/skills/frontmatter.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! YAML frontmatter parsing for `SKILL.md` files. -//! -//! Extracts the YAML block between `---` delimiters and parses it with -//! `yaml-rust2`. Returns the parsed fields and the byte offset where the -//! body begins (after the closing `---`). - -use yaml_rust2::YamlLoader; - -/// Parsed frontmatter fields from a `SKILL.md` file. -#[derive(Debug, Default)] -pub(crate) struct Frontmatter { - pub name: Option<String>, - pub description: Option<String>, - pub disable_model_invocation: bool, -} - -/// Result of splitting a skill file into frontmatter + body. -#[derive(Debug)] -pub(crate) struct ParsedSkillFile { - pub frontmatter: Frontmatter, - /// Everything after the closing `---` delimiter. - pub body: String, -} - -/// Parse a `SKILL.md` file's content into frontmatter and body. -/// -/// If no frontmatter delimiters are found, all content is treated as body -/// with default frontmatter. -pub(crate) fn parse(content: &str) -> ParsedSkillFile { - let Some((yaml_str, body)) = split_frontmatter(content) else { - return ParsedSkillFile { - frontmatter: Frontmatter::default(), - body: content.to_string(), - }; - }; - - let frontmatter = match YamlLoader::load_from_str(yaml_str) { - Ok(docs) if !docs.is_empty() => extract_fields(&docs[0]), - Ok(_) => Frontmatter::default(), - Err(e) => { - tracing::warn!("Failed to parse skill frontmatter: {e}"); - Frontmatter::default() - } - }; - - ParsedSkillFile { frontmatter, body } -} - -/// Split content on `---` delimiters. Returns `(yaml_str, body)` or `None` -/// if frontmatter is not present. -fn split_frontmatter(content: &str) -> Option<(&str, String)> { - let trimmed = content.trim_start(); - - // Must start with `---` - if !trimmed.starts_with("---") { - return None; - } - - // Find the end of the opening delimiter line - let after_open = trimmed.get(3..)?.trim_start_matches(|c: char| c != '\n'); - let after_open = after_open.strip_prefix('\n').unwrap_or(after_open); - - // Find the closing `---` - let close_pos = after_open - .lines() - .enumerate() - .find(|(_, line)| line.trim() == "---") - .map(|(i, _)| { - after_open - .lines() - .take(i) - .map(|l| l.len() + 1) // +1 for newline - .sum::<usize>() - })?; - - let yaml_str = &after_open[..close_pos]; - let rest = &after_open[close_pos..]; - // Skip the closing `---` line - let body = rest - .strip_prefix("---") - .unwrap_or(rest) - .trim_start_matches(|c: char| c != '\n'); - let body = body.strip_prefix('\n').unwrap_or(body); - - Some((yaml_str, body.to_string())) -} - -fn extract_fields(doc: &yaml_rust2::Yaml) -> Frontmatter { - use yaml_rust2::Yaml; - - let name = match &doc["name"] { - Yaml::String(s) => Some(s.clone()), - _ => None, - }; - - let description = match &doc["description"] { - Yaml::String(s) => Some(s.trim().to_string()), - _ => None, - }; - - let disable_model_invocation = match &doc["disable-model-invocation"] { - Yaml::Boolean(b) => *b, - Yaml::String(s) => matches!(s.as_str(), "true" | "yes" | "1"), - _ => false, - }; - - Frontmatter { - name, - description, - disable_model_invocation, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn basic_frontmatter() { - let content = "\ ---- -name: my-skill -description: A test skill -disable-model-invocation: true ---- - -Body content here. -"; - let parsed = parse(content); - assert_eq!(parsed.frontmatter.name.as_deref(), Some("my-skill")); - assert_eq!( - parsed.frontmatter.description.as_deref(), - Some("A test skill") - ); - assert!(parsed.frontmatter.disable_model_invocation); - assert_eq!(parsed.body.trim(), "Body content here."); - } - - #[test] - fn multiline_folded_description() { - let content = "\ ---- -name: release -description: > - Orchestrate a multi-step release — version bumping, changelog - generation, PR creation, tagging, and publishing. -disable-model-invocation: true ---- - -# Release steps -"; - let parsed = parse(content); - assert_eq!(parsed.frontmatter.name.as_deref(), Some("release")); - let desc = parsed.frontmatter.description.unwrap(); - assert!(desc.contains("Orchestrate a multi-step release")); - assert!(desc.contains("publishing")); - assert!(parsed.frontmatter.disable_model_invocation); - assert!(parsed.body.contains("# Release steps")); - } - - #[test] - fn no_frontmatter() { - let content = "Just a body with no frontmatter."; - let parsed = parse(content); - assert!(parsed.frontmatter.name.is_none()); - assert!(parsed.frontmatter.description.is_none()); - assert!(!parsed.frontmatter.disable_model_invocation); - assert_eq!(parsed.body, content); - } - - #[test] - fn empty_frontmatter() { - let content = "\ ---- ---- - -Body after empty frontmatter. -"; - let parsed = parse(content); - assert!(parsed.frontmatter.name.is_none()); - assert!(parsed.frontmatter.description.is_none()); - assert_eq!(parsed.body.trim(), "Body after empty frontmatter."); - } - - #[test] - fn missing_fields_use_defaults() { - let content = "\ ---- -name: partial ---- - -Some body. -"; - let parsed = parse(content); - assert_eq!(parsed.frontmatter.name.as_deref(), Some("partial")); - assert!(parsed.frontmatter.description.is_none()); - assert!(!parsed.frontmatter.disable_model_invocation); - } - - #[test] - fn unknown_fields_ignored() { - let content = "\ ---- -name: my-skill -future-field: some value -another: 42 ---- - -Body. -"; - let parsed = parse(content); - assert_eq!(parsed.frontmatter.name.as_deref(), Some("my-skill")); - } - - #[test] - fn body_with_triple_dashes() { - let content = "\ ---- -name: test ---- - -Some body. - ---- - -More body after a horizontal rule. -"; - let parsed = parse(content); - assert_eq!(parsed.frontmatter.name.as_deref(), Some("test")); - assert!(parsed.body.contains("Some body.")); - assert!(parsed.body.contains("More body after a horizontal rule.")); - } -} diff --git a/crates/atuin-ai/src/skills/mod.rs b/crates/atuin-ai/src/skills/mod.rs deleted file mode 100644 index 36b3a2ae..00000000 --- a/crates/atuin-ai/src/skills/mod.rs +++ /dev/null @@ -1,468 +0,0 @@ -//! AI skill discovery, metadata, and lazy loading. -//! -//! Skills are markdown files (`SKILL.md`) with YAML frontmatter that define -//! reusable instructions for the LLM. Only skill metadata (name + description) -//! is sent to the server; full content is loaded on demand via `load_skill`. - -mod frontmatter; -pub(crate) mod walker; - -use std::path::Path; - -use eyre::{Result, eyre}; - -use crate::user_context::interpolate; - -/// Per-skill description truncation limit (before budget calculation). -const MAX_DESCRIPTION_LEN: usize = 1024; - -/// Default total character budget for skill descriptions sent to the server. -const DEFAULT_DESCRIPTION_BUDGET: usize = 9992; - -/// JSON overhead per skill entry: `{"name":"","description":""},` ≈ 30 chars. -const PER_ENTRY_OVERHEAD: usize = 30; - -/// Metadata for a discovered skill. Produced at discovery time from -/// frontmatter only — the body is not read until `load()`. -#[derive(Debug, Clone)] -pub(crate) struct SkillDescriptor { - pub name: String, - pub description: String, - pub source_path: std::path::PathBuf, - pub disable_model_invocation: bool, -} - -/// A name + description pair ready to serialize into the request payload. -#[derive(Debug, Clone, serde::Serialize)] -pub(crate) struct SkillSummary { - pub name: String, - pub description: String, -} - -/// Holds discovered skills and provides lookup, budget packing, and loading. -#[derive(Debug, Clone)] -pub(crate) struct SkillRegistry { - skills: Vec<SkillDescriptor>, -} - -impl SkillRegistry { - /// Discover skills from project and global directories. - pub async fn discover(project_root: Option<&Path>) -> Self { - let global_dir = walker::global_skills_dir(); - let project_dir = project_root.map(walker::project_skills_dir); - - Self::discover_from_dirs(project_dir.as_deref(), &global_dir).await - } - - /// Discover skills from explicit directory paths. Useful for testing. - pub async fn discover_from_dirs( - project_skills_dir: Option<&Path>, - global_skills_dir: &Path, - ) -> Self { - let raw_files = walker::discover(project_skills_dir, global_skills_dir).await; - - let mut skills = Vec::new(); - let mut seen_names = std::collections::HashSet::new(); - - for raw in raw_files { - let parsed = frontmatter::parse(&raw.content); - let fm = parsed.frontmatter; - - let name = fm.name.unwrap_or_else(|| sanitize_name(&raw.dir_name)); - - // Deduplicate: first seen wins (project before global) - if !seen_names.insert(name.clone()) { - continue; - } - - let description = fm - .description - .or_else(|| first_paragraph(&parsed.body)) - .unwrap_or_default(); - - skills.push(SkillDescriptor { - name, - description, - source_path: raw.path, - disable_model_invocation: fm.disable_model_invocation, - }); - } - - Self { skills } - } - - /// Create an empty registry. - #[cfg(test)] - pub fn empty() -> Self { - Self { skills: Vec::new() } - } - - /// Look up a skill by name. - pub fn get(&self, name: &str) -> Option<&SkillDescriptor> { - self.skills.iter().find(|s| s.name == name) - } - - /// All discovered skills. - pub fn all(&self) -> &[SkillDescriptor] { - &self.skills - } - - /// Whether any non-disabled skills exist (determines capability advertisement). - #[cfg(test)] - pub fn has_server_visible_skills(&self) -> bool { - self.skills.iter().any(|s| !s.disable_model_invocation) - } - - /// Pack skill descriptions into the server payload under a character budget. - /// - /// Returns the summaries that fit plus an optional overflow message. - pub fn server_skills(&self) -> (Vec<SkillSummary>, Option<String>) { - self.server_skills_with_budget(DEFAULT_DESCRIPTION_BUDGET) - } - - pub fn server_skills_with_budget(&self, budget: usize) -> (Vec<SkillSummary>, Option<String>) { - let eligible: Vec<&SkillDescriptor> = self - .skills - .iter() - .filter(|s| !s.disable_model_invocation) - .collect(); - - let mut summaries = Vec::new(); - let mut used = 0; - let mut overflow_names = Vec::new(); - - for skill in &eligible { - let truncated_desc = truncate_description(&skill.description, MAX_DESCRIPTION_LEN); - let entry_size = skill.name.len() + truncated_desc.len() + PER_ENTRY_OVERHEAD; - - if used + entry_size > budget && !summaries.is_empty() { - overflow_names.push(skill.name.as_str()); - continue; - } - - used += entry_size; - summaries.push(SkillSummary { - name: skill.name.clone(), - description: truncated_desc, - }); - } - - let overflow = if overflow_names.is_empty() { - None - } else { - Some(format!( - "{} additional skill(s) not listed due to size limits: {}", - overflow_names.len(), - overflow_names.join(", ") - )) - }; - - (summaries, overflow) - } - - /// Load a skill's full body content, with argument substitution and - /// `!`` interpolation applied. - /// - /// `$ARGUMENTS` in the body is replaced with the provided arguments before - /// shell interpolation runs. If `$ARGUMENTS` does not appear in the body - /// and arguments were provided, they are appended as `ARGUMENTS: <value>`. - pub async fn load(&self, name: &str, shell: &str, arguments: Option<&str>) -> Result<String> { - let skill = self - .get(name) - .ok_or_else(|| eyre!("Unknown skill: {name}"))?; - - let content = tokio::fs::read_to_string(&skill.source_path).await?; - let parsed = frontmatter::parse(&content); - let body = parsed.body; - - if body.trim().is_empty() { - return Ok(format!("(Skill '{name}' has no body content)")); - } - - let body = substitute_arguments(&body, arguments); - - Ok(interpolate::interpolate(&body, shell).await) - } -} - -/// Replace `$ARGUMENTS` placeholders in skill body text. -/// -/// If `$ARGUMENTS` appears in the body, all occurrences are replaced with the -/// argument string (or empty string if none). If `$ARGUMENTS` does not appear -/// and arguments were provided, they are appended on a new line. -fn substitute_arguments(body: &str, arguments: Option<&str>) -> String { - let args = arguments.unwrap_or(""); - - if body.contains("$ARGUMENTS") { - return body.replace("$ARGUMENTS", args); - } - - if !args.is_empty() { - return format!("{body}\n\nARGUMENTS: {args}"); - } - - body.to_string() -} - -/// Sanitize a directory name into a valid skill name. -fn sanitize_name(name: &str) -> String { - name.chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' { - c - } else { - '-' - } - }) - .collect::<String>() - .to_lowercase() -} - -/// Extract the first non-empty paragraph from markdown body text. -fn first_paragraph(body: &str) -> Option<String> { - let trimmed = body.trim(); - if trimmed.is_empty() { - return None; - } - - let para: String = trimmed - .lines() - .take_while(|line| !line.trim().is_empty()) - .collect::<Vec<_>>() - .join(" "); - - let para = para.trim().to_string(); - if para.is_empty() { None } else { Some(para) } -} - -/// Truncate a description to `max_len` characters, adding ellipsis if cut. -fn truncate_description(desc: &str, max_len: usize) -> String { - if desc.len() <= max_len { - return desc.to_string(); - } - let mut end = max_len.saturating_sub(3); - // Avoid splitting a multi-byte char - while !desc.is_char_boundary(end) && end > 0 { - end -= 1; - } - format!("{}...", &desc[..end]) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sanitize_name_basic() { - assert_eq!(sanitize_name("My Skill"), "my-skill"); - assert_eq!(sanitize_name("deploy_prod"), "deploy-prod"); - assert_eq!(sanitize_name("code-review"), "code-review"); - } - - #[test] - fn first_paragraph_extraction() { - assert_eq!( - first_paragraph("Hello world\nSecond line\n\nNew paragraph"), - Some("Hello world Second line".to_string()) - ); - assert_eq!(first_paragraph(""), None); - assert_eq!(first_paragraph("\n\n"), None); - assert_eq!( - first_paragraph("Single line"), - Some("Single line".to_string()) - ); - } - - #[test] - fn truncate_description_short() { - assert_eq!(truncate_description("short", 100), "short"); - } - - #[test] - fn substitute_arguments_replaces_placeholder() { - let body = "Deploy $ARGUMENTS to production."; - assert_eq!( - substitute_arguments(body, Some("patch")), - "Deploy patch to production." - ); - } - - #[test] - fn substitute_arguments_multiple_occurrences() { - let body = "Run $ARGUMENTS then verify $ARGUMENTS worked."; - assert_eq!( - substitute_arguments(body, Some("migrate")), - "Run migrate then verify migrate worked." - ); - } - - #[test] - fn substitute_arguments_appends_when_no_placeholder() { - let body = "Do the thing."; - let result = substitute_arguments(body, Some("extra context")); - assert!(result.starts_with("Do the thing.")); - assert!(result.contains("ARGUMENTS: extra context")); - } - - #[test] - fn substitute_arguments_no_args_no_placeholder() { - let body = "Just a body."; - assert_eq!(substitute_arguments(body, None), "Just a body."); - } - - #[test] - fn substitute_arguments_no_args_clears_placeholder() { - let body = "Deploy $ARGUMENTS to production."; - assert_eq!(substitute_arguments(body, None), "Deploy to production."); - } - - #[test] - fn truncate_description_long() { - let long = "a".repeat(600); - let result = truncate_description(&long, 512); - assert!(result.len() <= 512); - assert!(result.ends_with("...")); - } - - #[test] - fn budget_packing() { - let registry = SkillRegistry { - skills: vec![ - SkillDescriptor { - name: "a".to_string(), - description: "Short desc".to_string(), - source_path: "a/SKILL.md".into(), - disable_model_invocation: false, - }, - SkillDescriptor { - name: "b".to_string(), - description: "Another desc".to_string(), - source_path: "b/SKILL.md".into(), - disable_model_invocation: false, - }, - ], - }; - - let (summaries, overflow) = registry.server_skills_with_budget(4096); - assert_eq!(summaries.len(), 2); - assert!(overflow.is_none()); - } - - #[test] - fn budget_overflow() { - let registry = SkillRegistry { - skills: vec![ - SkillDescriptor { - name: "first".to_string(), - description: "x".repeat(200), - source_path: "a/SKILL.md".into(), - disable_model_invocation: false, - }, - SkillDescriptor { - name: "second".to_string(), - description: "y".repeat(200), - source_path: "b/SKILL.md".into(), - disable_model_invocation: false, - }, - ], - }; - - // Budget only fits one - let (summaries, overflow) = registry.server_skills_with_budget(260); - assert_eq!(summaries.len(), 1); - assert_eq!(summaries[0].name, "first"); - let overflow = overflow.unwrap(); - assert!(overflow.contains("second")); - assert!(overflow.contains("1 additional")); - } - - #[test] - fn disabled_skills_excluded_from_server() { - let registry = SkillRegistry { - skills: vec![ - SkillDescriptor { - name: "visible".to_string(), - description: "I show up".to_string(), - source_path: "a/SKILL.md".into(), - disable_model_invocation: false, - }, - SkillDescriptor { - name: "hidden".to_string(), - description: "I don't".to_string(), - source_path: "b/SKILL.md".into(), - disable_model_invocation: true, - }, - ], - }; - - let (summaries, _) = registry.server_skills(); - assert_eq!(summaries.len(), 1); - assert_eq!(summaries[0].name, "visible"); - - // But all() includes both - assert_eq!(registry.all().len(), 2); - } - - #[test] - fn has_server_visible_skills() { - let empty = SkillRegistry::empty(); - assert!(!empty.has_server_visible_skills()); - - let all_disabled = SkillRegistry { - skills: vec![SkillDescriptor { - name: "hidden".to_string(), - description: String::new(), - source_path: "a/SKILL.md".into(), - disable_model_invocation: true, - }], - }; - assert!(!all_disabled.has_server_visible_skills()); - - let some_visible = SkillRegistry { - skills: vec![SkillDescriptor { - name: "visible".to_string(), - description: String::new(), - source_path: "a/SKILL.md".into(), - disable_model_invocation: false, - }], - }; - assert!(some_visible.has_server_visible_skills()); - } - - #[tokio::test] - async fn end_to_end_discover() { - let dir = tempfile::tempdir().unwrap(); - let skills_dir = dir.path().join("skills"); - - // Create a skill with frontmatter - let skill_dir = skills_dir.join("my-skill"); - std::fs::create_dir_all(&skill_dir).unwrap(); - std::fs::write( - skill_dir.join("SKILL.md"), - "---\nname: my-skill\ndescription: A test skill\n---\n\nBody here.\n", - ) - .unwrap(); - - // Create a skill with multiline description - let skill_dir2 = skills_dir.join("release"); - std::fs::create_dir_all(&skill_dir2).unwrap(); - std::fs::write( - skill_dir2.join("SKILL.md"), - "---\nname: release\ndescription: >\n Multi-line\n description here.\n---\n\nRelease steps.\n", - ) - .unwrap(); - - let registry = SkillRegistry::discover_from_dirs( - Some(&skills_dir), - &std::path::PathBuf::from("/nonexistent"), - ) - .await; - assert_eq!(registry.all().len(), 2); - - let my_skill = registry.get("my-skill").unwrap(); - assert_eq!(my_skill.description, "A test skill"); - - let release = registry.get("release").unwrap(); - assert!(release.description.contains("Multi-line")); - } -} diff --git a/crates/atuin-ai/src/skills/walker.rs b/crates/atuin-ai/src/skills/walker.rs deleted file mode 100644 index b93845f9..00000000 --- a/crates/atuin-ai/src/skills/walker.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Filesystem discovery for `SKILL.md` files. -//! -//! Recursively scans `.atuin/skills/` directories at the project and global -//! levels. Supports nested directories for organization (e.g. -//! `.atuin/skills/ops/deploy/SKILL.md`). - -use std::path::{Path, PathBuf}; - -const SKILL_FILENAME: &str = "SKILL.md"; - -/// A skill file found on disk, before body interpolation. -#[derive(Debug)] -pub(crate) struct RawSkillFile { - /// Full path to the SKILL.md file. - pub path: PathBuf, - /// The parent directory name, used as fallback skill name. - pub dir_name: String, - /// Whether this is a project-level skill (vs global). - #[allow(dead_code)] - pub is_project: bool, - /// Raw file content. - pub content: String, -} - -/// Discover all `SKILL.md` files across project and global skill directories. -/// -/// Project skills come first in the returned list (higher priority for -/// deduplication). -pub(crate) async fn discover( - project_skills_dir: Option<&Path>, - global_skills_dir: &Path, -) -> Vec<RawSkillFile> { - let mut files = Vec::new(); - - // Project skills first (higher priority) - if let Some(dir) = project_skills_dir.filter(|d| d.is_dir()) { - scan_dir(dir, true, &mut files).await; - } - - // Global skills second - if global_skills_dir.is_dir() { - scan_dir(global_skills_dir, false, &mut files).await; - } - - files -} - -/// The default global skills directory (`~/.config/atuin/skills/`). -pub(crate) fn global_skills_dir() -> PathBuf { - atuin_common::utils::config_dir().join("skills") -} - -/// Given a project working directory, return the project skills directory. -pub(crate) fn project_skills_dir(project_root: &Path) -> PathBuf { - project_root.join(".atuin").join("skills") -} - -/// Recursively scan a directory for `SKILL.md` files. -async fn scan_dir(dir: &Path, is_project: bool, out: &mut Vec<RawSkillFile>) { - let mut entries = match tokio::fs::read_dir(dir).await { - Ok(entries) => entries, - Err(e) => { - tracing::debug!("Could not read skills directory {}: {e}", dir.display()); - return; - } - }; - - let mut subdirs = Vec::new(); - - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - - if path.is_dir() { - // Check for SKILL.md directly in this directory - let skill_path = path.join(SKILL_FILENAME); - if skill_path.is_file() { - let dir_name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); - - match tokio::fs::read_to_string(&skill_path).await { - Ok(content) => { - out.push(RawSkillFile { - path: skill_path, - dir_name, - is_project, - content, - }); - } - Err(e) => { - tracing::warn!("Failed to read skill file {}: {e}", skill_path.display()); - } - } - } - - // Collect subdirectories for recursive scanning - subdirs.push(path); - } - } - - for subdir in subdirs { - Box::pin(scan_dir(&subdir, is_project, out)).await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn setup_skill(dir: &Path, rel_path: &str, content: &str) { - let skill_dir = dir.join(rel_path); - std::fs::create_dir_all(&skill_dir).unwrap(); - std::fs::write(skill_dir.join(SKILL_FILENAME), content).unwrap(); - } - - #[tokio::test] - async fn discovers_project_skills() { - let dir = tempfile::tempdir().unwrap(); - let skills_dir = dir.path().join("skills"); - setup_skill(&skills_dir, "deploy", "---\nname: deploy\n---\nDeploy."); - - let files = discover(Some(&skills_dir), Path::new("/nonexistent")).await; - assert_eq!(files.len(), 1); - assert_eq!(files[0].dir_name, "deploy"); - assert!(files[0].is_project); - } - - #[tokio::test] - async fn discovers_global_skills() { - let dir = tempfile::tempdir().unwrap(); - let skills_dir = dir.path().join("skills"); - setup_skill(&skills_dir, "review", "---\nname: review\n---\nReview."); - - let files = discover(None, &skills_dir).await; - assert_eq!(files.len(), 1); - assert_eq!(files[0].dir_name, "review"); - assert!(!files[0].is_project); - } - - #[tokio::test] - async fn discovers_nested_skills() { - let dir = tempfile::tempdir().unwrap(); - let skills_dir = dir.path().join("skills"); - setup_skill(&skills_dir, "ops/deploy", "---\nname: deploy\n---\n"); - setup_skill(&skills_dir, "ops/rollback", "---\nname: rollback\n---\n"); - - let files = discover(Some(&skills_dir), Path::new("/nonexistent")).await; - assert_eq!(files.len(), 2); - } - - #[tokio::test] - async fn project_comes_before_global() { - let project = tempfile::tempdir().unwrap(); - let global = tempfile::tempdir().unwrap(); - let project_skills = project.path().join("skills"); - let global_skills = global.path().join("skills"); - - setup_skill(&project_skills, "a-skill", "project"); - setup_skill(&global_skills, "b-skill", "global"); - - let files = discover(Some(&project_skills), &global_skills).await; - assert_eq!(files.len(), 2); - assert!(files[0].is_project); - assert!(!files[1].is_project); - } - - #[tokio::test] - async fn missing_directories_handled() { - let files = discover( - Some(Path::new("/does/not/exist")), - Path::new("/also/missing"), - ) - .await; - assert!(files.is_empty()); - } -} |
