Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,36 @@
[package]
name = "nomi-skills"
description = "Skills system for Nomi: named prompt snippets with tool orchestration, permissions, hooks, and MCP integration"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
nomi-types.workspace = true
nomi-config.workspace = true
nomi-mcp.workspace = true
tracing.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
tokio.workspace = true
futures.workspace = true
async-trait.workspace = true
thiserror.workspace = true
glob.workspace = true
dirs.workspace = true
regex.workspace = true
notify.workspace = true
unicode-width.workspace = true
[target.'cfg(unix)'.dependencies]
libc.workspace = true
[dev-dependencies]
nomi-mcp = { workspace = true, features = ["test-utils"] }
tempfile.workspace = true
rstest.workspace = true
serial_test.workspace = true
@@ -0,0 +1,466 @@
// Phase 10 inline tests for src/skills/bundled/mod.rs
// Covers TC-10.01 ~ TC-10.28 (registration API, field mapping, file extraction,
// resolve_skill_file_path path validation, prepare_bundled_skills, thread safety).
use super::*;
use serial_test::serial;
use std::path::Path;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn minimal_def(name: &'static str) -> BundledSkillDefinition {
BundledSkillDefinition {
name,
description: "test skill",
when_to_use: None,
argument_hint: None,
allowed_tools: &[],
model: None,
disable_model_invocation: false,
user_invocable: false,
context: None,
agent: None,
files: &[],
content: "content",
}
}
// ---------------------------------------------------------------------------
// TC-10.01: register single skill
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_01_register_single_skill() {
clear_bundled_skills();
register_bundled_skill(minimal_def("tc-01"));
let skills = get_bundled_skills();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "tc-01");
}
// ---------------------------------------------------------------------------
// TC-10.02: multiple registrations accumulate
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_02_register_multiple_accumulate() {
clear_bundled_skills();
register_bundled_skill(minimal_def("a"));
register_bundled_skill(minimal_def("b"));
register_bundled_skill(minimal_def("c"));
let skills = get_bundled_skills();
assert_eq!(skills.len(), 3);
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"a") && names.contains(&"b") && names.contains(&"c"));
}
// ---------------------------------------------------------------------------
// TC-10.03: clear_bundled_skills empties registry
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_03_clear_empties_registry() {
clear_bundled_skills();
register_bundled_skill(minimal_def("to-clear"));
clear_bundled_skills();
assert!(get_bundled_skills().is_empty());
}
// ---------------------------------------------------------------------------
// TC-10.04: init_bundled_skills registers hello skill
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_04_init_registers_hello() {
clear_bundled_skills();
init_bundled_skills();
let skills = get_bundled_skills();
assert!(!skills.is_empty());
assert!(skills.iter().any(|s| s.name == "hello"));
}
// ---------------------------------------------------------------------------
// TC-10.05: full field mapping
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_05_full_field_mapping() {
clear_bundled_skills();
register_bundled_skill(BundledSkillDefinition {
name: "full-skill",
description: "desc",
when_to_use: Some("when"),
argument_hint: Some("arg"),
allowed_tools: &["Bash", "Read"],
model: Some("claude-opus-4-6"),
disable_model_invocation: false,
user_invocable: true,
context: Some("inline"),
agent: Some("my-agent"),
files: &[],
content: "body",
});
let skills = get_bundled_skills();
let m = &skills[0];
assert_eq!(m.name, "full-skill");
assert_eq!(m.description, "desc");
assert_eq!(m.when_to_use.as_deref(), Some("when"));
assert_eq!(m.argument_hint.as_deref(), Some("arg"));
assert_eq!(m.allowed_tools, vec!["Bash", "Read"]);
assert_eq!(m.model.as_deref(), Some("claude-opus-4-6"));
assert!(!m.disable_model_invocation);
assert!(m.user_invocable);
assert_eq!(m.agent.as_deref(), Some("my-agent"));
assert!(m.has_user_specified_description);
}
// ---------------------------------------------------------------------------
// TC-10.06: source and loaded_from are Bundled
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_06_source_and_loaded_from_bundled() {
clear_bundled_skills();
register_bundled_skill(minimal_def("src-test"));
let skills = get_bundled_skills();
let m = &skills[0];
assert_eq!(m.source, SkillSource::Bundled);
assert_eq!(m.loaded_from, LoadedFrom::Bundled);
}
// ---------------------------------------------------------------------------
// TC-10.07: context="inline" maps to ExecutionContext::Inline
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_07_context_inline_maps_correctly() {
clear_bundled_skills();
register_bundled_skill(BundledSkillDefinition {
context: Some("inline"),
..minimal_def("ctx-inline")
});
let m = &get_bundled_skills()[0];
assert_eq!(m.execution_context, ExecutionContext::Inline);
}
// ---------------------------------------------------------------------------
// TC-10.08: context="fork" maps to ExecutionContext::Fork
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_08_context_fork_maps_correctly() {
clear_bundled_skills();
register_bundled_skill(BundledSkillDefinition {
context: Some("fork"),
..minimal_def("ctx-fork")
});
let m = &get_bundled_skills()[0];
assert_eq!(m.execution_context, ExecutionContext::Fork);
}
// ---------------------------------------------------------------------------
// TC-10.09: context=None defaults to ExecutionContext::Inline
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_09_context_none_defaults_to_inline() {
clear_bundled_skills();
register_bundled_skill(minimal_def("ctx-none"));
let m = &get_bundled_skills()[0];
assert_eq!(
m.execution_context,
ExecutionContext::Inline,
"context=None should default to Inline"
);
}
// ---------------------------------------------------------------------------
// TC-10.10: no files → skill_root is None
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_10_no_files_skill_root_none() {
clear_bundled_skills();
register_bundled_skill(minimal_def("no-files"));
let m = &get_bundled_skills()[0];
assert!(m.skill_root.is_none());
}
// ---------------------------------------------------------------------------
// TC-10.11: with files → prepare_bundled_skills sets skill_root
// ---------------------------------------------------------------------------
#[tokio::test]
#[serial]
async fn tc_10_11_files_skill_root_set_by_prepare() {
clear_bundled_skills();
register_bundled_skill(BundledSkillDefinition {
files: &[("guide.md", "# Guide")],
..minimal_def("file-skill")
});
let skills = prepare_bundled_skills().await;
let m = skills.iter().find(|s| s.name == "file-skill").unwrap();
assert!(
m.skill_root.is_some(),
"skill_root should be set by prepare_bundled_skills"
);
assert!(m.skill_root.as_ref().unwrap().contains("file-skill"));
}
// ---------------------------------------------------------------------------
// TC-10.12: extraction — directory and file created
// ---------------------------------------------------------------------------
#[tokio::test]
#[serial]
async fn tc_10_12_extract_creates_dir_and_file() {
let result = extract_bundled_skill_files("tc-12-skill", &[("data.md", "content")]).await;
let dir = result.expect("extraction should succeed");
let file = dir.join("data.md");
assert!(file.exists(), "extracted file should exist");
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"content",
"file content should match"
);
// cleanup
let _ = std::fs::remove_dir_all(&dir);
}
// ---------------------------------------------------------------------------
// TC-10.13: directory permission 0o700 (unix only)
// ---------------------------------------------------------------------------
#[tokio::test]
#[serial]
async fn tc_10_13_dir_permission_0700() {
let result = extract_bundled_skill_files("tc-13-skill", &[("perm.md", "x")]).await;
let dir = result.expect("extraction should succeed");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(&dir).unwrap();
assert_eq!(
meta.permissions().mode() & 0o777,
0o700,
"directory must be owner-only (0o700)"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
// ---------------------------------------------------------------------------
// TC-10.14: file permission 0o600 (unix only)
// ---------------------------------------------------------------------------
#[tokio::test]
#[serial]
async fn tc_10_14_file_permission_0600() {
let result = extract_bundled_skill_files("tc-14-skill", &[("file.md", "y")]).await;
let dir = result.expect("extraction should succeed");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let fmeta = std::fs::metadata(dir.join("file.md")).unwrap();
assert_eq!(
fmeta.permissions().mode() & 0o777,
0o600,
"file must be owner-only (0o600)"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
// ---------------------------------------------------------------------------
// TC-10.15: path traversal rejected at integration layer
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tc_10_15_path_traversal_rejected_integration() {
let result = extract_bundled_skill_files("tc-15-evil", &[("../escape.txt", "pwned")]).await;
// Either extraction fails entirely, or the traversal entry is skipped
if let Some(dir) = result {
assert!(
!dir.parent().unwrap().join("escape.txt").exists(),
"traversal file must not be created outside extract dir"
);
let _ = std::fs::remove_dir_all(&dir);
}
// If result is None, the test also passes (extraction was rejected)
}
// ---------------------------------------------------------------------------
// TC-10.16: extraction failure returns None, not panic
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tc_10_16_extraction_failure_returns_none() {
// Pass an empty files slice — extract_bundled_skill_files returns None for empty
let result = extract_bundled_skill_files("tc-16-empty", &[]).await;
assert!(
result.is_none(),
"empty files should return None without panic"
);
}
// ---------------------------------------------------------------------------
// TC-10.17: get_bundled_skill_extract_dir path format
// ---------------------------------------------------------------------------
#[test]
fn tc_10_17_extract_dir_path_format() {
let path = get_bundled_skill_extract_dir("my-skill");
let s = path.to_string_lossy();
assert!(
s.contains("nomi-bundled-skills"),
"path should contain nomi-bundled-skills"
);
assert!(s.contains("my-skill"), "path should contain skill name");
}
// ---------------------------------------------------------------------------
// TC-10.19: get_bundled_skills is idempotent (does not consume registry)
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_19_get_bundled_skills_idempotent() {
clear_bundled_skills();
register_bundled_skill(minimal_def("idem-a"));
register_bundled_skill(minimal_def("idem-b"));
assert_eq!(get_bundled_skills().len(), 2);
assert_eq!(get_bundled_skills().len(), 2);
}
// ---------------------------------------------------------------------------
// TC-10.23: content_length field is correct
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_23_content_length_correct() {
clear_bundled_skills();
register_bundled_skill(BundledSkillDefinition {
content: "hello world",
..minimal_def("cl-skill")
});
let m = &get_bundled_skills()[0];
assert_eq!(m.content_length, "hello world".len());
}
// ---------------------------------------------------------------------------
// TC-10.24: concurrent registration does not panic
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_24_concurrent_registration_no_panic() {
clear_bundled_skills();
let handles: Vec<_> = (0..10_u8)
.map(|i| {
std::thread::spawn(move || {
// SAFETY: each thread registers a unique name literal via a
// fixed array; we pick from the set of 10 pre-defined literals.
let names: [&'static str; 10] =
["t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8", "t9"];
register_bundled_skill(minimal_def(names[i as usize]));
})
})
.collect();
for h in handles {
h.join().expect("thread should not panic");
}
let skills = get_bundled_skills();
assert_eq!(
skills.len(),
10,
"all 10 concurrent registrations should be present"
);
}
// ---------------------------------------------------------------------------
// TC-10.25: unknown context string defaults to Inline
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_25_unknown_context_defaults_to_inline() {
clear_bundled_skills();
register_bundled_skill(BundledSkillDefinition {
context: Some("unknown-value"),
..minimal_def("ctx-unknown")
});
let m = &get_bundled_skills()[0];
assert_eq!(m.execution_context, ExecutionContext::Inline);
}
// ---------------------------------------------------------------------------
// TC-10.27: resolve_skill_file_path path validation (private fn, inline test)
// ---------------------------------------------------------------------------
#[test]
fn tc_10_27a_resolve_normal_path_ok() {
let result = resolve_skill_file_path(Path::new("/base"), "sub/file.md");
assert!(result.is_ok(), "normal relative path should be Ok");
assert_eq!(
result.unwrap(),
std::path::PathBuf::from("/base/sub/file.md")
);
}
#[test]
fn tc_10_27b_resolve_traversal_rejected() {
let result = resolve_skill_file_path(Path::new("/base"), "../escape.txt");
assert!(result.is_err(), "path traversal '../' must be rejected");
}
#[test]
fn tc_10_27c_resolve_absolute_path_rejected() {
// Use a platform-appropriate absolute path so `Path::is_absolute()` returns true
#[cfg(unix)]
let abs_path = "/etc/passwd";
#[cfg(windows)]
let abs_path = "C:\\Windows\\System32\\drivers\\etc\\hosts";
let result = resolve_skill_file_path(Path::new("/base"), abs_path);
assert!(result.is_err(), "absolute path must be rejected");
}
#[test]
fn tc_10_27d_resolve_disguised_traversal_rejected() {
let result = resolve_skill_file_path(Path::new("/base"), "sub/../escape");
assert!(
result.is_err(),
"disguised traversal 'sub/../escape' must be rejected"
);
}
// ---------------------------------------------------------------------------
// TC-10.28: init_bundled_skills is idempotent
// ---------------------------------------------------------------------------
#[test]
#[serial]
fn tc_10_28_init_idempotent() {
clear_bundled_skills();
init_bundled_skills();
init_bundled_skills();
let skills = get_bundled_skills();
let hello_count = skills.iter().filter(|s| s.name == "hello").count();
assert_eq!(
hello_count, 1,
"init_bundled_skills must be idempotent — hello should appear exactly once"
);
}
@@ -0,0 +1,48 @@
use super::{BundledSkillDefinition, register_bundled_skill};
/// Register the built-in "hello" skill used to validate the bundled skill framework.
pub fn register_hello_skill() {
register_bundled_skill(BundledSkillDefinition {
name: "hello",
description: "A simple greeting skill for testing the bundled skill framework.",
content: "Hello! I'm a bundled skill. How can I help you today?\n\n$ARGUMENTS",
user_invocable: true,
when_to_use: None,
argument_hint: None,
allowed_tools: &[],
model: None,
disable_model_invocation: false,
context: None,
agent: None,
files: &[],
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::super::{clear_bundled_skills, get_bundled_skills};
use super::register_hello_skill;
use serial_test::serial;
// TC-10.18: hello skill fields are correct
#[test]
#[serial]
fn tc_10_18_hello_skill_fields_correct() {
clear_bundled_skills();
register_hello_skill();
let skills = get_bundled_skills();
let hello = skills
.iter()
.find(|s| s.name == "hello")
.expect("hello skill should be registered");
assert!(hello.user_invocable, "hello should be user_invocable");
assert!(
!hello.description.is_empty(),
"hello should have a non-empty description"
);
}
}
@@ -0,0 +1,341 @@
mod hello;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use crate::types::{ExecutionContext, LoadedFrom, SkillMetadata, SkillSource};
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/// Definition for a bundled skill compiled into the binary.
///
/// All string fields use `&'static str` because bundled skill definitions are
/// compile-time constants embedded in the binary.
pub struct BundledSkillDefinition {
pub name: &'static str,
pub description: &'static str,
pub when_to_use: Option<&'static str>,
pub argument_hint: Option<&'static str>,
pub allowed_tools: &'static [&'static str],
pub model: Option<&'static str>,
pub disable_model_invocation: bool,
pub user_invocable: bool,
/// "inline" | "fork"
pub context: Option<&'static str>,
pub agent: Option<&'static str>,
/// Embedded reference files: (relative_path, content) pairs.
/// Extracted to disk on first invocation via `extract_bundled_skill_files`.
pub files: &'static [(&'static str, &'static str)],
/// Skill body content (Markdown).
pub content: &'static str,
}
// ---------------------------------------------------------------------------
// Global registry
// ---------------------------------------------------------------------------
static REGISTRY: OnceLock<Mutex<Vec<BundledSkillDefinition>>> = OnceLock::new();
fn registry() -> &'static Mutex<Vec<BundledSkillDefinition>> {
REGISTRY.get_or_init(|| Mutex::new(Vec::new()))
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Register a bundled skill definition into the global registry.
pub fn register_bundled_skill(def: BundledSkillDefinition) {
registry()
.lock()
.expect("bundled skill registry lock poisoned")
.push(def);
}
/// Get all registered bundled skills as `SkillMetadata`.
///
/// Does NOT extract files to disk — `skill_root` is always `None` for skills
/// that have embedded files. Use `prepare_bundled_skills()` from an async
/// context to get metadata with `skill_root` populated.
pub fn get_bundled_skills() -> Vec<SkillMetadata> {
registry()
.lock()
.expect("bundled skill registry lock poisoned")
.iter()
.map(definition_to_metadata)
.collect()
}
/// Async version: get bundled skills with file extraction.
///
/// For each skill that has embedded `files`, calls `extract_bundled_skill_files`
/// and sets `skill_root` to the extraction directory on success. File extraction
/// failure is non-fatal — `skill_root` remains `None` and the skill still works.
///
/// Called from `load_all_skills()` (async context). Not suitable for sync callers.
pub async fn prepare_bundled_skills() -> Vec<SkillMetadata> {
let mut skills = get_bundled_skills();
// Collect (name, files) for skills that have embedded reference files.
let defs_with_files: Vec<(String, Vec<(&'static str, &'static str)>)> = {
let guard = registry()
.lock()
.expect("bundled skill registry lock poisoned");
guard
.iter()
.filter(|d| !d.files.is_empty())
.map(|d| (d.name.to_owned(), d.files.to_vec()))
.collect()
};
for (name, files) in defs_with_files {
if let Some(dir) = extract_bundled_skill_files(&name, &files).await
&& let Some(meta) = skills.iter_mut().find(|m| m.name == name)
{
meta.skill_root = Some(dir.to_string_lossy().into_owned());
}
}
skills
}
/// Initialize all built-in bundled skills.
///
/// Clears the registry first to guarantee idempotency — safe to call multiple
/// times (useful in tests).
pub fn init_bundled_skills() {
clear_bundled_skills_inner();
hello::register_hello_skill();
}
/// Returns the extraction directory for a bundled skill's reference files.
///
/// Path: `$TMPDIR/nomi-bundled-skills-{pid}/{skill_name}`
/// Uses PID as a per-process nonce to prevent symlink pre-creation attacks.
/// NOTE: This directory is not cleaned up automatically; it accumulates across
/// process restarts until the OS purges the temp directory.
pub fn get_bundled_skill_extract_dir(skill_name: &str) -> PathBuf {
let pid = std::process::id();
let tmp = std::env::temp_dir();
tmp.join(format!("nomi-bundled-skills-{pid}"))
.join(skill_name)
}
/// Extract a bundled skill's reference files to disk.
///
/// Security properties:
/// - Directory created with mode 0o700 (owner-only).
/// - Files written with `create_new(true)` (O_CREAT|O_EXCL) to prevent
/// overwriting existing files.
/// - On Unix, O_NOFOLLOW is added via `OpenOptionsExt` to prevent symlink
/// attacks on the final path component.
/// - Relative paths validated: `..` components and absolute paths are rejected.
///
/// Returns the extraction directory on success, or `None` if extraction fails.
/// Failure is non-fatal — the skill continues to work without a `skill_root`.
pub async fn extract_bundled_skill_files(
skill_name: &str,
files: &[(&str, &str)],
) -> Option<PathBuf> {
if files.is_empty() {
return None;
}
let dir = get_bundled_skill_extract_dir(skill_name);
match write_skill_files(&dir, files).await {
Ok(()) => Some(dir),
Err(e) => {
// Non-fatal: log and degrade gracefully (skill runs without skill_root)
tracing::warn!(target: "nomi_skills", skill = %skill_name, path = %dir.display(), error = %e, "failed to extract bundled skill");
None
}
}
}
// ---------------------------------------------------------------------------
// Internal: conversion
// ---------------------------------------------------------------------------
fn definition_to_metadata(def: &BundledSkillDefinition) -> SkillMetadata {
let execution_context = match def.context {
Some("fork") => ExecutionContext::Fork,
_ => ExecutionContext::Inline,
};
let content_length = def.content.len();
SkillMetadata {
name: def.name.to_owned(),
display_name: None,
description: def.description.to_owned(),
has_user_specified_description: true,
allowed_tools: def.allowed_tools.iter().map(|s| s.to_string()).collect(),
argument_hint: def.argument_hint.map(str::to_owned),
argument_names: Vec::new(),
when_to_use: def.when_to_use.map(str::to_owned),
version: None,
model: def.model.map(str::to_owned),
disable_model_invocation: def.disable_model_invocation,
user_invocable: def.user_invocable,
execution_context,
agent: def.agent.map(str::to_owned),
effort: None,
shell: None,
paths: Vec::new(),
hooks_raw: None,
source: SkillSource::Bundled,
loaded_from: LoadedFrom::Bundled,
content: def.content.to_owned(),
content_length,
// skill_root is set later by extract_bundled_skill_files in load_all_skills
skill_root: None,
}
}
// ---------------------------------------------------------------------------
// Internal: file extraction
// ---------------------------------------------------------------------------
async fn write_skill_files(dir: &std::path::Path, files: &[(&str, &str)]) -> std::io::Result<()> {
use std::collections::HashMap;
// Group files by parent directory to minimise mkdir calls.
let mut by_parent: HashMap<PathBuf, Vec<(PathBuf, &str)>> = HashMap::new();
for (rel_path, content) in files {
let target = resolve_skill_file_path(dir, rel_path)?;
let parent = target
.parent()
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent")
})?
.to_owned();
by_parent.entry(parent).or_default().push((target, content));
}
// Create directories and write files.
for (parent, entries) in by_parent {
create_dir_secure(&parent).await?;
for (path, content) in entries {
safe_write_file(&path, content).await?;
}
}
Ok(())
}
/// Create a directory (and all parents) with owner-only permissions (0o700).
async fn create_dir_secure(dir: &std::path::Path) -> std::io::Result<()> {
let dir = dir.to_owned();
tokio::task::spawn_blocking(move || {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&dir)
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(&dir)
}
})
.await
.map_err(std::io::Error::other)?
}
/// Write `content` to `path` using O_CREAT|O_EXCL (and O_NOFOLLOW on Unix).
/// Fails if the file already exists or if `path` is a symlink (Unix only).
async fn safe_write_file(path: &std::path::Path, content: &str) -> std::io::Result<()> {
let file = open_secure(path).await?;
let mut file = tokio::fs::File::from_std(file);
use tokio::io::AsyncWriteExt;
file.write_all(content.as_bytes()).await?;
file.flush().await
}
/// Open a file for writing with O_CREAT|O_EXCL (+ O_NOFOLLOW on Unix, mode 0o600).
async fn open_secure(path: &std::path::Path) -> std::io::Result<std::fs::File> {
let path = path.to_owned();
// Use spawn_blocking because OpenOptions with custom_flags is synchronous.
tokio::task::spawn_blocking(move || {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
// O_NOFOLLOW: refuse to open if final path component is a symlink.
// Belt-and-suspenders alongside O_EXCL (mirrors TS implementation).
.custom_flags(libc::O_NOFOLLOW)
.open(&path)
}
#[cfg(not(unix))]
{
// Windows: 'x' flag (exclusive create) via create_new — no O_NOFOLLOW equivalent.
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
}
})
.await
.map_err(std::io::Error::other)?
}
/// Validate and resolve a skill-relative path.
/// Rejects absolute paths and any path containing `..` components.
fn resolve_skill_file_path(base_dir: &std::path::Path, rel_path: &str) -> std::io::Result<PathBuf> {
let normalized = std::path::Path::new(rel_path);
if normalized.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("bundled skill file path must be relative: {rel_path}"),
));
}
for component in normalized.components() {
use std::path::Component;
if matches!(component, Component::ParentDir) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("bundled skill file path escapes skill dir: {rel_path}"),
));
}
}
Ok(base_dir.join(normalized))
}
// ---------------------------------------------------------------------------
// Test helpers (registry reset only — no test logic here)
// ---------------------------------------------------------------------------
fn clear_bundled_skills_inner() {
registry()
.lock()
.expect("bundled skill registry lock poisoned")
.clear();
}
/// Clear the bundled skill registry.
///
/// Exposed for test isolation. Production code should use `init_bundled_skills()`
/// which calls this internally.
#[cfg(test)]
pub fn clear_bundled_skills() {
clear_bundled_skills_inner();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[path = "bundled_tests.rs"]
mod tests;
@@ -0,0 +1,126 @@
// Phase 10 supplemental tests — loader integration for bundled skills.
// Covers TC-10.20~10.22 and TC-10.26:
// TC-10.20: bundled skills appear in load_all_skills (normal mode)
// TC-10.21: bundled skill wins deduplication over same-named filesystem skill
// TC-10.22: bundled skill virtual path format
// TC-10.26: bare mode also includes bundled skills (AC-14)
#[cfg(test)]
#[allow(clippy::module_inception)]
mod bundled_supplemental_tests {
use crate::bundled::{
BundledSkillDefinition, clear_bundled_skills, get_bundled_skills, register_bundled_skill,
};
use crate::loader::load_all_skills;
use crate::types::SkillSource;
use serial_test::serial;
use std::fs;
use tempfile::TempDir;
fn minimal_def(name: &'static str) -> BundledSkillDefinition {
BundledSkillDefinition {
name,
description: "supplemental test skill",
when_to_use: None,
argument_hint: None,
allowed_tools: &[],
model: None,
disable_model_invocation: false,
user_invocable: false,
context: None,
agent: None,
files: &[],
content: "content",
}
}
fn write_skill_dir(dir: &std::path::Path, name: &str) {
let skill_dir = dir.join(name);
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), "---\n---\n").unwrap();
}
// TC-10.20: bundled skill appears in load_all_skills (normal mode)
#[tokio::test]
#[serial]
async fn tc_10_20_bundled_in_load_all_skills_normal() {
clear_bundled_skills();
register_bundled_skill(minimal_def("bundled-only"));
let tmp = TempDir::new().unwrap();
let result = load_all_skills(tmp.path(), &[], false, None).await;
let found = result
.iter()
.find(|s| s.name == "bundled-only")
.expect("bundled skill should appear in load_all_skills result");
assert_eq!(
found.source,
SkillSource::Bundled,
"bundled skill source should be Bundled"
);
clear_bundled_skills();
}
// TC-10.21: bundled skill wins deduplication over same-named filesystem skill
#[tokio::test]
#[serial]
async fn tc_10_21_bundled_wins_dedup_over_filesystem() {
clear_bundled_skills();
register_bundled_skill(minimal_def("shared-name"));
let tmp = TempDir::new().unwrap();
write_skill_dir(tmp.path(), "shared-name");
let result = load_all_skills(tmp.path(), &[tmp.path().to_path_buf()], false, None).await;
let matches: Vec<_> = result.iter().filter(|s| s.name == "shared-name").collect();
assert_eq!(
matches.len(),
1,
"deduplication should leave exactly one 'shared-name'"
);
assert_eq!(
matches[0].source,
SkillSource::Bundled,
"bundled skill should win deduplication"
);
clear_bundled_skills();
}
// TC-10.22: bundled skill virtual path format
#[test]
fn tc_10_22_virtual_path_format() {
let virtual_path = std::path::PathBuf::from(format!("<bundled:{}>", "path-test"));
assert_eq!(virtual_path.to_str().unwrap(), "<bundled:path-test>");
}
// TC-10.26: bare mode also includes bundled skills (AC-14, C-6 decision)
#[tokio::test]
#[serial]
async fn tc_10_26_bare_mode_includes_bundled() {
clear_bundled_skills();
register_bundled_skill(minimal_def("bundled-bare"));
let tmp = TempDir::new().unwrap();
let result = load_all_skills(tmp.path(), &[], true, None).await;
let found = result
.iter()
.find(|s| s.name == "bundled-bare")
.expect("bundled skill should appear in bare mode load_all_skills");
assert_eq!(
found.source,
SkillSource::Bundled,
"bundled skill source should be Bundled in bare mode"
);
// Verify total registry is accessible after test
let reg = get_bundled_skills();
assert!(!reg.is_empty());
clear_bundled_skills();
}
}
@@ -0,0 +1,323 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use glob::Pattern;
use crate::types::SkillMetadata;
// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------
/// A dormant conditional skill with its pre-compiled glob patterns.
struct ConditionalEntry {
skill: SkillMetadata,
/// Pre-compiled glob patterns for efficient matching.
/// Invalid patterns are skipped at compile time with a warning (C1).
patterns: Vec<Pattern>,
}
// ---------------------------------------------------------------------------
// Public manager
// ---------------------------------------------------------------------------
/// Manages conditional skills (skills with `paths:` frontmatter).
///
/// Conditional skills start dormant and become active when the LLM operates
/// on files whose paths match the skill's `paths:` glob patterns.
///
/// # Matching semantics
///
/// Uses [`glob::Pattern`] for path matching. This covers the common cases
/// (`*.rs`, `src/**/*.ts`, etc.) but does **not** support `!` negation
/// patterns (unlike the TypeScript `ignore` library used in the reference implementation).
/// Invalid patterns are logged and skipped rather than causing a panic (C1).
///
/// # Concurrency
///
/// Not designed for concurrent access — caller wraps in `Arc<Mutex<>>` if needed.
pub struct ConditionalSkillManager {
/// Dormant skills awaiting activation, keyed by skill name.
dormant: HashMap<String, ConditionalEntry>,
/// Activated skills, keyed by skill name.
activated: HashMap<String, SkillMetadata>,
/// Names of skills that have been activated (survives `clear_dormant` calls).
activated_names: HashSet<String>,
}
impl Default for ConditionalSkillManager {
fn default() -> Self {
Self::new()
}
}
impl ConditionalSkillManager {
/// Create a new, empty manager.
pub fn new() -> Self {
Self {
dormant: HashMap::new(),
activated: HashMap::new(),
activated_names: HashSet::new(),
}
}
/// Separate conditional skills from a loaded skill list.
///
/// Returns the unconditional skills; conditional ones are stored internally
/// as dormant, awaiting path-based activation.
///
/// Skills whose names are already in `activated_names` are treated as
/// unconditional and returned directly (they survive cache reloads).
///
/// # Multiple calls
///
/// Subsequent calls with the same skill name overwrite the existing dormant
/// entry (HashMap::insert semantics). To fully rebuild, call `clear_dormant()`
/// before re-partitioning (C6).
///
/// Aligns with TypeScript `loadSkillsDir.ts` L771-802.
pub fn partition_skills(&mut self, skills: Vec<SkillMetadata>) -> Vec<SkillMetadata> {
let mut unconditional = Vec::new();
for skill in skills {
// Conditional = has non-empty paths AND not yet activated
if !skill.paths.is_empty() && !self.activated_names.contains(&skill.name) {
let patterns = compile_patterns(&skill.name, &skill.paths);
self.dormant
.insert(skill.name.clone(), ConditionalEntry { skill, patterns });
} else {
unconditional.push(skill);
}
}
unconditional
}
/// Check file paths against dormant skills and activate any matches.
///
/// `file_paths` are expected to be absolute paths; `cwd` is used to compute
/// relative paths before matching. Paths outside `cwd` (starting with `..`
/// after relativization, or still absolute on cross-drive systems) are
/// skipped — they cannot match cwd-relative patterns.
///
/// Returns the names of newly activated skills (empty if none matched).
/// The order of returned names is not guaranteed (HashMap iteration order).
///
/// Aligns with TypeScript `activateConditionalSkillsForPaths` L997-1058.
pub fn activate_for_paths(&mut self, file_paths: &[&str], cwd: &str) -> Vec<String> {
if self.dormant.is_empty() {
return Vec::new();
}
let cwd_path = Path::new(cwd);
// Collect names to activate first (cannot mutate dormant while iterating it)
let mut to_activate: Vec<String> = Vec::new();
'outer: for (name, entry) in &self.dormant {
for &file_path in file_paths {
let rel = match relativize(file_path, cwd_path) {
Some(r) => r,
None => continue,
};
for pattern in &entry.patterns {
if pattern.matches(&rel) {
tracing::info!(target: "nomi_skills", skill = %name, path = %rel, "activated conditional skill");
to_activate.push(name.clone());
continue 'outer;
}
}
}
}
// Apply activations: move from dormant → activated
for name in &to_activate {
if let Some(entry) = self.dormant.remove(name) {
self.activated_names.insert(name.clone());
self.activated.insert(name.clone(), entry.skill);
}
}
to_activate
}
/// Retrieve a specific activated skill by name.
///
/// Used by SkillTool to fetch the skill definition on invocation.
pub fn get_activated(&self, name: &str) -> Option<&SkillMetadata> {
self.activated.get(name)
}
/// Get all currently activated skills.
///
/// Used for prompt listing.
pub fn get_all_activated(&self) -> Vec<&SkillMetadata> {
self.activated.values().collect()
}
/// Returns the number of skills currently dormant.
pub fn dormant_count(&self) -> usize {
self.dormant.len()
}
/// Clear dormant skills (e.g., when reloading from disk).
///
/// `activated_names` is preserved so previously activated skills remain
/// treated as unconditional on the next `partition_skills` call.
pub fn clear_dormant(&mut self) {
self.dormant.clear();
}
/// Full reset: clear dormant skills, activated skills, and activated names.
///
/// Corresponds to the TypeScript `clearDynamicSkills()` (L1070-1075) which
/// also clears `activatedConditionalSkillNames`. Use when a complete session
/// reset is needed (C5).
pub fn reset_all(&mut self) {
self.dormant.clear();
self.activated.clear();
self.activated_names.clear();
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Compile a list of glob pattern strings into [`Pattern`] instances.
///
/// Invalid patterns (e.g., those containing `!` negation which is not supported
/// by `glob::Pattern`) are logged and skipped rather than panicking (C1).
fn compile_patterns(skill_name: &str, raw_patterns: &[String]) -> Vec<Pattern> {
raw_patterns
.iter()
.filter_map(|p| match Pattern::new(p) {
Ok(pat) => Some(pat),
Err(e) => {
tracing::warn!(target: "nomi_skills", skill = %skill_name, pattern = %p, error = %e, "invalid glob pattern, skipping");
None
}
})
.collect()
}
/// Convert `file_path` to a path relative to `cwd`.
///
/// Returns `None` for paths that:
/// - are empty after relativization
/// - escape `cwd` (start with `..`)
/// - remain absolute (cross-drive on Windows)
///
/// Aligns with TypeScript guard at L1019-1027.
fn relativize(file_path: &str, cwd: &Path) -> Option<String> {
let abs = Path::new(file_path);
let rel = if abs.is_absolute() {
match abs.strip_prefix(cwd) {
Ok(r) => r.to_string_lossy().into_owned(),
// strip_prefix fails when abs is not under cwd — treat as outside
Err(_) => return None,
}
} else {
file_path.to_owned()
};
if rel.is_empty() || rel.starts_with("..") || Path::new(&rel).is_absolute() {
return None;
}
// Normalise separators to forward-slash for glob matching consistency
Some(rel.replace('\\', "/"))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[cfg(not(windows))] // Path handling differs on Windows; skip these tests there
mod tests {
use std::path::Path;
use super::{compile_patterns, relativize};
// --- compile_patterns ---
#[test]
fn compile_patterns_valid_returns_all() {
let patterns = compile_patterns("skill", &["src/**/*.rs".to_string(), "*.ts".to_string()]);
assert_eq!(patterns.len(), 2);
}
// NOTE: glob::Pattern accepts "!negation" as a valid (literal) pattern —
// the "!" character is not special in glob::Pattern, only in gitignore.
// compile_patterns skips patterns that glob::Pattern::new rejects (e.g.
// patterns with unclosed brackets like "[bad"), not gitignore-style "!".
#[test]
fn compile_patterns_unclosed_bracket_skipped_no_panic() {
// "[unclosed" is syntactically invalid for glob::Pattern — should be skipped
let patterns = compile_patterns("skill", &["[unclosed".to_string()]);
assert_eq!(patterns.len(), 0);
}
#[test]
fn compile_patterns_mixed_keeps_valid_drops_syntactically_invalid() {
let patterns = compile_patterns("skill", &["[bad".to_string(), "src/**/*.rs".to_string()]);
assert_eq!(patterns.len(), 1);
assert!(patterns[0].matches("src/lib.rs"));
}
#[test]
fn compile_patterns_empty_input_returns_empty() {
let patterns = compile_patterns("skill", &[]);
assert!(patterns.is_empty());
}
// --- relativize ---
#[test]
fn relativize_absolute_under_cwd_returns_relative() {
let cwd = Path::new("/project");
let result = relativize("/project/src/lib.rs", cwd);
assert_eq!(result, Some("src/lib.rs".to_string()));
}
#[test]
fn relativize_absolute_outside_cwd_returns_none() {
let cwd = Path::new("/project");
let result = relativize("/other/file.rs", cwd);
assert!(result.is_none());
}
#[test]
fn relativize_empty_string_returns_none() {
let cwd = Path::new("/project");
let result = relativize("", cwd);
assert!(result.is_none());
}
#[test]
fn relativize_path_equal_to_cwd_returns_none() {
// strip_prefix of cwd from itself → empty string → rejected
let cwd = Path::new("/project");
let result = relativize("/project", cwd);
assert!(result.is_none());
}
#[test]
fn relativize_relative_input_returned_as_is() {
// Non-absolute paths are passed through (caller's responsibility to provide absolute)
let cwd = Path::new("/project");
let result = relativize("src/lib.rs", cwd);
assert_eq!(result, Some("src/lib.rs".to_string()));
}
}
// ---------------------------------------------------------------------------
// Supplemental tests (tester role — covers test-plan.md cases)
// ---------------------------------------------------------------------------
#[cfg(test)]
#[path = "conditional_supplemental_tests.rs"]
mod supplemental_tests;
@@ -0,0 +1,411 @@
// Supplemental tests for Phase 8 — ConditionalSkillManager.
// Covers test-plan.md TC-1 through TC-20, plus AC-10 and AC-11.
#[cfg(test)]
#[cfg(not(windows))] // Path handling differs on Windows; skip these tests there
mod conditional_supplemental_tests {
use crate::conditional::ConditionalSkillManager;
use crate::types::{ExecutionContext, LoadedFrom, SkillMetadata, SkillSource};
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
fn make_skill(name: &str) -> SkillMetadata {
SkillMetadata {
name: name.to_string(),
display_name: None,
description: String::new(),
has_user_specified_description: false,
allowed_tools: vec![],
argument_hint: None,
argument_names: vec![],
when_to_use: None,
version: None,
model: None,
disable_model_invocation: false,
user_invocable: true,
execution_context: ExecutionContext::Inline,
agent: None,
effort: None,
shell: None,
paths: vec![],
hooks_raw: None,
source: SkillSource::Project,
loaded_from: LoadedFrom::Skills,
content: String::new(),
content_length: 0,
skill_root: None,
}
}
fn make_conditional_skill(name: &str, patterns: Vec<&str>) -> SkillMetadata {
let mut skill = make_skill(name);
skill.paths = patterns.into_iter().map(|s| s.to_string()).collect();
skill
}
// ---------------------------------------------------------------------------
// TC-1: new_creates_empty_manager
// ---------------------------------------------------------------------------
// TC-1: new() yields an empty manager with no dormant or activated skills.
#[test]
fn tc1_new_creates_empty_manager() {
let mgr = ConditionalSkillManager::new();
assert_eq!(mgr.dormant_count(), 0);
assert!(mgr.get_all_activated().is_empty());
}
// ---------------------------------------------------------------------------
// TC-2 to TC-5: partition_skills
// ---------------------------------------------------------------------------
// TC-2: conditional and unconditional skills are separated correctly.
#[test]
fn tc2_partition_separates_conditional_and_unconditional() {
let mut mgr = ConditionalSkillManager::new();
let skills = vec![
make_conditional_skill("backend", vec!["src/**/*.rs"]),
make_conditional_skill("frontend", vec!["src/**/*.ts"]),
make_skill("no-paths"),
];
let unconditional = mgr.partition_skills(skills);
assert_eq!(unconditional.len(), 1);
assert_eq!(unconditional[0].name, "no-paths");
assert_eq!(mgr.dormant_count(), 2);
assert!(mgr.get_all_activated().is_empty());
}
// TC-3: partition_skills with empty input returns empty list and leaves manager empty.
#[test]
fn tc3_partition_empty_input() {
let mut mgr = ConditionalSkillManager::new();
let result = mgr.partition_skills(vec![]);
assert!(result.is_empty());
assert_eq!(mgr.dormant_count(), 0);
}
// TC-4: all-unconditional input — everything returned, dormant stays zero.
#[test]
fn tc4_partition_all_unconditional() {
let mut mgr = ConditionalSkillManager::new();
let skills = vec![make_skill("a"), make_skill("b"), make_skill("c")];
let result = mgr.partition_skills(skills);
assert_eq!(result.len(), 3);
assert_eq!(mgr.dormant_count(), 0);
}
// TC-5: already-activated skill is treated as unconditional on re-partition.
#[test]
fn tc5_partition_already_activated_treated_as_unconditional() {
let mut mgr = ConditionalSkillManager::new();
// First round: partition + activate
let skills = vec![make_conditional_skill("foo", vec!["**/*.rs"])];
mgr.partition_skills(skills);
let activated = mgr.activate_for_paths(&["/project/main.rs"], "/project");
assert_eq!(activated, vec!["foo"]);
// Second round: same skill with paths — should be returned as unconditional
let skills2 = vec![make_conditional_skill("foo", vec!["**/*.rs"])];
let result = mgr.partition_skills(skills2);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "foo");
// dormant count doesn't gain "foo" again
assert_eq!(mgr.dormant_count(), 0);
}
// ---------------------------------------------------------------------------
// TC-6 to TC-13: activate_for_paths
// ---------------------------------------------------------------------------
// TC-6: simple glob `src/**/*.rs` matches nested rust source file.
#[test]
fn tc6_activate_matches_simple_glob() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![make_conditional_skill("backend", vec!["src/**/*.rs"])]);
let activated = mgr.activate_for_paths(&["/project/src/lib.rs"], "/project");
assert_eq!(activated, vec!["backend"]);
assert!(mgr.get_activated("backend").is_some());
assert_eq!(mgr.dormant_count(), 0);
}
// TC-7: wildcard extension `*.ts` matches typescript file in cwd root.
#[test]
fn tc7_activate_matches_wildcard_extension() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![make_conditional_skill("ts-skill", vec!["*.ts"])]);
let activated = mgr.activate_for_paths(&["/project/index.ts"], "/project");
assert_eq!(activated, vec!["ts-skill"]);
}
// TC-8: no match — activate_for_paths returns empty list.
#[test]
fn tc8_activate_no_match() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![make_conditional_skill("backend", vec!["src/**/*.rs"])]);
let activated = mgr.activate_for_paths(&["/project/docs/readme.md"], "/project");
assert!(activated.is_empty());
assert_eq!(mgr.dormant_count(), 1);
}
// TC-9: path outside cwd is skipped — relative path would start with `..`.
#[test]
fn tc9_activate_skips_path_outside_cwd() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![make_conditional_skill("s", vec!["**/*.rs"])]);
let activated = mgr.activate_for_paths(&["/other/file.rs"], "/project");
assert!(activated.is_empty());
assert_eq!(mgr.dormant_count(), 1);
}
// TC-10: completely different absolute path that cannot be relativized to cwd.
#[test]
fn tc10_activate_skips_unrelated_absolute_path() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![make_conditional_skill("s", vec!["**/*.rs"])]);
let activated = mgr.activate_for_paths(&["/completely/different/file.rs"], "/project");
assert!(activated.is_empty());
}
// TC-11: empty string path is skipped without panic.
#[test]
fn tc11_activate_skips_empty_string_path() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![make_conditional_skill("s", vec!["**/*.rs"])]);
let activated = mgr.activate_for_paths(&[""], "/project");
assert!(activated.is_empty());
}
// TC-12: multiple files activate multiple matching skills in one call.
#[test]
fn tc12_activate_multiple_skills_in_one_call() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![
make_conditional_skill("a", vec!["src/**/*.rs"]),
make_conditional_skill("b", vec!["tests/**/*.rs"]),
]);
let mut activated = mgr.activate_for_paths(
&["/project/src/lib.rs", "/project/tests/test_main.rs"],
"/project",
);
activated.sort();
assert_eq!(activated, vec!["a", "b"]);
assert!(mgr.get_activated("a").is_some());
assert!(mgr.get_activated("b").is_some());
}
// TC-13: second call with same paths returns empty (already-activated skills
// are not re-activated).
#[test]
fn tc13_activate_idempotent() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![make_conditional_skill("foo", vec!["**/*.rs"])]);
let first = mgr.activate_for_paths(&["/project/main.rs"], "/project");
assert_eq!(first, vec!["foo"]);
let second = mgr.activate_for_paths(&["/project/main.rs"], "/project");
assert!(second.is_empty());
}
// ---------------------------------------------------------------------------
// TC-14 to TC-15: activated_names persistence and clear_dormant
// ---------------------------------------------------------------------------
// TC-14: activated_names survive clear_dormant so re-partitioned same skill
// goes to unconditional list instead of dormant.
#[test]
fn tc14_activated_names_preserved_across_clear_dormant() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![make_conditional_skill("foo", vec!["**/*.rs"])]);
mgr.activate_for_paths(&["/project/main.rs"], "/project");
mgr.clear_dormant();
// Re-partition: "foo" has paths but is already activated → unconditional
let result = mgr.partition_skills(vec![make_conditional_skill("foo", vec!["**/*.rs"])]);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "foo");
assert_eq!(mgr.dormant_count(), 0);
}
// TC-15: clear_dormant removes all unactivated dormant skills.
#[test]
fn tc15_clear_dormant_removes_unactivated() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![
make_conditional_skill("a", vec!["**/*.rs"]),
make_conditional_skill("b", vec!["**/*.ts"]),
make_conditional_skill("c", vec!["**/*.go"]),
]);
assert_eq!(mgr.dormant_count(), 3);
mgr.clear_dormant();
assert_eq!(mgr.dormant_count(), 0);
assert!(mgr.get_all_activated().is_empty());
}
// ---------------------------------------------------------------------------
// TC-16 to TC-17: get_activated / dormant_count
// ---------------------------------------------------------------------------
// TC-16: get_activated returns None for unknown / unactivated skill names.
#[test]
fn tc16_get_activated_returns_none_for_unknown() {
let mgr = ConditionalSkillManager::new();
assert!(mgr.get_activated("nonexistent").is_none());
}
// TC-17: dormant_count accurately reflects state after partition and activate.
#[test]
fn tc17_dormant_count_reflects_partition_and_activate() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![
make_conditional_skill("a", vec!["**/*.rs"]),
make_conditional_skill("b", vec!["**/*.ts"]),
make_conditional_skill("c", vec!["**/*.go"]),
]);
assert_eq!(mgr.dormant_count(), 3);
mgr.activate_for_paths(&["/project/main.rs"], "/project");
assert_eq!(mgr.dormant_count(), 2);
}
// ---------------------------------------------------------------------------
// TC-18 to TC-20: glob pattern edge cases (AC-9)
// ---------------------------------------------------------------------------
// TC-18: `src/**/*.ts` matches multi-level nested TypeScript files.
#[test]
fn tc18_glob_src_double_star_ts() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![make_conditional_skill(
"ts-skill",
vec!["src/**/*.ts"],
)]);
let activated = mgr.activate_for_paths(&["/app/src/components/Button.ts"], "/app");
assert_eq!(activated, vec!["ts-skill"]);
}
// TC-19: `*.rs` matches rust file at cwd root level.
#[test]
fn tc19_glob_root_star_rs_matches_root_file() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![make_conditional_skill("root-rs", vec!["*.rs"])]);
let activated = mgr.activate_for_paths(&["/app/main.rs"], "/app");
assert_eq!(activated, vec!["root-rs"]);
}
// TC-20: Documents glob::Pattern matching behaviour for `*.rs`.
//
// NOTE: `glob::Pattern` uses shell-glob semantics where `*` DOES match
// path separators (unlike gitignore semantics where `*` stops at `/`).
// Therefore `*.rs` matches both `main.rs` and `src/lib.rs` in glob::Pattern.
//
// This test documents this behaviour rather than asserting the opposite,
// so that future readers understand why the implementation uses glob::Pattern
// and its trade-offs vs the TypeScript `ignore` library (which respects `/`).
#[test]
fn tc20_glob_star_rs_matches_subdir_due_to_glob_semantics() {
let mut mgr = ConditionalSkillManager::new();
// `*.rs` with glob::Pattern matches any `.rs` file, including in subdirs
mgr.partition_skills(vec![make_conditional_skill("root-rs", vec!["*.rs"])]);
// glob::Pattern `*.rs` DOES match `src/lib.rs` (star matches `/`)
let activated = mgr.activate_for_paths(&["/app/src/lib.rs"], "/app");
// Document the actual behaviour: activated (glob semantics, not gitignore)
assert_eq!(activated, vec!["root-rs"]);
}
// ---------------------------------------------------------------------------
// AC-10: invalid glob pattern does not panic (e.g. `!negation`)
// ---------------------------------------------------------------------------
// AC-10: partition_skills with an invalid glob pattern in `paths:` does not
// panic — the pattern is skipped with a warning.
#[test]
fn ac10_invalid_glob_pattern_does_not_panic() {
let mut mgr = ConditionalSkillManager::new();
// "!negation" is not a valid glob::Pattern — should be skipped
let skill = make_conditional_skill("bad-pattern", vec!["!negation", "src/**/*.rs"]);
mgr.partition_skills(vec![skill]);
assert_eq!(mgr.dormant_count(), 1);
// The valid pattern `src/**/*.rs` should still work even though `!negation` was skipped.
// Use a relative path to avoid platform-dependent absolute path issues
let activated = mgr.activate_for_paths(&["src/main.rs"], ".");
assert_eq!(activated.len(), 1);
assert_eq!(activated[0], "bad-pattern");
assert!(mgr.get_activated("bad-pattern").is_some());
}
// AC-10b: all-invalid patterns results in a dormant skill that never activates.
#[test]
fn ac10b_all_invalid_patterns_skill_never_activates() {
let mut mgr = ConditionalSkillManager::new();
let skill = make_conditional_skill("all-invalid", vec!["!invalid1", "!invalid2"]);
mgr.partition_skills(vec![skill]);
assert_eq!(mgr.dormant_count(), 1);
let activated = mgr.activate_for_paths(&["any/file.rs"], ".");
// No valid patterns → nothing activates
assert!(activated.is_empty());
assert_eq!(mgr.dormant_count(), 1);
}
// ---------------------------------------------------------------------------
// AC-11: reset_all clears everything including activated_names
// ---------------------------------------------------------------------------
// AC-11a: reset_all clears dormant skills, activated skills, and activated_names.
#[test]
fn ac11a_reset_all_clears_dormant_and_activated() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![
make_conditional_skill("a", vec!["**/*.rs"]),
make_conditional_skill("b", vec!["**/*.ts"]),
]);
mgr.activate_for_paths(&["/project/main.rs"], "/project");
assert_eq!(mgr.dormant_count(), 1);
assert_eq!(mgr.get_all_activated().len(), 1);
mgr.reset_all();
assert_eq!(mgr.dormant_count(), 0);
assert!(mgr.get_all_activated().is_empty());
}
// AC-11b: after reset_all, re-partitioning a previously-activated skill puts it
// back into dormant (activated_names was cleared).
#[test]
fn ac11b_reset_all_clears_activated_names_so_skill_goes_dormant_again() {
let mut mgr = ConditionalSkillManager::new();
mgr.partition_skills(vec![make_conditional_skill("foo", vec!["**/*.rs"])]);
mgr.activate_for_paths(&["/project/main.rs"], "/project");
// Confirm "foo" is activated
assert!(mgr.get_activated("foo").is_some());
mgr.reset_all();
// Re-partition: "foo" should now enter dormant (not unconditional)
let result = mgr.partition_skills(vec![make_conditional_skill("foo", vec!["**/*.rs"])]);
assert!(
result.is_empty(),
"foo should be dormant, not returned as unconditional"
);
assert_eq!(mgr.dormant_count(), 1);
}
}
@@ -0,0 +1,126 @@
use crate::types::SkillMetadata;
// Re-export from nomi-types so callers can use a single import path
pub use nomi_types::skill_types::{ContextModifier, effort_to_string};
/// Build a ContextModifier from skill metadata. Returns None if no overrides are specified.
pub fn from_skill(skill: &SkillMetadata) -> Option<ContextModifier> {
let has_overrides =
skill.model.is_some() || skill.effort.is_some() || !skill.allowed_tools.is_empty();
if !has_overrides {
return None;
}
Some(ContextModifier {
model: skill.model.clone(),
effort: skill.effort,
allowed_tools: skill.allowed_tools.clone(),
..Default::default()
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ExecutionContext, LoadedFrom, SkillSource};
use nomi_types::skill_types::EffortLevel;
fn make_skill(
model: Option<&str>,
effort: Option<EffortLevel>,
allowed_tools: Vec<String>,
) -> SkillMetadata {
SkillMetadata {
name: "test".to_string(),
display_name: None,
description: String::new(),
has_user_specified_description: false,
allowed_tools,
argument_hint: None,
argument_names: Vec::new(),
when_to_use: None,
version: None,
model: model.map(str::to_owned),
disable_model_invocation: false,
user_invocable: true,
execution_context: ExecutionContext::Inline,
agent: None,
effort,
shell: None,
paths: Vec::new(),
hooks_raw: None,
source: SkillSource::User,
loaded_from: LoadedFrom::Skills,
content: String::new(),
content_length: 0,
skill_root: None,
}
}
#[test]
fn test_from_skill_no_overrides_returns_none() {
let skill = make_skill(None, None, vec![]);
assert!(from_skill(&skill).is_none());
}
#[test]
fn test_from_skill_model_override() {
let skill = make_skill(Some("claude-opus-4-6"), None, vec![]);
let m = from_skill(&skill).unwrap();
assert_eq!(m.model.as_deref(), Some("claude-opus-4-6"));
assert!(m.effort.is_none());
assert!(m.allowed_tools.is_empty());
}
#[test]
fn test_from_skill_effort_override() {
let skill = make_skill(None, Some(EffortLevel::High), vec![]);
let m = from_skill(&skill).unwrap();
assert!(m.model.is_none());
assert_eq!(m.effort, Some(EffortLevel::High));
}
#[test]
fn test_from_skill_allowed_tools_override() {
let skill = make_skill(None, None, vec!["Bash".to_string(), "Read".to_string()]);
let m = from_skill(&skill).unwrap();
assert_eq!(m.allowed_tools, vec!["Bash", "Read"]);
}
#[test]
fn test_from_skill_all_overrides() {
let skill = make_skill(
Some("gpt-4o"),
Some(EffortLevel::Low),
vec!["Write".to_string()],
);
let m = from_skill(&skill).unwrap();
assert_eq!(m.model.as_deref(), Some("gpt-4o"));
assert_eq!(m.effort, Some(EffortLevel::Low));
assert_eq!(m.allowed_tools, vec!["Write"]);
}
#[test]
fn test_is_empty_on_default() {
let m = ContextModifier::default();
assert!(m.is_empty());
}
#[test]
fn test_is_empty_false_when_model_set() {
let m = ContextModifier {
model: Some("x".to_string()),
..Default::default()
};
assert!(!m.is_empty());
}
#[test]
fn test_effort_to_string_all_variants() {
assert_eq!(effort_to_string(EffortLevel::Low), "low");
assert_eq!(effort_to_string(EffortLevel::Medium), "medium");
assert_eq!(effort_to_string(EffortLevel::High), "high");
assert_eq!(effort_to_string(EffortLevel::Max), "max");
}
}
@@ -0,0 +1,366 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::loader::{LoadedSkill, load_skills_from_dir};
use crate::types::{LoadedFrom, SkillMetadata, SkillSource};
// ---------------------------------------------------------------------------
// Public manager
// ---------------------------------------------------------------------------
/// Manages runtime discovery of `.nomi/skills/` directories found in
/// subdirectories when the LLM operates on files.
///
/// CWD-level skills are loaded at startup; this manager handles dynamically
/// discovered skills in directories nested below the CWD.
///
/// # Concurrency
///
/// Not designed for concurrent access — caller wraps in `Arc<Mutex<>>` if needed.
pub struct RuntimeDiscovery {
/// Directories already checked (both hits and misses) — avoids repeated stat.
checked_dirs: HashSet<PathBuf>,
/// Skills loaded from dynamically discovered directories, keyed by skill name.
dynamic_skills: HashMap<String, SkillMetadata>,
}
impl Default for RuntimeDiscovery {
fn default() -> Self {
Self::new()
}
}
impl RuntimeDiscovery {
/// Create a new, empty discovery manager.
pub fn new() -> Self {
Self {
checked_dirs: HashSet::new(),
dynamic_skills: HashMap::new(),
}
}
/// Discover `.nomi/skills/` directories by walking up from each file path to `cwd`.
///
/// Only discovers directories **below** `cwd` (cwd-level skills are loaded at
/// startup). Already-checked directories are skipped to avoid redundant stat
/// calls on every Read/Write/Edit operation.
///
/// Directories belonging to gitignored paths are silently skipped via
/// `git check-ignore`. The check fails open (returns `false`) outside a git
/// repository or when the `git` binary is unavailable.
///
/// Returns newly discovered skill directories sorted deepest-first so that
/// skills closer to the file take precedence when names conflict.
///
/// Aligns with TypeScript `discoverSkillDirsForPaths` L861-915.
pub async fn discover_dirs_for_paths(
&mut self,
file_paths: &[&str],
cwd: &str,
) -> Vec<PathBuf> {
// Normalise cwd: strip trailing separator to avoid prefix-match false positives
let resolved_cwd = cwd.trim_end_matches(std::path::MAIN_SEPARATOR);
let cwd_with_sep = format!("{}{}", resolved_cwd, std::path::MAIN_SEPARATOR);
let mut new_dirs: Vec<PathBuf> = Vec::new();
for &file_path in file_paths {
let file = Path::new(file_path);
let Some(parent) = file.parent() else {
continue;
};
let mut current = parent.to_path_buf();
// Walk up toward cwd but NOT including cwd itself
// Use prefix+separator check to avoid /project-backup matching when cwd=/project
loop {
let current_str = current.to_string_lossy();
if !current_str.starts_with(&*cwd_with_sep) {
break;
}
let skill_dir = current.join(".nomi").join("skills");
if !self.checked_dirs.contains(&skill_dir) {
self.checked_dirs.insert(skill_dir.clone());
if tokio::fs::metadata(&skill_dir).await.is_ok() {
// Check if the containing directory (currentDir = skill_dir's
// grandparent) is gitignored. Aligns with TS L892 which passes
// `currentDir` (not skillDir) to isPathGitignored (C4).
let containing_dir = skill_dir
.parent() // .nomi/
.and_then(|p| p.parent()) // currentDir
.unwrap_or(&current);
if is_path_gitignored(containing_dir, resolved_cwd).await {
tracing::debug!(target: "nomi_skills", path = %skill_dir.display(), "skipping gitignored skills directory");
} else {
new_dirs.push(skill_dir);
}
}
}
// Move to parent
let parent_dir = match current.parent() {
Some(p) if p != current => p.to_path_buf(),
_ => break, // Reached filesystem root
};
current = parent_dir;
}
}
// Sort deepest-first: more path components = deeper
new_dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
new_dirs
}
/// Load skills from newly discovered directories and merge into dynamic skills.
///
/// Directories should be sorted deepest-first (as returned by
/// `discover_dirs_for_paths`). Deeper directories take precedence: when two
/// skills share a name, the one from the deeper directory wins.
///
/// Only prompt-type skills are merged (skills with no `skill_type` or
/// `skill_type == "prompt"`), aligning with TS `addSkillDirectories` L947 (C8).
///
/// Returns the count of newly merged skills.
pub async fn add_skill_directories(&mut self, dirs: &[PathBuf]) -> usize {
if dirs.is_empty() {
return 0;
}
// Load all directories in parallel-ish (sequential here for simplicity;
// the dirs slice is typically small — one per recently-touched file).
let mut loaded_batches: Vec<Vec<LoadedSkill>> = Vec::with_capacity(dirs.len());
for dir in dirs {
let batch = load_skills_from_dir(dir, SkillSource::Project, LoadedFrom::Skills).await;
loaded_batches.push(batch);
}
let previous_count = self.dynamic_skills.len();
// Process in reverse order (shallowest first) so deeper entries override.
// `dirs` is already deepest-first, so reversing gives shallowest-first.
for batch in loaded_batches.iter().rev() {
for loaded in batch {
if is_prompt_type(&loaded.metadata) {
self.dynamic_skills
.insert(loaded.metadata.name.clone(), loaded.metadata.clone());
}
}
}
let new_count = self.dynamic_skills.len();
// Net increase in unique skill names. Replacements of existing skills
// (same name, deeper directory) are not counted — this is a rough
// "newly visible" metric for logging, not a total-loaded count.
let added = new_count.saturating_sub(previous_count);
if added > 0 {
tracing::info!(target: "nomi_skills", added, directories = dirs.len(), "dynamically discovered new skills");
}
added
}
/// Get all dynamically discovered skills.
pub fn get_dynamic_skills(&self) -> Vec<&SkillMetadata> {
self.dynamic_skills.values().collect()
}
/// Clear dynamic skills (e.g., when reloading the skill set).
///
/// `checked_dirs` is preserved to avoid redundant stat calls for directories
/// already known not to contain a `.nomi/skills/` subdirectory.
pub fn clear_dynamic_skills(&mut self) {
self.dynamic_skills.clear();
}
/// Clear the set of directories that have already been checked for
/// `.nomi/skills/` subdirectories.
///
/// Call this when a file-system watcher detects changes so that newly
/// created directories (or directories that were previously absent) are
/// re-examined on the next [`discover_dirs_for_paths`](Self::discover_dirs_for_paths) call.
pub fn clear_checked_dirs(&mut self) {
self.checked_dirs.clear();
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Check whether `path` is gitignored using `git check-ignore -q`.
///
/// Exit code 0 means the path is ignored; any non-zero exit or command failure
/// means "not ignored" (fail-open design — safe outside git repositories).
///
/// `cwd` is used as the working directory for the `git` process so that
/// `.gitignore` files are resolved relative to the project root.
///
/// Aligns with TypeScript `isPathGitignored` referenced at L892.
async fn is_path_gitignored(path: &Path, cwd: &str) -> bool {
let mut cmd = tokio::process::Command::new("git");
cmd.arg("check-ignore").arg("-q").arg(path).current_dir(cwd);
// CREATE_NO_WINDOW: silence the git console window under a GUI host.
#[cfg(windows)]
cmd.creation_flags(0x0800_0000);
let result = cmd.output().await;
match result {
Ok(output) => output.status.success(),
Err(_) => false, // git unavailable or other I/O error — fail open
}
}
/// Returns `true` if the skill is a prompt-type skill (the default when no
/// `skill_type` is set) or explicitly typed as `"prompt"`.
///
/// Aligns with TypeScript `addSkillDirectories` L947: `skill.type === 'prompt'` (C8).
fn is_prompt_type(_skill: &SkillMetadata) -> bool {
// SkillMetadata does not expose skill_type as a parsed field yet.
// All skills loaded via load_skills_from_dir are treated as prompt type.
// Update when SkillMetadata gains a skill_type field.
true
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use super::{is_path_gitignored, is_prompt_type};
use crate::types::{ExecutionContext, LoadedFrom, SkillMetadata, SkillSource};
fn make_skill(name: &str) -> SkillMetadata {
SkillMetadata {
name: name.to_string(),
display_name: None,
description: String::new(),
has_user_specified_description: false,
allowed_tools: vec![],
argument_hint: None,
argument_names: vec![],
when_to_use: None,
version: None,
model: None,
disable_model_invocation: false,
user_invocable: true,
execution_context: ExecutionContext::Inline,
agent: None,
effort: None,
shell: None,
paths: vec![],
hooks_raw: None,
source: SkillSource::Project,
loaded_from: LoadedFrom::Skills,
content: String::new(),
content_length: 0,
skill_root: None,
}
}
// --- is_prompt_type ---
#[test]
fn is_prompt_type_always_returns_true() {
let skill = make_skill("any-skill");
assert!(is_prompt_type(&skill));
}
// --- is_path_gitignored ---
// Not gitignored in a non-git dir → fail open → returns false.
#[tokio::test]
async fn is_path_gitignored_returns_false_outside_git_repo() {
let tmp = TempDir::new().unwrap();
// No `git init` → not a git repo → git check-ignore fails → fail open
let cwd = tmp.path().to_str().unwrap();
let target = tmp.path().join("somefile.rs");
fs::write(&target, "").unwrap();
let result = is_path_gitignored(&target, cwd).await;
assert!(!result);
}
// Gitignored path in a real git repo → returns true.
#[tokio::test]
async fn is_path_gitignored_returns_true_for_ignored_path() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_str().unwrap();
let init_ok = std::process::Command::new("git")
.args(["init"])
.current_dir(tmp.path())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !init_ok {
// git not available — skip
return;
}
fs::write(tmp.path().join(".gitignore"), "ignored_dir/\n").unwrap();
let ignored = tmp.path().join("ignored_dir");
fs::create_dir_all(&ignored).unwrap();
let result = is_path_gitignored(&ignored, cwd).await;
assert!(result, "ignored_dir/ should be detected as gitignored");
}
// Non-ignored path in a real git repo → returns false.
#[tokio::test]
async fn is_path_gitignored_returns_false_for_tracked_path() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_str().unwrap();
let init_ok = std::process::Command::new("git")
.args(["init"])
.current_dir(tmp.path())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !init_ok {
return;
}
// Empty .gitignore — nothing is ignored
fs::write(tmp.path().join(".gitignore"), "").unwrap();
let tracked = tmp.path().join("normal_dir");
fs::create_dir_all(&tracked).unwrap();
let result = is_path_gitignored(&tracked, cwd).await;
assert!(!result);
}
// Path that doesn't exist → git check-ignore exits non-zero → fail open → false.
#[tokio::test]
async fn is_path_gitignored_returns_false_for_nonexistent_path() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_str().unwrap();
let nonexistent = Path::new("/nonexistent/path/xyz");
let result = is_path_gitignored(nonexistent, cwd).await;
assert!(!result);
}
}
// ---------------------------------------------------------------------------
// Supplemental tests (tester role — covers test-plan.md cases)
// ---------------------------------------------------------------------------
#[cfg(test)]
#[path = "discovery_supplemental_tests.rs"]
mod supplemental_tests;
@@ -0,0 +1,499 @@
// Supplemental tests for Phase 8 — RuntimeDiscovery.
// Covers test-plan.md TC-21 through TC-37.
//
// These tests use `tempfile::TempDir` to create real filesystem structures.
// Async tests use `#[tokio::test]`.
#[cfg(test)]
mod discovery_supplemental_tests {
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use crate::discovery::RuntimeDiscovery;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Create a `.nomi/skills/` directory inside `parent`.
fn create_skill_dir(parent: &Path) -> PathBuf {
let dir = parent.join(".nomi").join("skills");
fs::create_dir_all(&dir).unwrap();
dir
}
/// Write a minimal valid skill in directory format (`<name>/SKILL.md`).
///
/// `load_skills_from_dir` only supports the directory format (each
/// subdirectory containing a `SKILL.md` file), so flat `.md` files
/// are not loaded by that function.
fn write_skill_file(dir: &Path, name: &str) {
let skill_subdir = dir.join(name);
fs::create_dir_all(&skill_subdir).unwrap();
let content = format!(
"---\ndescription: test skill {}\n---\n\nSkill content for {}.",
name, name
);
fs::write(skill_subdir.join("SKILL.md"), content).unwrap();
}
// ---------------------------------------------------------------------------
// TC-21: new_creates_empty_discovery
// ---------------------------------------------------------------------------
// TC-21: new() yields an empty manager.
#[test]
fn tc21_new_creates_empty_discovery() {
let mgr = RuntimeDiscovery::new();
assert!(mgr.get_dynamic_skills().is_empty());
}
// ---------------------------------------------------------------------------
// TC-22: discover_dirs_finds_skill_dir_in_subdir
// ---------------------------------------------------------------------------
// TC-22: discovers `.nomi/skills/` inside a direct subdirectory of cwd.
#[tokio::test]
async fn tc22_discover_dirs_finds_nomi_skills_in_subdir() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_str().unwrap().to_string();
// Create /tmp/proj/module/.nomi/skills/
let module = tmp.path().join("module");
fs::create_dir_all(&module).unwrap();
create_skill_dir(&module);
let mut mgr = RuntimeDiscovery::new();
let file_path = module.join("foo.rs");
fs::write(&file_path, "").unwrap();
let found = mgr
.discover_dirs_for_paths(&[file_path.to_str().unwrap()], &cwd)
.await;
assert_eq!(found.len(), 1);
assert!(found[0].ends_with(".nomi/skills"));
}
// ---------------------------------------------------------------------------
// TC-23: cwd-level skill dir not re-discovered
// ---------------------------------------------------------------------------
// TC-23: `.nomi/skills/` at cwd level is not returned (loaded at startup).
#[tokio::test]
async fn tc23_discover_dirs_does_not_return_cwd_level() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_str().unwrap().to_string();
// Create skill dir directly in cwd
create_skill_dir(tmp.path());
let mut mgr = RuntimeDiscovery::new();
let file_path = tmp.path().join("foo.rs");
fs::write(&file_path, "").unwrap();
// file parent IS cwd — no subdirectory to walk
let found = mgr
.discover_dirs_for_paths(&[file_path.to_str().unwrap()], &cwd)
.await;
assert!(found.is_empty());
}
// ---------------------------------------------------------------------------
// TC-24: already-checked dirs are not re-stat'd
// ---------------------------------------------------------------------------
// TC-24: second call for the same file path returns empty (already checked).
#[tokio::test]
async fn tc24_discover_dirs_dedup_checked_dirs() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_str().unwrap().to_string();
let module = tmp.path().join("a");
fs::create_dir_all(&module).unwrap();
create_skill_dir(&module);
let file_path = module.join("file.rs");
fs::write(&file_path, "").unwrap();
let mut mgr = RuntimeDiscovery::new();
// First call discovers the dir
let first = mgr
.discover_dirs_for_paths(&[file_path.to_str().unwrap()], &cwd)
.await;
assert_eq!(first.len(), 1);
// Second call: already in checked_dirs → returns empty
let second = mgr
.discover_dirs_for_paths(&[file_path.to_str().unwrap()], &cwd)
.await;
assert!(second.is_empty());
}
// ---------------------------------------------------------------------------
// TC-25: miss dirs are also recorded in checked_dirs
// ---------------------------------------------------------------------------
// TC-25: directories without `.nomi/skills/` are still recorded to avoid
// repeated stat calls.
#[tokio::test]
async fn tc25_discover_dirs_records_miss_dirs_in_checked() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_str().unwrap().to_string();
// `b` does NOT have .nomi/skills/
let subdir = tmp.path().join("b");
fs::create_dir_all(&subdir).unwrap();
let file_path = subdir.join("bar.rs");
fs::write(&file_path, "").unwrap();
let mut mgr = RuntimeDiscovery::new();
let first = mgr
.discover_dirs_for_paths(&[file_path.to_str().unwrap()], &cwd)
.await;
assert!(first.is_empty());
// Second call: the miss is cached — still empty, no crash
let second = mgr
.discover_dirs_for_paths(&[file_path.to_str().unwrap()], &cwd)
.await;
assert!(second.is_empty());
}
// ---------------------------------------------------------------------------
// TC-26 & TC-27: gitignore integration
// ---------------------------------------------------------------------------
// TC-26: gitignored directory is skipped.
// Uses a real git repo with `.gitignore` to trigger `git check-ignore`.
#[tokio::test]
async fn tc26_discover_dirs_skips_gitignored_dir() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_str().unwrap().to_string();
// Init git repo
let status = std::process::Command::new("git")
.args(["init"])
.current_dir(tmp.path())
.status();
// If git is not available skip gracefully
if status.is_err() || !status.unwrap().success() {
return;
}
// Create subdirectory and write to .gitignore
let ignored = tmp.path().join("ignored");
fs::create_dir_all(&ignored).unwrap();
create_skill_dir(&ignored);
// Add `ignored/` to .gitignore
fs::write(tmp.path().join(".gitignore"), "ignored/\n").unwrap();
let file_path = ignored.join("file.rs");
fs::write(&file_path, "").unwrap();
let mut mgr = RuntimeDiscovery::new();
let found = mgr
.discover_dirs_for_paths(&[file_path.to_str().unwrap()], &cwd)
.await;
assert!(found.is_empty(), "gitignored dir should be skipped");
}
// TC-27: when git fails (non-git dir), path is not filtered (fail-open).
#[tokio::test]
async fn tc27_discover_dirs_not_filtered_when_git_unavailable() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_str().unwrap().to_string();
// NOT a git repo — git check-ignore will fail
let normal = tmp.path().join("normal");
fs::create_dir_all(&normal).unwrap();
create_skill_dir(&normal);
let file_path = normal.join("file.rs");
fs::write(&file_path, "").unwrap();
let mut mgr = RuntimeDiscovery::new();
let found = mgr
.discover_dirs_for_paths(&[file_path.to_str().unwrap()], &cwd)
.await;
// fail-open: non-git dir → dir is NOT filtered
assert_eq!(found.len(), 1);
}
// ---------------------------------------------------------------------------
// TC-28: deepest-first sort
// ---------------------------------------------------------------------------
// TC-28: returned directories are sorted deepest-first.
#[tokio::test]
async fn tc28_discover_dirs_sorted_deepest_first() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_str().unwrap().to_string();
let a = tmp.path().join("a");
let ab = a.join("b");
fs::create_dir_all(&ab).unwrap();
create_skill_dir(&a);
create_skill_dir(&ab);
let file_path = ab.join("file.rs");
fs::write(&file_path, "").unwrap();
let mut mgr = RuntimeDiscovery::new();
let found = mgr
.discover_dirs_for_paths(&[file_path.to_str().unwrap()], &cwd)
.await;
assert_eq!(found.len(), 2);
// Deepest path has more components
let depth = |p: &PathBuf| p.components().count();
assert!(depth(&found[0]) >= depth(&found[1]));
}
// ---------------------------------------------------------------------------
// TC-29: multiple file paths discover multiple directories
// ---------------------------------------------------------------------------
// TC-29: two separate file paths each with a skill dir are both discovered.
#[tokio::test]
async fn tc29_discover_dirs_multiple_file_paths() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_str().unwrap().to_string();
let x = tmp.path().join("x");
let y = tmp.path().join("y");
fs::create_dir_all(&x).unwrap();
fs::create_dir_all(&y).unwrap();
create_skill_dir(&x);
create_skill_dir(&y);
let fx = x.join("a.rs");
let fy = y.join("b.rs");
fs::write(&fx, "").unwrap();
fs::write(&fy, "").unwrap();
let mut mgr = RuntimeDiscovery::new();
let found = mgr
.discover_dirs_for_paths(&[fx.to_str().unwrap(), fy.to_str().unwrap()], &cwd)
.await;
assert_eq!(found.len(), 2);
}
// ---------------------------------------------------------------------------
// TC-30: empty file_paths returns empty
// ---------------------------------------------------------------------------
// TC-30: passing no file paths returns empty list without panic.
#[tokio::test]
async fn tc30_discover_dirs_empty_file_paths() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_str().unwrap().to_string();
let mut mgr = RuntimeDiscovery::new();
let found = mgr.discover_dirs_for_paths(&[], &cwd).await;
assert!(found.is_empty());
}
// ---------------------------------------------------------------------------
// TC-31: add_skill_directories loads skills
// ---------------------------------------------------------------------------
// TC-31: skills are loaded from a discovered directory.
#[tokio::test]
async fn tc31_add_skill_directories_loads_skills() {
let tmp = TempDir::new().unwrap();
let module = tmp.path().join("module");
fs::create_dir_all(&module).unwrap();
let skill_dir = create_skill_dir(&module);
write_skill_file(&skill_dir, "my-skill");
let mut mgr = RuntimeDiscovery::new();
let count = mgr.add_skill_directories(&[skill_dir]).await;
assert!(count > 0);
assert!(!mgr.get_dynamic_skills().is_empty());
}
// ---------------------------------------------------------------------------
// TC-32: deeper directory wins on same-name skill
// ---------------------------------------------------------------------------
// TC-32: when the same skill name appears in both shallow and deep dirs,
// the deeper directory's version takes precedence.
#[tokio::test]
async fn tc32_add_skill_directories_deeper_wins() {
let tmp = TempDir::new().unwrap();
let shallow = tmp.path().join("a");
let deep = tmp.path().join("a").join("b");
fs::create_dir_all(&shallow).unwrap();
fs::create_dir_all(&deep).unwrap();
let shallow_skills = create_skill_dir(&shallow);
let deep_skills = create_skill_dir(&deep);
// Both dirs have a skill named "shared" with different content.
// Use directory format (<name>/SKILL.md) — required by load_skills_from_dir.
let shallow_content = "---\ndescription: shallow version\n---\n\nShallow skill.";
let deep_content = "---\ndescription: deep version\n---\n\nDeep skill.";
let shallow_skill_dir = shallow_skills.join("shared");
let deep_skill_dir = deep_skills.join("shared");
fs::create_dir_all(&shallow_skill_dir).unwrap();
fs::create_dir_all(&deep_skill_dir).unwrap();
fs::write(shallow_skill_dir.join("SKILL.md"), shallow_content).unwrap();
fs::write(deep_skill_dir.join("SKILL.md"), deep_content).unwrap();
let mut mgr = RuntimeDiscovery::new();
// Pass deepest first (as discover_dirs_for_paths would return)
mgr.add_skill_directories(&[deep_skills, shallow_skills])
.await;
let skills = mgr.get_dynamic_skills();
assert_eq!(skills.len(), 1);
// Deeper version should have "deep version" description
assert_eq!(skills[0].description, "deep version");
}
// ---------------------------------------------------------------------------
// TC-33: add_skill_directories with empty dir
// ---------------------------------------------------------------------------
// TC-33: empty skill directory returns 0 and get_dynamic_skills stays empty.
#[tokio::test]
async fn tc33_add_skill_directories_empty_dir() {
let tmp = TempDir::new().unwrap();
let empty_skills = create_skill_dir(tmp.path());
let mut mgr = RuntimeDiscovery::new();
let count = mgr.add_skill_directories(&[empty_skills]).await;
assert_eq!(count, 0);
assert!(mgr.get_dynamic_skills().is_empty());
}
// ---------------------------------------------------------------------------
// TC-34: get_dynamic_skills returns all loaded skills
// ---------------------------------------------------------------------------
// TC-34: skills from two separate directories are all returned.
#[tokio::test]
async fn tc34_get_dynamic_skills_returns_all_loaded() {
let tmp = TempDir::new().unwrap();
let dir_a = tmp.path().join("dir_a");
let dir_b = tmp.path().join("dir_b");
fs::create_dir_all(&dir_a).unwrap();
fs::create_dir_all(&dir_b).unwrap();
let skills_a = create_skill_dir(&dir_a);
let skills_b = create_skill_dir(&dir_b);
write_skill_file(&skills_a, "skill-a");
write_skill_file(&skills_b, "skill-b");
let mut mgr = RuntimeDiscovery::new();
mgr.add_skill_directories(&[skills_a]).await;
mgr.add_skill_directories(&[skills_b]).await;
let all = mgr.get_dynamic_skills();
assert_eq!(all.len(), 2);
let names: Vec<&str> = all.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"skill-a"));
assert!(names.contains(&"skill-b"));
}
// ---------------------------------------------------------------------------
// TC-35: clear_dynamic_skills removes all skills
// ---------------------------------------------------------------------------
// TC-35: after clear_dynamic_skills, get_dynamic_skills returns empty.
#[tokio::test]
async fn tc35_clear_dynamic_skills_removes_skills() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("m");
fs::create_dir_all(&dir).unwrap();
let skill_dir = create_skill_dir(&dir);
write_skill_file(&skill_dir, "some-skill");
let mut mgr = RuntimeDiscovery::new();
mgr.add_skill_directories(&[skill_dir]).await;
assert!(!mgr.get_dynamic_skills().is_empty());
mgr.clear_dynamic_skills();
assert!(mgr.get_dynamic_skills().is_empty());
}
// ---------------------------------------------------------------------------
// TC-36: clear_dynamic_skills preserves checked_dirs
// ---------------------------------------------------------------------------
// TC-36: checked_dirs survive clear_dynamic_skills — second discover call
// for the same path returns empty (still cached as checked).
#[tokio::test]
async fn tc36_clear_dynamic_skills_preserves_checked_dirs() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_str().unwrap().to_string();
let module = tmp.path().join("mod");
fs::create_dir_all(&module).unwrap();
create_skill_dir(&module);
let file_path = module.join("file.rs");
fs::write(&file_path, "").unwrap();
let mut mgr = RuntimeDiscovery::new();
// First discover — populates checked_dirs
let first = mgr
.discover_dirs_for_paths(&[file_path.to_str().unwrap()], &cwd)
.await;
assert_eq!(first.len(), 1);
mgr.clear_dynamic_skills();
assert!(mgr.get_dynamic_skills().is_empty());
// Second discover — checked_dirs still has the entry → returns empty
let second = mgr
.discover_dirs_for_paths(&[file_path.to_str().unwrap()], &cwd)
.await;
assert!(second.is_empty());
}
// ---------------------------------------------------------------------------
// TC-37: file outside cwd is not traversed into cwd
// ---------------------------------------------------------------------------
// TC-37: a file path outside cwd does not cause traversal into cwd or beyond.
#[tokio::test]
async fn tc37_discover_dirs_file_outside_cwd_ignored() {
let tmp = TempDir::new().unwrap();
// Two separate dirs: cwd and an "other" root
let cwd_dir = tmp.path().join("proj");
let other_dir = tmp.path().join("other");
fs::create_dir_all(&cwd_dir).unwrap();
fs::create_dir_all(other_dir.join("module")).unwrap();
create_skill_dir(&other_dir.join("module"));
let cwd = cwd_dir.to_str().unwrap().to_string();
let file_path = other_dir.join("module").join("file.rs");
fs::write(&file_path, "").unwrap();
let mut mgr = RuntimeDiscovery::new();
let found = mgr
.discover_dirs_for_paths(&[file_path.to_str().unwrap()], &cwd)
.await;
// Outside cwd — no traversal should produce results within cwd
assert!(found.is_empty());
}
}
@@ -0,0 +1,798 @@
use crate::context_modifier::effort_to_string;
use crate::shell::{ShellExecutionError, execute_shell_commands};
use crate::substitution::substitute_arguments;
use crate::types::{ExecutionContext, SkillMetadata};
use nomi_types::spawner::{ForkOverrides, Spawner, SubAgentConfig};
/// Prepare skill content for inline execution.
///
/// Steps:
/// 1. If the skill has a known `skill_root`, prepend a base-directory header.
/// 2. Perform variable substitution (arguments + env vars).
/// 3. Execute any embedded shell commands (skipped for MCP skills).
///
/// The `session_id` is `None` in Phase 3; it will be wired in Phase 6.
pub async fn prepare_inline_content(
skill: &SkillMetadata,
args: Option<&str>,
session_id: Option<&str>,
cwd: &str,
) -> Result<String, ShellExecutionError> {
// Prepend base directory header so the model can resolve relative paths
// (e.g. `./schemas/foo.json`). Matches TS `processPromptSlashCommand`.
let base = match skill.skill_root.as_deref() {
Some(root) => {
let normalized = normalize_path_separators(root);
format!(
"Base directory for this skill: {normalized}\n\n{}",
skill.content
)
}
None => skill.content.clone(),
};
let substituted = substitute_arguments(
&base,
args,
&skill.argument_names,
skill.skill_root.as_deref(),
session_id,
);
execute_shell_commands(&substituted, skill.loaded_from, cwd).await
}
/// Normalize path separators to forward slashes.
/// On non-Windows platforms this is a no-op; included for portability.
fn normalize_path_separators(path: &str) -> String {
if cfg!(windows) {
path.replace('\\', "/")
} else {
path.to_owned()
}
}
/// Check whether a skill can be executed in inline mode.
///
/// Returns an error if the skill requires fork execution context.
/// Retained for test compatibility — SkillTool no longer calls this directly;
/// it uses an inline/fork match branch instead.
pub fn check_execution_context(skill: &SkillMetadata) -> Result<(), String> {
if skill.execution_context == ExecutionContext::Fork {
return Err(format!(
"Skill '{}' requires fork execution context, \
which requires fork support. This function only validates inline context.",
skill.name
));
}
Ok(())
}
/// Execute a fork skill by spawning an independent sub-agent.
///
/// Steps:
/// 1. Prepare skill content (variable substitution + shell execution).
/// 2. Build a SubAgentConfig from skill metadata overrides.
/// 3. Spawn the sub-agent and wait for its result.
/// 4. Return the sub-agent's output text, or an error string on failure.
pub async fn execute_fork(
skill: &SkillMetadata,
args: Option<&str>,
session_id: Option<&str>,
cwd: &str,
spawner: &dyn Spawner,
) -> Result<String, String> {
// Prepare content (substitution + shell) — same pipeline as inline mode
let prompt = prepare_inline_content(skill, args, session_id, cwd)
.await
.map_err(|e: ShellExecutionError| e.to_string())?;
let sub_config = SubAgentConfig {
name: skill.name.clone(),
prompt,
max_turns: 10,
max_tokens: 16384,
system_prompt: None,
// Fork skills restrict tools via ForkOverrides.allowed_tools below.
allowed_tools: Vec::new(),
};
let overrides = ForkOverrides {
model: skill.model.clone(),
effort: skill.effort.map(effort_to_string),
allowed_tools: skill.allowed_tools.clone(),
};
let result = spawner.spawn_fork(sub_config, overrides).await;
if result.is_error {
Err(result.text)
} else {
Ok(result.text)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ExecutionContext, LoadedFrom, SkillMetadata, SkillSource};
fn make_skill(content: &str, skill_root: Option<&str>) -> SkillMetadata {
SkillMetadata {
name: "test".to_string(),
display_name: None,
description: String::new(),
has_user_specified_description: false,
allowed_tools: Vec::new(),
argument_hint: None,
argument_names: Vec::new(),
when_to_use: None,
version: None,
model: None,
disable_model_invocation: false,
user_invocable: true,
execution_context: ExecutionContext::Inline,
agent: None,
effort: None,
shell: None,
paths: Vec::new(),
hooks_raw: None,
source: SkillSource::User,
loaded_from: LoadedFrom::Skills,
content: content.to_string(),
content_length: content.len(),
skill_root: skill_root.map(str::to_owned),
}
}
#[tokio::test]
async fn test_prepare_inline_no_args() {
let skill = make_skill("Do the thing.", None);
let result = prepare_inline_content(&skill, None, None, "/tmp")
.await
.unwrap();
assert_eq!(result, "Do the thing.");
}
#[tokio::test]
async fn test_prepare_inline_with_base_directory_header() {
let skill = make_skill("Content here.", Some("/my/skill/dir"));
let result = prepare_inline_content(&skill, None, None, "/tmp")
.await
.unwrap();
assert!(
result.starts_with("Base directory for this skill: /my/skill/dir\n\n"),
"expected base directory header, got: {result}"
);
assert!(result.contains("Content here."));
}
#[tokio::test]
async fn test_prepare_inline_substitutes_arguments() {
let skill = make_skill("Target: $ARGUMENTS", None);
let result = prepare_inline_content(&skill, Some("foo"), None, "/tmp")
.await
.unwrap();
assert_eq!(result, "Target: foo");
}
#[tokio::test]
async fn test_prepare_inline_substitutes_skill_dir() {
let skill = make_skill("Dir: ${NOMI_SKILL_DIR}", Some("/skills/mine"));
let result = prepare_inline_content(&skill, None, None, "/tmp")
.await
.unwrap();
// Header + substituted dir
assert!(result.contains("Dir: /skills/mine"));
}
#[tokio::test]
async fn test_prepare_inline_substitutes_session_id() {
let skill = make_skill("Session: ${NOMI_SESSION_ID}", None);
let result = prepare_inline_content(&skill, None, Some("sess-abc"), "/tmp")
.await
.unwrap();
assert!(result.contains("Session: sess-abc"));
}
#[test]
fn test_check_execution_context_inline_ok() {
let skill = make_skill("", None);
assert!(check_execution_context(&skill).is_ok());
}
#[test]
fn test_check_execution_context_fork_err() {
let mut skill = make_skill("", None);
skill.execution_context = ExecutionContext::Fork;
let err = check_execution_context(&skill).unwrap_err();
assert!(err.contains("fork execution context"));
}
}
// ---------------------------------------------------------------------------
// Supplemental tests (tester role — covers test-plan.md cases not in impl tests)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod supplemental_tests {
use super::*;
use crate::types::{ExecutionContext, LoadedFrom, SkillMetadata, SkillSource};
fn make_skill_full(
name: &str,
content: &str,
skill_root: Option<&str>,
argument_names: Vec<String>,
context: ExecutionContext,
) -> SkillMetadata {
SkillMetadata {
name: name.to_string(),
display_name: None,
description: String::new(),
has_user_specified_description: false,
allowed_tools: Vec::new(),
argument_hint: None,
argument_names,
when_to_use: None,
version: None,
model: None,
disable_model_invocation: false,
user_invocable: true,
execution_context: context,
agent: None,
effort: None,
shell: None,
paths: Vec::new(),
hooks_raw: None,
source: SkillSource::User,
loaded_from: LoadedFrom::Skills,
content: content.to_string(),
content_length: content.len(),
skill_root: skill_root.map(str::to_owned),
}
}
// TC-10.1: basic prepare_inline_content call
#[tokio::test]
async fn tc_10_1_prepare_inline_substitutes_arguments() {
let skill = make_skill_full(
"s",
"Search $ARGUMENTS",
None,
vec![],
ExecutionContext::Inline,
);
let result = prepare_inline_content(&skill, Some("rust"), None, "/tmp")
.await
.unwrap();
assert_eq!(result, "Search rust");
}
// TC-10.2: no args, no placeholder → content unchanged
#[tokio::test]
async fn tc_10_2_no_args_no_placeholder_unchanged() {
let skill = make_skill_full("s", "Just content.", None, vec![], ExecutionContext::Inline);
let result = prepare_inline_content(&skill, None, None, "/tmp")
.await
.unwrap();
assert_eq!(result, "Just content.");
}
// TC-10.3: skill_root causes base directory header to be prepended
#[tokio::test]
async fn tc_10_3_skill_root_prepends_header() {
let skill = make_skill_full(
"s",
"${NOMI_SKILL_DIR}/script.sh",
Some("/path/to/skill"),
vec![],
ExecutionContext::Inline,
);
let result = prepare_inline_content(&skill, None, None, "/tmp")
.await
.unwrap();
assert!(
result.starts_with("Base directory for this skill: /path/to/skill"),
"expected header, got: {result}"
);
assert!(result.contains("/path/to/skill/script.sh"));
}
// TC-10.x: session_id substitution wired through
#[tokio::test]
async fn tc_10_x_session_id_substituted() {
let skill = make_skill_full(
"s",
"${NOMI_SESSION_ID}",
None,
vec![],
ExecutionContext::Inline,
);
let result = prepare_inline_content(&skill, None, Some("sess-xyz"), "/tmp")
.await
.unwrap();
assert_eq!(result, "sess-xyz");
}
// TC-10.x: argument_names from metadata are used
#[tokio::test]
async fn tc_10_x_argument_names_from_metadata() {
let names = vec!["query".to_string()];
let skill = make_skill_full(
"s",
"Find $query in codebase",
None,
names,
ExecutionContext::Inline,
);
let result = prepare_inline_content(&skill, Some("main function"), None, "/tmp")
.await
.unwrap();
assert_eq!(result, "Find main in codebase");
}
// TC-10.x: fork context check
#[test]
fn tc_10_x_check_context_fork_returns_err() {
let skill = make_skill_full("fork-skill", "body", None, vec![], ExecutionContext::Fork);
let result = check_execution_context(&skill);
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("fork-skill"));
assert!(msg.contains("fork execution context"));
}
// TC-10.x: inline context check returns Ok
#[test]
fn tc_10_x_check_context_inline_returns_ok() {
let skill = make_skill_full(
"inline-skill",
"body",
None,
vec![],
ExecutionContext::Inline,
);
assert!(check_execution_context(&skill).is_ok());
}
// -----------------------------------------------------------------------
// Phase 4 additions: shell integration in prepare_inline_content
// -----------------------------------------------------------------------
// TC-10.4: Block shell 命令被执行替换
#[tokio::test]
#[cfg(not(windows))] // Uses Unix shell syntax in ```! blocks
async fn tc_10_4_block_shell_executed_in_prepare() {
let skill = make_skill_full(
"s",
"Result:\n```!\necho shell_output\n```\nDone.",
None,
vec![],
ExecutionContext::Inline,
);
let result = prepare_inline_content(&skill, None, None, "/tmp")
.await
.unwrap();
assert!(
result.contains("shell_output"),
"block shell output missing: {result}"
);
assert!(
!result.contains("```!"),
"block syntax should be replaced: {result}"
);
}
// TC-10.5: Inline shell 命令被执行替换
#[tokio::test]
#[cfg(not(windows))] // Uses Unix shell syntax (!` inline)
async fn tc_10_5_inline_shell_executed_in_prepare() {
let skill = make_skill_full(
"s",
"Dir: !`echo /inline_dir`",
None,
vec![],
ExecutionContext::Inline,
);
let result = prepare_inline_content(&skill, None, None, "/tmp")
.await
.unwrap();
assert!(
result.contains("/inline_dir"),
"inline shell output missing: {result}"
);
assert!(
!result.contains("!`"),
"inline syntax should be replaced: {result}"
);
}
// TC-10.6: MCP skill 跳过 shell — content 中的 shell 语法原样保留
#[tokio::test]
async fn tc_10_6_mcp_skill_shell_skipped() {
let mut skill = make_skill_full(
"s",
"run !`pwd` here",
None,
vec![],
ExecutionContext::Inline,
);
skill.loaded_from = LoadedFrom::Mcp;
let result = prepare_inline_content(&skill, None, None, "/tmp")
.await
.unwrap();
// MCP skill: shell command NOT executed, syntax remains
assert_eq!(
result, "run !`pwd` here",
"MCP skill should preserve shell syntax: {result}"
);
}
// TC-10.7: 变量替换 + shell 顺序 — 先变量替换再 shell 执行
#[tokio::test]
#[cfg(not(windows))] // Uses Unix shell syntax and /tmp path
async fn tc_10_7_variable_substitution_before_shell() {
// $ARGUMENTS is substituted first, then the resulting content is shell-executed
// We verify by having a non-shell placeholder that gets substituted
let skill = make_skill_full(
"s",
"Text: $ARGUMENTS !`echo done`",
None,
vec![],
ExecutionContext::Inline,
);
let result = prepare_inline_content(&skill, Some("hello"), None, "/tmp")
.await
.unwrap();
assert!(
result.contains("hello"),
"variable substitution should have happened: {result}"
);
assert!(
result.contains("done"),
"shell should have executed: {result}"
);
}
// TC-10.8: cwd 参数传递给 execute_shell_commands
#[tokio::test]
#[cfg(not(windows))] // Uses pwd command (Unix only)
async fn tc_10_8_cwd_passed_to_shell() {
let skill = make_skill_full("s", "!`pwd`", None, vec![], ExecutionContext::Inline);
let result = prepare_inline_content(&skill, None, None, "/tmp")
.await
.unwrap();
// /tmp or /private/tmp on macOS
assert!(
result.contains("tmp"),
"cwd should be reflected in pwd output: {result}"
);
}
}
// ---------------------------------------------------------------------------
// Phase 7 tests — execute_fork() with MockSpawner
// ---------------------------------------------------------------------------
#[cfg(test)]
mod phase7_tests {
use std::sync::Mutex;
use async_trait::async_trait;
use super::execute_fork;
use crate::types::{EffortLevel, ExecutionContext, LoadedFrom, SkillMetadata, SkillSource};
use nomi_types::message::TokenUsage;
use nomi_types::spawner::{ForkOverrides, Spawner, SubAgentConfig, SubAgentResult};
// ---------------------------------------------------------------------------
// MockSpawner — captures args passed to spawn_fork, returns preset result
// ---------------------------------------------------------------------------
struct MockSpawner {
/// Preset is_error value for the returned SubAgentResult.
is_error: bool,
/// Preset text value for the returned SubAgentResult.
text: String,
/// Captures the SubAgentConfig passed to spawn_fork.
captured_config: Mutex<Option<SubAgentConfig>>,
/// Captures the ForkOverrides passed to spawn_fork.
captured_overrides: Mutex<Option<ForkOverrides>>,
}
impl MockSpawner {
fn success(text: &str) -> Self {
Self {
is_error: false,
text: text.to_string(),
captured_config: Mutex::new(None),
captured_overrides: Mutex::new(None),
}
}
fn error(text: &str) -> Self {
Self {
is_error: true,
text: text.to_string(),
captured_config: Mutex::new(None),
captured_overrides: Mutex::new(None),
}
}
fn take_config(&self) -> SubAgentConfig {
self.captured_config
.lock()
.unwrap()
.take()
.expect("spawn_fork was not called")
}
fn take_overrides(&self) -> ForkOverrides {
self.captured_overrides
.lock()
.unwrap()
.take()
.expect("spawn_fork was not called")
}
}
#[async_trait]
impl Spawner for MockSpawner {
async fn spawn_fork(
&self,
config: SubAgentConfig,
overrides: ForkOverrides,
) -> SubAgentResult {
*self.captured_config.lock().unwrap() = Some(config.clone());
*self.captured_overrides.lock().unwrap() = Some(overrides.clone());
SubAgentResult {
name: config.name.clone(),
text: self.text.clone(),
usage: TokenUsage::default(),
turns: 1,
is_error: self.is_error,
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_fork_skill(name: &str, content: &str) -> SkillMetadata {
SkillMetadata {
name: name.to_string(),
display_name: None,
description: String::new(),
has_user_specified_description: false,
allowed_tools: Vec::new(),
argument_hint: None,
argument_names: Vec::new(),
when_to_use: None,
version: None,
model: None,
disable_model_invocation: false,
user_invocable: true,
execution_context: ExecutionContext::Fork,
agent: None,
effort: None,
shell: None,
paths: Vec::new(),
hooks_raw: None,
source: SkillSource::User,
loaded_from: LoadedFrom::Skills,
content: content.to_string(),
content_length: content.len(),
skill_root: None,
}
}
// ---------------------------------------------------------------------------
// TC-7.10: execute_fork success — returns Ok with sub-agent text
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tc_7_10_fork_success_returns_ok() {
let skill = make_fork_skill("my-fork", "Do the task.");
let spawner = MockSpawner::success("agent completed task");
let result = execute_fork(&skill, None, None, "/tmp", &spawner).await;
assert!(result.is_ok(), "expected Ok, got: {result:?}");
assert_eq!(result.unwrap(), "agent completed task");
}
// TC-7.11: execute_fork sub-agent error — returns Err with error text
#[tokio::test]
async fn tc_7_11_fork_sub_agent_error_returns_err() {
let skill = make_fork_skill("failing-fork", "Do something.");
let spawner = MockSpawner::error("sub-agent crashed");
let result = execute_fork(&skill, None, None, "/tmp", &spawner).await;
assert!(result.is_err(), "expected Err, got: {result:?}");
assert_eq!(result.unwrap_err(), "sub-agent crashed");
}
// TC-7.13: model from SkillMetadata propagates to ForkOverrides
#[tokio::test]
async fn tc_7_13_model_propagated_to_fork_overrides() {
let mut skill = make_fork_skill("model-fork", "content");
skill.model = Some("claude-sonnet-4-6".to_string());
let spawner = MockSpawner::success("ok");
execute_fork(&skill, None, None, "/tmp", &spawner)
.await
.unwrap();
let overrides = spawner.take_overrides();
assert_eq!(overrides.model.as_deref(), Some("claude-sonnet-4-6"));
}
// TC-7.14: effort from SkillMetadata propagates to ForkOverrides as string
#[tokio::test]
async fn tc_7_14_effort_propagated_to_fork_overrides() {
let mut skill = make_fork_skill("effort-fork", "content");
skill.effort = Some(EffortLevel::High);
let spawner = MockSpawner::success("ok");
execute_fork(&skill, None, None, "/tmp", &spawner)
.await
.unwrap();
let overrides = spawner.take_overrides();
assert_eq!(overrides.effort.as_deref(), Some("high"));
}
// TC-7.15: allowed_tools from SkillMetadata propagates to ForkOverrides
#[tokio::test]
async fn tc_7_15_allowed_tools_propagated_to_fork_overrides() {
let mut skill = make_fork_skill("tools-fork", "content");
skill.allowed_tools = vec!["Bash".to_string(), "Read".to_string()];
let spawner = MockSpawner::success("ok");
execute_fork(&skill, None, None, "/tmp", &spawner)
.await
.unwrap();
let overrides = spawner.take_overrides();
assert_eq!(overrides.allowed_tools, vec!["Bash", "Read"]);
}
// TC-7.16: prompt passed to SubAgentConfig equals prepare_inline_content output
#[tokio::test]
async fn tc_7_16_prompt_is_prepared_content() {
let mut skill = make_fork_skill("prompt-fork", "Search $ARGUMENTS");
skill.argument_names = vec![]; // use $ARGUMENTS placeholder
let spawner = MockSpawner::success("ok");
execute_fork(&skill, Some("rust"), None, "/tmp", &spawner)
.await
.unwrap();
let config = spawner.take_config();
// Variable substitution should have replaced $ARGUMENTS with "rust"
assert_eq!(
config.prompt, "Search rust",
"prompt should contain substituted content"
);
}
// TC-7.17: SubAgentConfig.name equals skill.name
#[tokio::test]
async fn tc_7_17_sub_agent_config_name_equals_skill_name() {
let skill = make_fork_skill("my-skill-name", "content");
let spawner = MockSpawner::success("ok");
execute_fork(&skill, None, None, "/tmp", &spawner)
.await
.unwrap();
let config = spawner.take_config();
assert_eq!(config.name, "my-skill-name");
}
// TC-7.40: empty skill content produces empty prompt (no parse error)
#[tokio::test]
async fn tc_7_40_empty_content_no_error() {
let skill = make_fork_skill("empty-fork", "");
let spawner = MockSpawner::success("ok");
let result = execute_fork(&skill, None, None, "/tmp", &spawner).await;
assert!(
result.is_ok(),
"empty content should not cause error: {result:?}"
);
let config = spawner.take_config();
assert_eq!(config.prompt, "");
}
// TC-7.41: MCP fork skill behaves the same as regular fork skill
#[tokio::test]
async fn tc_7_41_mcp_fork_skill_allowed() {
let mut skill = make_fork_skill("mcp-fork", "content");
skill.source = SkillSource::Mcp;
skill.loaded_from = LoadedFrom::Mcp;
let spawner = MockSpawner::success("mcp result");
let result = execute_fork(&skill, None, None, "/tmp", &spawner).await;
assert!(
result.is_ok(),
"MCP fork skill should be allowed: {result:?}"
);
}
// TC-7.42: no model/effort → ForkOverrides fields are None/empty
#[tokio::test]
async fn tc_7_42_no_model_no_effort_fork_overrides_empty() {
let skill = make_fork_skill("plain-fork", "content");
let spawner = MockSpawner::success("ok");
execute_fork(&skill, None, None, "/tmp", &spawner)
.await
.unwrap();
let overrides = spawner.take_overrides();
assert!(overrides.model.is_none(), "model should be None");
assert!(overrides.effort.is_none(), "effort should be None");
assert!(
overrides.allowed_tools.is_empty(),
"allowed_tools should be empty"
);
}
// TC-7.43 (allowed_tools empty): empty allowed_tools passes through
#[tokio::test]
async fn tc_7_43_empty_allowed_tools_passthrough() {
let skill = make_fork_skill("no-tools-fork", "content");
let spawner = MockSpawner::success("ok");
execute_fork(&skill, None, None, "/tmp", &spawner)
.await
.unwrap();
let overrides = spawner.take_overrides();
assert!(overrides.allowed_tools.is_empty());
}
// TC-7.44: sub-agent result text propagated to Ok return value
#[tokio::test]
async fn tc_7_44_result_text_propagated() {
let skill = make_fork_skill("text-fork", "content");
let spawner = MockSpawner::success("the final answer");
let result = execute_fork(&skill, None, None, "/tmp", &spawner).await;
assert_eq!(result.unwrap(), "the final answer");
}
// TC-7.45: SubAgentConfig.max_turns defaults to 10
#[tokio::test]
async fn tc_7_45_max_turns_default_is_10() {
let skill = make_fork_skill("turns-fork", "content");
let spawner = MockSpawner::success("ok");
execute_fork(&skill, None, None, "/tmp", &spawner)
.await
.unwrap();
let config = spawner.take_config();
assert_eq!(config.max_turns, 10);
}
// TC-7.46: SubAgentConfig.max_tokens defaults to 16384
#[tokio::test]
async fn tc_7_46_max_tokens_default_is_16384() {
let skill = make_fork_skill("tokens-fork", "content");
let spawner = MockSpawner::success("ok");
execute_fork(&skill, None, None, "/tmp", &spawner)
.await
.unwrap();
let config = spawner.take_config();
assert_eq!(config.max_tokens, 16384);
}
// TC-7.47: SubAgentConfig.system_prompt defaults to None
#[tokio::test]
async fn tc_7_47_system_prompt_default_is_none() {
let skill = make_fork_skill("sysprompt-fork", "content");
let spawner = MockSpawner::success("ok");
execute_fork(&skill, None, None, "/tmp", &spawner)
.await
.unwrap();
let config = spawner.take_config();
assert!(
config.system_prompt.is_none(),
"system_prompt should default to None"
);
}
// All effort levels convert to their string representations
#[test]
fn tc_7_effort_all_variants_to_string() {
use crate::context_modifier::effort_to_string;
assert_eq!(effort_to_string(EffortLevel::Low), "low");
assert_eq!(effort_to_string(EffortLevel::Medium), "medium");
assert_eq!(effort_to_string(EffortLevel::High), "high");
assert_eq!(effort_to_string(EffortLevel::Max), "max");
}
}
@@ -0,0 +1,715 @@
use super::types::{
BoolOrString, EffortLevel, ExecutionContext, FrontmatterData, LoadedFrom, ParsedMarkdown,
SkillMetadata, SkillSource, StringOrNumber, StringOrVec,
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Parse frontmatter and body from a Markdown skill file.
///
/// Uses string search (not regex) to locate the `---` delimiters. Falls back
/// to an empty FrontmatterData when the YAML cannot be parsed after two
/// attempts (log a warning; never panic).
pub fn parse_frontmatter(input: &str) -> ParsedMarkdown {
match extract_frontmatter_bounds(input) {
Some((yaml_text, content)) => {
let frontmatter = parse_yaml_with_fallback(yaml_text);
ParsedMarkdown {
frontmatter,
content: content.to_owned(),
}
}
None => ParsedMarkdown {
frontmatter: FrontmatterData::default(),
content: input.to_owned(),
},
}
}
/// Normalize a FrontmatterData into a SkillMetadata.
pub fn parse_skill_fields(
frontmatter: &FrontmatterData,
content: &str,
resolved_name: &str,
source: SkillSource,
loaded_from: LoadedFrom,
skill_root: Option<&str>,
) -> SkillMetadata {
let description_from_frontmatter = coerce_description(&frontmatter.description);
let has_user_specified_description = description_from_frontmatter.is_some();
let description = description_from_frontmatter
.or_else(|| extract_description_from_content(content))
.unwrap_or_default();
let user_invocable = parse_bool(&frontmatter.user_invocable, true);
let disable_model_invocation = parse_bool(&frontmatter.hide_from_model_invocation, false);
let execution_context = match frontmatter.context.as_deref() {
Some("fork") => ExecutionContext::Fork,
_ => ExecutionContext::Inline,
};
// "inherit" means "don't override the caller's model choice"
let model = frontmatter
.model
.as_deref()
.filter(|m| *m != "inherit")
.map(str::to_owned);
let allowed_tools = parse_string_or_vec(&frontmatter.allowed_tools);
let argument_names = parse_string_or_vec(&frontmatter.arguments);
let paths = split_paths(&frontmatter.paths);
let effort = parse_effort(&frontmatter.effort);
let hooks_raw = frontmatter.hooks.as_ref().and_then(yaml_value_to_json);
let content_length = content.len();
SkillMetadata {
name: resolved_name.to_owned(),
display_name: frontmatter.name.clone(),
description,
has_user_specified_description,
allowed_tools,
argument_hint: frontmatter.argument_hint.clone(),
argument_names,
when_to_use: frontmatter.when_to_use.clone(),
version: frontmatter.version.clone(),
model,
disable_model_invocation,
user_invocable,
execution_context,
agent: frontmatter.agent.clone(),
effort,
shell: frontmatter.shell.clone(),
paths,
hooks_raw,
source,
loaded_from,
content: content.to_owned(),
content_length,
skill_root: skill_root.map(str::to_owned),
}
}
// ---------------------------------------------------------------------------
// Frontmatter extraction
// ---------------------------------------------------------------------------
/// Extract (yaml_text, body_content) from a Markdown string using string search.
///
/// Expects the file to start with `---\n` (opening fence). Finds the next
/// line that is exactly `---` as the closing fence. Handles empty frontmatter,
/// CRLF line endings, and closing fence at end-of-file.
fn extract_frontmatter_bounds(input: &str) -> Option<(&str, &str)> {
// Normalise CRLF → LF for consistent processing
// We work on the original bytes but accept both endings at fence lines.
// Opening fence must be the very first line
let after_open = input
.strip_prefix("---\n")
.or_else(|| input.strip_prefix("---\r\n"))?;
// Scan line by line for the closing fence
let mut pos = 0;
for line in after_open.lines() {
let line_with_ending_len = {
// Compute byte length including the line ending
let raw = &after_open[pos..];
let trimmed = line.len();
if raw[trimmed..].starts_with("\r\n") {
trimmed + 2
} else if raw[trimmed..].starts_with('\n') {
trimmed + 1
} else {
trimmed // last line with no newline
}
};
if line == "---" {
let yaml_text = &after_open[..pos];
// Strip leading newline from yaml_text if present (empty frontmatter)
let yaml_text = yaml_text.strip_suffix('\n').unwrap_or(yaml_text);
let body_start = pos + line_with_ending_len;
let body = if body_start <= after_open.len() {
&after_open[body_start..]
} else {
""
};
return Some((yaml_text, body));
}
pos += line_with_ending_len;
}
None
}
// ---------------------------------------------------------------------------
// Two-pass YAML parsing
// ---------------------------------------------------------------------------
fn parse_yaml_with_fallback(yaml_text: &str) -> FrontmatterData {
// First pass: parse as-is
match serde_yaml::from_str::<FrontmatterData>(yaml_text) {
Ok(data) => return data,
Err(e) => {
tracing::warn!(target: "nomi_skills", error = %e, "frontmatter first-pass parse failed");
}
}
// Second pass: auto-quote top-level scalar values containing YAML special chars
let fixed = quote_problematic_values(yaml_text);
match serde_yaml::from_str::<FrontmatterData>(&fixed) {
Ok(data) => data,
Err(e) => {
tracing::warn!(target: "nomi_skills", error = %e, "frontmatter second-pass parse failed, returning empty");
FrontmatterData::default()
}
}
}
// ---------------------------------------------------------------------------
// quote_problematic_values
// ---------------------------------------------------------------------------
/// Re-quote top-level scalar values that contain YAML special characters.
///
/// Only touches lines of the form `key: value` where:
/// - the line is not already quoted (`"` or `'` as first value char)
/// - the value contains at least one YAML special character
/// - the line has no leading whitespace (top-level only — nested structures
/// like hooks blocks are left untouched to preserve their syntax)
fn quote_problematic_values(yaml_text: &str) -> String {
const SPECIAL_CHARS: &[char] = &[
'{', '}', '[', ']', '*', '&', '#', '!', '|', '>', '%', '@', '`',
];
let mut result = String::with_capacity(yaml_text.len() + 64);
for line in yaml_text.lines() {
// Only process top-level key: value lines (no leading whitespace)
if line.starts_with(' ') || line.starts_with('\t') {
result.push_str(line);
result.push('\n');
continue;
}
// Find the colon separator for key: value
if let Some(colon_pos) = line.find(": ") {
let key = &line[..colon_pos + 1]; // includes ":"
let value = &line[colon_pos + 2..];
// Skip if already quoted or value is empty
if value.is_empty() || value.starts_with('"') || value.starts_with('\'') {
result.push_str(line);
result.push('\n');
continue;
}
if value.contains(SPECIAL_CHARS) {
// Escape any existing double quotes inside the value
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
result.push_str(key);
result.push_str(" \"");
result.push_str(&escaped);
result.push('"');
result.push('\n');
continue;
}
}
result.push_str(line);
result.push('\n');
}
// Remove trailing newline added by the loop to keep output consistent
if result.ends_with('\n') && !yaml_text.ends_with('\n') {
result.pop();
}
result
}
// ---------------------------------------------------------------------------
// Helper: serde_yaml::Value → serde_json::Value
// ---------------------------------------------------------------------------
fn yaml_value_to_json(v: &serde_yaml::Value) -> Option<serde_json::Value> {
// Round-trip through JSON string to convert between the two Value types
let json_str = serde_json::to_string(v).ok()?;
serde_json::from_str(&json_str).ok()
}
// ---------------------------------------------------------------------------
// Field parsing helpers
// ---------------------------------------------------------------------------
/// Parse StringOrVec to Vec<String>, splitting comma-separated single strings.
fn parse_string_or_vec(value: &Option<StringOrVec>) -> Vec<String> {
match value {
None => vec![],
Some(StringOrVec::Multiple(v)) => v.clone(),
Some(StringOrVec::Single(s)) => s
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect(),
}
}
/// Parse the `paths` field: comma-split (respecting braces) then brace-expand each element.
fn split_paths(value: &Option<StringOrVec>) -> Vec<String> {
match value {
None => vec![],
Some(StringOrVec::Multiple(v)) => v.iter().flat_map(|p| expand_braces(p)).collect(),
Some(StringOrVec::Single(s)) => {
// Split on commas that are NOT inside {} braces, then brace-expand each part
split_respecting_braces(s)
.into_iter()
.flat_map(|p| expand_braces(&p))
.collect()
}
}
}
/// Split a string on top-level commas (commas not inside `{...}` groups).
fn split_respecting_braces(s: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut depth: usize = 0;
for ch in s.chars() {
match ch {
'{' => {
depth += 1;
current.push(ch);
}
'}' => {
depth = depth.saturating_sub(1);
current.push(ch);
}
',' if depth == 0 => {
let trimmed = current.trim().to_owned();
if !trimmed.is_empty() {
parts.push(trimmed);
}
current.clear();
}
_ => current.push(ch),
}
}
let trimmed = current.trim().to_owned();
if !trimmed.is_empty() {
parts.push(trimmed);
}
parts
}
/// Expand a single brace pattern into all combinations.
///
/// Examples:
/// - `"*.{ts,tsx}"` → `["*.ts", "*.tsx"]`
/// - `"{a,b}/{c,d}"` → `["a/c", "a/d", "b/c", "b/d"]`
/// - No braces → returns the original pattern unchanged.
fn expand_braces(pattern: &str) -> Vec<String> {
// Find the first `{` that has a matching `}`
if let Some(open) = pattern.find('{')
&& let Some(close_rel) = pattern[open..].find('}')
{
let close = open + close_rel;
let prefix = &pattern[..open];
let suffix = &pattern[close + 1..];
let alternatives = &pattern[open + 1..close];
let mut results = Vec::new();
for alt in alternatives.split(',') {
let expanded = format!("{}{}{}", prefix, alt, suffix);
// Recursively expand in case there are more brace groups
results.extend(expand_braces(&expanded));
}
return results;
}
vec![pattern.to_owned()]
}
/// Parse BoolOrString to bool.
fn parse_bool(value: &Option<BoolOrString>, default: bool) -> bool {
match value {
None => default,
Some(BoolOrString::Bool(b)) => *b,
Some(BoolOrString::Str(s)) => s.eq_ignore_ascii_case("true"),
}
}
/// Parse the effort field to an EffortLevel.
fn parse_effort(value: &Option<StringOrNumber>) -> Option<EffortLevel> {
match value {
None => None,
Some(StringOrNumber::Num(n)) => match n {
0 => Some(EffortLevel::Low),
1 => Some(EffortLevel::Medium),
2 => Some(EffortLevel::High),
_ => Some(EffortLevel::Max),
},
Some(StringOrNumber::Str(s)) => match s.to_lowercase().as_str() {
"low" => Some(EffortLevel::Low),
"medium" | "normal" => Some(EffortLevel::Medium),
"high" => Some(EffortLevel::High),
"max" | "maximum" => Some(EffortLevel::Max),
_ => None,
},
}
}
/// Extract the first non-empty, non-heading line from body content as a
/// fallback description.
fn extract_description_from_content(content: &str) -> Option<String> {
content
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_owned)
}
/// Normalise description: strip surrounding whitespace, return None if empty.
fn coerce_description(value: &Option<String>) -> Option<String> {
value
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{LoadedFrom, SkillSource};
// --- extract_frontmatter_bounds ---
#[test]
fn test_extract_basic_frontmatter() {
let input = "---\nname: foo\n---\nbody text";
let (yaml, body) = extract_frontmatter_bounds(input).unwrap();
assert_eq!(yaml, "name: foo");
assert_eq!(body, "body text");
}
#[test]
fn test_extract_empty_frontmatter() {
let input = "---\n---\nbody";
let (yaml, body) = extract_frontmatter_bounds(input).unwrap();
assert_eq!(yaml, "");
assert_eq!(body, "body");
}
#[test]
fn test_extract_no_frontmatter() {
let input = "# Just a heading\n\nSome content";
assert!(extract_frontmatter_bounds(input).is_none());
}
#[test]
fn test_extract_empty_body() {
let input = "---\nname: bar\n---";
let (yaml, body) = extract_frontmatter_bounds(input).unwrap();
assert_eq!(yaml, "name: bar");
assert_eq!(body, "");
}
#[test]
fn test_extract_empty_input() {
assert!(extract_frontmatter_bounds("").is_none());
}
// --- parse_frontmatter ---
#[test]
fn test_parse_frontmatter_full() {
let input = r#"---
name: my-skill
description: Does something useful
allowed-tools: Read, Write
user-invocable: true
---
# Skill body
Do the thing.
"#;
let parsed = parse_frontmatter(input);
assert_eq!(parsed.frontmatter.name.as_deref(), Some("my-skill"));
assert_eq!(
parsed.frontmatter.description.as_deref(),
Some("Does something useful")
);
assert!(parsed.content.contains("Skill body"));
}
#[test]
fn test_parse_frontmatter_empty() {
let input = "---\n---\nbody";
let parsed = parse_frontmatter(input);
assert!(parsed.frontmatter.name.is_none());
assert_eq!(parsed.content, "body");
}
#[test]
fn test_parse_frontmatter_none() {
let input = "# No frontmatter here\n\nJust content.";
let parsed = parse_frontmatter(input);
assert!(parsed.frontmatter.name.is_none());
assert_eq!(parsed.content, input);
}
#[test]
fn test_parse_frontmatter_malformed_yaml() {
// Malformed YAML that can't be fixed — should return empty FrontmatterData
let input = "---\n: {broken yaml\n---\ncontent";
let parsed = parse_frontmatter(input);
// Should not panic; content preserved
assert_eq!(parsed.content, "content");
}
#[test]
fn test_parse_frontmatter_special_chars_in_value() {
// Description contains { } which would fail unquoted YAML
let input = "---\ndescription: Use {arg} to specify the value\n---\nbody";
let parsed = parse_frontmatter(input);
// Second-pass auto-quoting should rescue this
assert_eq!(
parsed.frontmatter.description.as_deref(),
Some("Use {arg} to specify the value")
);
}
// --- expand_braces ---
#[test]
fn test_expand_braces_single_group() {
let mut result = expand_braces("*.{ts,tsx}");
result.sort();
assert_eq!(result, vec!["*.ts", "*.tsx"]);
}
#[test]
fn test_expand_braces_two_groups() {
let mut result = expand_braces("{a,b}/{c,d}");
result.sort();
assert_eq!(result, vec!["a/c", "a/d", "b/c", "b/d"]);
}
#[test]
fn test_expand_braces_no_braces() {
let result = expand_braces("src/**/*.rs");
assert_eq!(result, vec!["src/**/*.rs"]);
}
#[test]
fn test_expand_braces_single_option() {
let result = expand_braces("{only}");
assert_eq!(result, vec!["only"]);
}
// --- parse_bool ---
#[test]
fn test_parse_bool_true_bool() {
assert!(parse_bool(&Some(BoolOrString::Bool(true)), false));
}
#[test]
fn test_parse_bool_false_bool() {
assert!(!parse_bool(&Some(BoolOrString::Bool(false)), true));
}
#[test]
fn test_parse_bool_string_true() {
assert!(parse_bool(&Some(BoolOrString::Str("true".into())), false));
assert!(parse_bool(&Some(BoolOrString::Str("TRUE".into())), false));
}
#[test]
fn test_parse_bool_string_false() {
assert!(!parse_bool(&Some(BoolOrString::Str("false".into())), true));
}
#[test]
fn test_parse_bool_none_returns_default() {
assert!(parse_bool(&None, true));
assert!(!parse_bool(&None, false));
}
// --- parse_effort ---
#[test]
fn test_parse_effort_strings() {
assert_eq!(
parse_effort(&Some(StringOrNumber::Str("low".into()))),
Some(EffortLevel::Low)
);
assert_eq!(
parse_effort(&Some(StringOrNumber::Str("medium".into()))),
Some(EffortLevel::Medium)
);
assert_eq!(
parse_effort(&Some(StringOrNumber::Str("high".into()))),
Some(EffortLevel::High)
);
assert_eq!(
parse_effort(&Some(StringOrNumber::Str("max".into()))),
Some(EffortLevel::Max)
);
}
#[test]
fn test_parse_effort_numbers() {
assert_eq!(
parse_effort(&Some(StringOrNumber::Num(0))),
Some(EffortLevel::Low)
);
assert_eq!(
parse_effort(&Some(StringOrNumber::Num(1))),
Some(EffortLevel::Medium)
);
assert_eq!(
parse_effort(&Some(StringOrNumber::Num(2))),
Some(EffortLevel::High)
);
assert_eq!(
parse_effort(&Some(StringOrNumber::Num(99))),
Some(EffortLevel::Max)
);
}
#[test]
fn test_parse_effort_none() {
assert_eq!(parse_effort(&None), None);
}
// --- parse_string_or_vec ---
#[test]
fn test_parse_string_or_vec_single_comma() {
let v = parse_string_or_vec(&Some(StringOrVec::Single("Read, Write, Bash".into())));
assert_eq!(v, vec!["Read", "Write", "Bash"]);
}
#[test]
fn test_parse_string_or_vec_multiple() {
let v = parse_string_or_vec(&Some(StringOrVec::Multiple(vec![
"Read".into(),
"Write".into(),
])));
assert_eq!(v, vec!["Read", "Write"]);
}
#[test]
fn test_parse_string_or_vec_none() {
let v = parse_string_or_vec(&None);
assert!(v.is_empty());
}
// --- quote_problematic_values ---
#[test]
fn test_quote_curly_braces() {
let yaml = "description: Use {arg} here";
let fixed = quote_problematic_values(yaml);
assert!(fixed.contains("\"Use {arg} here\""));
}
#[test]
fn test_quote_already_quoted_untouched() {
let yaml = "description: \"already quoted\"";
let fixed = quote_problematic_values(yaml);
// Should not double-quote
assert_eq!(fixed.trim(), yaml);
}
#[test]
fn test_quote_nested_lines_untouched() {
let yaml = "hooks:\n - match: foo\n value: {bar}";
let fixed = quote_problematic_values(yaml);
// Indented lines must not be modified
assert!(fixed.contains(" - match: foo"));
assert!(fixed.contains(" value: {bar}"));
}
// --- parse_skill_fields ---
#[test]
fn test_parse_skill_fields_defaults() {
let fm = FrontmatterData::default();
let meta = parse_skill_fields(
&fm,
"# My skill\n\nDoes things.",
"my-skill",
SkillSource::User,
LoadedFrom::Skills,
None,
);
assert_eq!(meta.name, "my-skill");
assert!(meta.user_invocable); // default true
assert!(!meta.disable_model_invocation); // default false
assert_eq!(meta.execution_context, ExecutionContext::Inline);
assert!(meta.model.is_none());
// description falls back to first non-empty content line
assert_eq!(meta.description, "Does things.");
}
#[test]
fn test_parse_skill_fields_model_inherit() {
let fm = FrontmatterData {
model: Some("inherit".into()),
..Default::default()
};
let meta = parse_skill_fields(&fm, "", "x", SkillSource::Project, LoadedFrom::Skills, None);
assert!(meta.model.is_none());
}
#[test]
fn test_parse_skill_fields_fork_context() {
let fm = FrontmatterData {
context: Some("fork".into()),
..Default::default()
};
let meta = parse_skill_fields(&fm, "", "x", SkillSource::User, LoadedFrom::Skills, None);
assert_eq!(meta.execution_context, ExecutionContext::Fork);
}
#[test]
fn test_parse_skill_fields_paths_brace_expansion() {
let fm = FrontmatterData {
paths: Some(StringOrVec::Single("src/*.{ts,tsx}".into())),
..Default::default()
};
let meta = parse_skill_fields(&fm, "", "x", SkillSource::User, LoadedFrom::Skills, None);
let mut paths = meta.paths.clone();
paths.sort();
assert_eq!(paths, vec!["src/*.ts", "src/*.tsx"]);
}
#[test]
fn test_parse_skill_fields_content_length() {
let fm = FrontmatterData::default();
let body = "Hello world";
let meta = parse_skill_fields(&fm, body, "x", SkillSource::User, LoadedFrom::Skills, None);
assert_eq!(meta.content_length, body.len());
}
}
// Supplemental tests live in a separate file to keep this file under 800 lines.
#[cfg(test)]
#[path = "frontmatter_tests.rs"]
mod supplemental_tests;
@@ -0,0 +1,665 @@
// Supplemental tests for frontmatter.rs — covers test-plan.md cases not in impl tests.
// Included from frontmatter.rs as: #[cfg(test)] mod supplemental_tests;
// `use super::*` gives access to private functions in frontmatter.rs.
use super::*;
use crate::types::{
BoolOrString, EffortLevel, ExecutionContext, FrontmatterData, LoadedFrom, SkillSource,
StringOrNumber, StringOrVec,
};
// -----------------------------------------------------------------------
// TC-1.x: FrontmatterData deserialization
// -----------------------------------------------------------------------
#[test]
fn tc_1_3_serde_default_missing_fields_are_none() {
let yaml = "name: test";
let data: FrontmatterData = serde_yaml::from_str(yaml).unwrap();
assert_eq!(data.name.as_deref(), Some("test"));
assert!(data.description.is_none());
assert!(data.allowed_tools.is_none());
assert!(data.argument_hint.is_none());
assert!(data.arguments.is_none());
assert!(data.model.is_none());
assert!(data.effort.is_none());
assert!(data.context.is_none());
assert!(data.user_invocable.is_none());
}
#[test]
fn tc_1_4_allowed_tools_array() {
let yaml = "allowed-tools:\n - Bash\n - Read\n - Write";
let data: FrontmatterData = serde_yaml::from_str(yaml).unwrap();
match data.allowed_tools.unwrap() {
StringOrVec::Multiple(v) => assert_eq!(v, vec!["Bash", "Read", "Write"]),
other => panic!("expected Multiple, got {:?}", other),
}
}
#[test]
fn tc_1_5_allowed_tools_single_string() {
let yaml = "allowed-tools: Bash";
let data: FrontmatterData = serde_yaml::from_str(yaml).unwrap();
match data.allowed_tools.unwrap() {
StringOrVec::Single(s) => assert_eq!(s, "Bash"),
other => panic!("expected Single, got {:?}", other),
}
}
#[test]
fn tc_1_6_effort_as_string() {
let yaml = "effort: high";
let data: FrontmatterData = serde_yaml::from_str(yaml).unwrap();
match data.effort.unwrap() {
StringOrNumber::Str(s) => assert_eq!(s, "high"),
other => panic!("expected Str, got {:?}", other),
}
}
#[test]
fn tc_1_7_effort_as_number() {
let yaml = "effort: 3";
let data: FrontmatterData = serde_yaml::from_str(yaml).unwrap();
match data.effort.unwrap() {
StringOrNumber::Num(n) => assert_eq!(n, 3),
other => panic!("expected Num, got {:?}", other),
}
}
#[test]
fn tc_1_8_user_invocable_bool() {
let yaml = "user-invocable: false";
let data: FrontmatterData = serde_yaml::from_str(yaml).unwrap();
match data.user_invocable.unwrap() {
BoolOrString::Bool(b) => assert!(!b),
other => panic!("expected Bool, got {:?}", other),
}
}
#[test]
fn tc_1_9_user_invocable_string() {
let yaml = "user-invocable: \"true\"";
let data: FrontmatterData = serde_yaml::from_str(yaml).unwrap();
match data.user_invocable.unwrap() {
BoolOrString::Str(s) => assert_eq!(s, "true"),
other => panic!("expected Str, got {:?}", other),
}
}
#[test]
fn tc_1_10_hooks_field_preserved() {
let yaml = "hooks:\n PostToolUse:\n - command: echo done";
let data: FrontmatterData = serde_yaml::from_str(yaml).unwrap();
assert!(data.hooks.is_some());
}
// -----------------------------------------------------------------------
// TC-2.x: Two-pass parsing strategy
// -----------------------------------------------------------------------
#[test]
fn tc_2_3_square_bracket_in_value() {
let input = "---\nargument-hint: [optional]\n---\nbody";
let parsed = parse_frontmatter(input);
assert_eq!(
parsed.frontmatter.argument_hint.as_deref(),
Some("[optional]")
);
}
#[test]
fn tc_2_4_asterisk_in_value() {
let input = "---\ndescription: Match *.rs files\n---\nbody";
let parsed = parse_frontmatter(input);
assert_eq!(
parsed.frontmatter.description.as_deref(),
Some("Match *.rs files")
);
}
#[test]
fn tc_2_5_hash_in_value() {
// YAML treats " #..." as an inline comment. serde_yaml first pass "succeeds"
// but silently strips the comment portion: "See issue #123" → "See issue".
// The two-pass rescue only triggers when the first pass errors — it does not
// detect silent value truncation caused by inline comments.
// Known limitation: values containing " #" are not rescued by quote_problematic_values.
let input = "---\ndescription: See issue #123\n---\nbody";
let parsed = parse_frontmatter(input);
assert_eq!(parsed.frontmatter.description.as_deref(), Some("See issue"));
}
#[test]
fn tc_2_6_pipe_in_value() {
let input = "---\ndescription: Use cmd | grep pattern\n---\nbody";
let parsed = parse_frontmatter(input);
assert_eq!(
parsed.frontmatter.description.as_deref(),
Some("Use cmd | grep pattern")
);
}
#[test]
fn tc_2_7_both_passes_fail_returns_empty_frontmatter() {
// Deeply malformed YAML that cannot be rescued by quoting
let input = "---\n: {unclosed\n bad: : : yaml:\n---\n# Real Content\n";
let parsed = parse_frontmatter(input);
// Must not panic; all fields should be None (empty FrontmatterData)
assert!(parsed.frontmatter.name.is_none());
assert!(parsed.frontmatter.description.is_none());
}
#[test]
fn tc_2_8_multiple_special_char_fields() {
let input =
"---\ndescription: Handle {a} and [b] patterns\nargument-hint: <file> [options]\n---\n";
let parsed = parse_frontmatter(input);
assert_eq!(
parsed.frontmatter.description.as_deref(),
Some("Handle {a} and [b] patterns")
);
assert_eq!(
parsed.frontmatter.argument_hint.as_deref(),
Some("<file> [options]")
);
}
// -----------------------------------------------------------------------
// TC-3.x: Edge cases for parse_frontmatter
// -----------------------------------------------------------------------
#[test]
fn tc_3_1_no_frontmatter_plain_markdown() {
let input = "# Just a heading\nSome content";
let parsed = parse_frontmatter(input);
assert!(parsed.frontmatter.name.is_none());
assert!(parsed.frontmatter.description.is_none());
assert_eq!(parsed.content, input);
}
#[test]
fn tc_3_2_empty_string_input() {
let parsed = parse_frontmatter("");
assert!(parsed.frontmatter.name.is_none());
assert_eq!(parsed.content, "");
}
#[test]
fn tc_3_3_only_frontmatter_no_body() {
let input = "---\nname: test\n---\n";
let parsed = parse_frontmatter(input);
assert_eq!(parsed.frontmatter.name.as_deref(), Some("test"));
assert_eq!(parsed.content, "");
}
#[test]
fn tc_3_5_yaml_comment_in_frontmatter() {
let input = "---\n# This is a comment\nname: test\n---\n";
let parsed = parse_frontmatter(input);
assert_eq!(parsed.frontmatter.name.as_deref(), Some("test"));
}
#[test]
fn tc_3_6_frontmatter_not_at_start() {
// --- not at line 0 — should NOT be treated as frontmatter
let input = "Some text\n---\nname: test\n---\n";
let parsed = parse_frontmatter(input);
assert!(parsed.frontmatter.name.is_none());
assert_eq!(parsed.content, input);
}
#[test]
fn tc_3_7_only_opening_fence_no_close() {
let input = "---\nname: test\n# No closing fence";
let parsed = parse_frontmatter(input);
assert!(parsed.frontmatter.name.is_none());
assert_eq!(parsed.content, input);
}
// -----------------------------------------------------------------------
// TC-4.x: parse_skill_fields normalization
// -----------------------------------------------------------------------
#[test]
fn tc_4_1_user_invocable_defaults_to_true() {
let fm = FrontmatterData::default();
let meta = parse_skill_fields(&fm, "", "x", SkillSource::User, LoadedFrom::Skills, None);
assert!(meta.user_invocable);
}
#[test]
fn tc_4_2_user_invocable_false() {
let fm = FrontmatterData {
user_invocable: Some(BoolOrString::Bool(false)),
..Default::default()
};
let meta = parse_skill_fields(&fm, "", "x", SkillSource::User, LoadedFrom::Skills, None);
assert!(!meta.user_invocable);
}
#[test]
fn tc_4_3_user_invocable_string_false() {
let fm = FrontmatterData {
user_invocable: Some(BoolOrString::Str("false".into())),
..Default::default()
};
let meta = parse_skill_fields(&fm, "", "x", SkillSource::User, LoadedFrom::Skills, None);
assert!(!meta.user_invocable);
}
#[test]
fn tc_4_5_model_non_inherit_preserved() {
let fm = FrontmatterData {
model: Some("claude-opus-4-6".into()),
..Default::default()
};
let meta = parse_skill_fields(&fm, "", "x", SkillSource::User, LoadedFrom::Skills, None);
assert_eq!(meta.model.as_deref(), Some("claude-opus-4-6"));
}
#[test]
fn tc_4_6_description_extracted_from_content_first_nonheading_line() {
let fm = FrontmatterData::default();
let meta = parse_skill_fields(
&fm,
"# Title\n\nFirst real paragraph.",
"x",
SkillSource::User,
LoadedFrom::Skills,
None,
);
assert_eq!(meta.description, "First real paragraph.");
assert!(!meta.has_user_specified_description);
}
#[test]
fn tc_4_7_description_empty_content_no_panic() {
let fm = FrontmatterData::default();
let meta = parse_skill_fields(&fm, "", "x", SkillSource::User, LoadedFrom::Skills, None);
assert_eq!(meta.description, "");
assert!(!meta.has_user_specified_description);
}
#[test]
fn tc_4_8_has_user_specified_description_true_when_frontmatter_has_it() {
let fm = FrontmatterData {
description: Some("User provided".into()),
..Default::default()
};
let meta = parse_skill_fields(
&fm,
"# Title",
"x",
SkillSource::User,
LoadedFrom::Skills,
None,
);
assert!(meta.has_user_specified_description);
assert_eq!(meta.description, "User provided");
}
#[test]
fn tc_4_10_allowed_tools_single_string_to_vec() {
let fm = FrontmatterData {
allowed_tools: Some(StringOrVec::Single("Bash".into())),
..Default::default()
};
let meta = parse_skill_fields(&fm, "", "x", SkillSource::User, LoadedFrom::Skills, None);
assert_eq!(meta.allowed_tools, vec!["Bash"]);
}
#[test]
fn tc_4_11_allowed_tools_comma_separated() {
let fm = FrontmatterData {
allowed_tools: Some(StringOrVec::Single("Bash,Read,Write".into())),
..Default::default()
};
let meta = parse_skill_fields(&fm, "", "x", SkillSource::User, LoadedFrom::Skills, None);
assert_eq!(meta.allowed_tools, vec!["Bash", "Read", "Write"]);
}
#[test]
fn tc_4_12_allowed_tools_none_gives_empty_vec() {
let fm = FrontmatterData::default();
let meta = parse_skill_fields(&fm, "", "x", SkillSource::User, LoadedFrom::Skills, None);
assert!(meta.allowed_tools.is_empty());
}
#[test]
fn tc_4_13_argument_names_parsed() {
let fm = FrontmatterData {
arguments: Some(StringOrVec::Multiple(vec!["query".into(), "limit".into()])),
..Default::default()
};
let meta = parse_skill_fields(&fm, "", "x", SkillSource::User, LoadedFrom::Skills, None);
assert_eq!(meta.argument_names, vec!["query", "limit"]);
}
#[test]
fn tc_4_14_execution_context_defaults_to_inline() {
let fm = FrontmatterData::default();
let meta = parse_skill_fields(&fm, "", "x", SkillSource::User, LoadedFrom::Skills, None);
assert_eq!(meta.execution_context, ExecutionContext::Inline);
}
#[test]
fn tc_4_17_source_and_loaded_from_passed_through() {
let fm = FrontmatterData::default();
let meta = parse_skill_fields(
&fm,
"",
"x",
SkillSource::Project,
LoadedFrom::CommandsDeprecated,
None,
);
assert_eq!(meta.source, SkillSource::Project);
assert_eq!(meta.loaded_from, LoadedFrom::CommandsDeprecated);
}
#[test]
fn tc_4_18_skill_root_passed_through() {
let fm = FrontmatterData::default();
let meta = parse_skill_fields(
&fm,
"",
"x",
SkillSource::User,
LoadedFrom::Skills,
Some("/home/user/.claude/skills"),
);
assert_eq!(
meta.skill_root.as_deref(),
Some("/home/user/.claude/skills")
);
}
#[test]
fn tc_4_19_disable_model_invocation_mapping() {
let fm = FrontmatterData {
hide_from_model_invocation: Some(BoolOrString::Bool(true)),
..Default::default()
};
let meta = parse_skill_fields(&fm, "", "x", SkillSource::User, LoadedFrom::Skills, None);
assert!(meta.disable_model_invocation);
}
// -----------------------------------------------------------------------
// TC-5.x: parse_effort additional cases
// -----------------------------------------------------------------------
#[test]
fn tc_5_5_effort_number_mapping() {
assert_eq!(
parse_effort(&Some(StringOrNumber::Num(3))),
Some(EffortLevel::Max)
);
assert_eq!(
parse_effort(&Some(StringOrNumber::Num(0))),
Some(EffortLevel::Low)
);
assert_eq!(
parse_effort(&Some(StringOrNumber::Num(1))),
Some(EffortLevel::Medium)
);
assert_eq!(
parse_effort(&Some(StringOrNumber::Num(2))),
Some(EffortLevel::High)
);
}
#[test]
fn tc_5_7_effort_unknown_string_returns_none() {
let result = parse_effort(&Some(StringOrNumber::Str("unknown".into())));
assert_eq!(result, None);
}
#[test]
fn tc_5_8_effort_uppercase_string() {
let result = parse_effort(&Some(StringOrNumber::Str("HIGH".into())));
assert_eq!(result, Some(EffortLevel::High));
}
#[test]
fn tc_5_x_effort_normal_alias() {
let result = parse_effort(&Some(StringOrNumber::Str("normal".into())));
assert_eq!(result, Some(EffortLevel::Medium));
}
#[test]
fn tc_5_x_effort_maximum_alias() {
let result = parse_effort(&Some(StringOrNumber::Str("maximum".into())));
assert_eq!(result, Some(EffortLevel::Max));
}
// -----------------------------------------------------------------------
// TC-6.x: expand_braces additional cases
// -----------------------------------------------------------------------
#[test]
fn tc_6_2_path_prefix_brace_expansion() {
let mut result = expand_braces("src/*.{ts,tsx}");
result.sort();
assert_eq!(result, vec!["src/*.ts", "src/*.tsx"]);
}
#[test]
fn tc_6_6_three_element_brace() {
let mut result = expand_braces("*.{rs,toml,md}");
result.sort();
assert_eq!(result, vec!["*.md", "*.rs", "*.toml"]);
}
#[test]
fn tc_6_7_empty_string_no_panic() {
// Must not panic
let _ = expand_braces("");
}
// -----------------------------------------------------------------------
// TC-7.x: parse_string_or_vec additional cases
// -----------------------------------------------------------------------
#[test]
fn tc_7_5_comma_separated_with_spaces_trimmed() {
let v = parse_string_or_vec(&Some(StringOrVec::Single("Bash, Read, Write".into())));
assert_eq!(v, vec!["Bash", "Read", "Write"]);
}
// -----------------------------------------------------------------------
// TC-8.x: split_paths
// -----------------------------------------------------------------------
#[test]
fn tc_8_1_single_path_no_brace() {
let result = split_paths(&Some(StringOrVec::Single("src/**/*.rs".into())));
assert_eq!(result, vec!["src/**/*.rs"]);
}
#[test]
fn tc_8_2_single_path_with_brace() {
let mut result = split_paths(&Some(StringOrVec::Single("src/*.{ts,tsx}".into())));
result.sort();
assert_eq!(result, vec!["src/*.ts", "src/*.tsx"]);
}
#[test]
fn tc_8_3_multiple_paths_each_brace_expanded() {
let mut result = split_paths(&Some(StringOrVec::Multiple(vec![
"*.{rs,toml}".into(),
"src/**".into(),
])));
result.sort();
assert_eq!(result, vec!["*.rs", "*.toml", "src/**"]);
}
#[test]
fn tc_8_4_comma_separated_paths_string() {
let mut result = split_paths(&Some(StringOrVec::Single("src/*.rs,tests/*.rs".into())));
result.sort();
assert_eq!(result, vec!["src/*.rs", "tests/*.rs"]);
}
#[test]
fn tc_8_4b_comma_in_brace_not_split() {
let mut result = split_paths(&Some(StringOrVec::Single("src/*.{ts,tsx}".into())));
result.sort();
assert_eq!(result, vec!["src/*.ts", "src/*.tsx"]);
}
#[test]
fn tc_8_5_none_returns_empty_vec() {
let result = split_paths(&None);
assert!(result.is_empty());
}
// -----------------------------------------------------------------------
// TC-9.x: parse_bool additional cases
// -----------------------------------------------------------------------
#[test]
fn tc_9_7_unknown_string_returns_default() {
let result = parse_bool(&Some(BoolOrString::Str("yes".into())), false);
assert!(!result);
}
// -----------------------------------------------------------------------
// TC-10.x: extract_description_from_content
// -----------------------------------------------------------------------
#[test]
fn tc_10_1_extract_first_nonheading_line_skips_h1() {
let result = extract_description_from_content("# My Skill Title\nSome description");
assert_eq!(result.as_deref(), Some("Some description"));
}
#[test]
fn tc_10_2_extract_plain_first_line() {
let result = extract_description_from_content("First line of content\nSecond line");
assert_eq!(result.as_deref(), Some("First line of content"));
}
#[test]
fn tc_10_3_empty_content_returns_none() {
let result = extract_description_from_content("");
assert!(result.is_none());
}
#[test]
fn tc_10_4_all_whitespace_returns_none() {
let result = extract_description_from_content("\n\n\n");
assert!(result.is_none());
}
#[test]
fn tc_10_5_skips_blank_lines_then_heading() {
let result = extract_description_from_content("\n\n# Real Title\nContent");
assert_eq!(result.as_deref(), Some("Content"));
}
// -----------------------------------------------------------------------
// TC-11.x: quote_problematic_values additional cases
// -----------------------------------------------------------------------
#[test]
fn tc_11_2_square_bracket_gets_quoted() {
let yaml = "argument-hint: [optional]";
let fixed = quote_problematic_values(yaml);
assert!(fixed.contains("\"[optional]\""));
}
#[test]
fn tc_11_3_no_special_chars_unchanged() {
let yaml = "name: simple-name";
let fixed = quote_problematic_values(yaml);
assert_eq!(fixed.trim(), yaml);
}
#[test]
fn tc_11_5_only_problematic_lines_requoted() {
let yaml = "name: simple\ndescription: Use {x} to do y\nversion: \"1.0\"";
let fixed = quote_problematic_values(yaml);
assert!(fixed.contains("name: simple"));
assert!(fixed.contains("version: \"1.0\""));
assert!(fixed.contains("\"Use {x} to do y\""));
}
// -----------------------------------------------------------------------
// TC-12.x: Integration tests
// -----------------------------------------------------------------------
#[test]
fn tc_12_1_full_skill_file_standard() {
let input = r#"---
name: test-skill
description: A test skill for integration
allowed-tools: Bash
user-invocable: true
paths: "src/*.{rs,toml}"
effort: high
---
# Test Skill
This skill does things.
"#;
let parsed = parse_frontmatter(input);
let meta = parse_skill_fields(
&parsed.frontmatter,
&parsed.content,
"test-skill",
SkillSource::User,
LoadedFrom::Skills,
None,
);
assert_eq!(meta.name, "test-skill");
assert_eq!(meta.allowed_tools, vec!["Bash"]);
assert!(meta.user_invocable);
assert_eq!(meta.effort, Some(EffortLevel::High));
assert!(meta.content.contains("This skill does things"));
let mut paths = meta.paths.clone();
paths.sort();
assert_eq!(paths, vec!["src/*.rs", "src/*.toml"]);
}
#[test]
fn tc_12_2_full_skill_file_special_chars() {
let input = r#"---
description: Handle {input} and [output] patterns
argument-hint: <file> [options]
---
# Body
"#;
let parsed = parse_frontmatter(input);
assert_eq!(
parsed.frontmatter.description.as_deref(),
Some("Handle {input} and [output] patterns")
);
assert_eq!(
parsed.frontmatter.argument_hint.as_deref(),
Some("<file> [options]")
);
}
#[test]
fn tc_12_3_legacy_commands_loaded_from() {
let fm = FrontmatterData {
description: Some("Legacy skill".into()),
..Default::default()
};
let meta = parse_skill_fields(
&fm,
"# Legacy\nDoes old things.",
"legacy-cmd",
SkillSource::Legacy,
LoadedFrom::CommandsDeprecated,
Some("/project/.claude/commands"),
);
assert_eq!(meta.loaded_from, LoadedFrom::CommandsDeprecated);
assert_eq!(meta.source, SkillSource::Legacy);
assert_eq!(
meta.skill_root.as_deref(),
Some("/project/.claude/commands")
);
}
@@ -0,0 +1,643 @@
use crate::types::SkillSource;
use nomi_config::hooks::{HookDef, HooksConfig};
/// A single hook command extracted from skill frontmatter.
/// Only command-type hooks are supported; prompt/http/agent are silently skipped.
pub struct SkillHookCommand {
pub command: String,
/// Tool name glob pattern; None means match all tools.
pub matcher: Option<String>,
/// Timeout in seconds (converted to ms when building HookDef).
pub timeout_secs: Option<u64>,
}
/// Parsed hooks from a skill's frontmatter, grouped by event.
pub struct SkillHooksConfig {
pub pre_tool_use: Vec<SkillHookCommand>,
pub post_tool_use: Vec<SkillHookCommand>,
pub stop: Vec<SkillHookCommand>,
}
/// Parse `hooks_raw` (serde_json::Value) into a `SkillHooksConfig`.
///
/// Returns None when:
/// - `hooks_raw` is None
/// - skill source is MCP (security boundary)
/// - the JSON is not an object (logs warning)
/// - after parsing all events, every vec is empty (D-5)
pub fn parse_skill_hooks(
hooks_raw: Option<&serde_json::Value>,
skill_name: &str,
source: SkillSource,
) -> Option<SkillHooksConfig> {
// MCP skills may not register hooks (security boundary).
if source == SkillSource::Mcp {
tracing::warn!(target: "nomi_skills", skill = %skill_name, "hooks ignored for MCP source");
return None;
}
let raw = hooks_raw?;
let obj = match raw.as_object() {
Some(o) => o,
None => {
tracing::warn!(target: "nomi_skills", skill = %skill_name, "hooks_raw is not a JSON object, ignoring");
return None;
}
};
let mut config = SkillHooksConfig {
pre_tool_use: Vec::new(),
post_tool_use: Vec::new(),
stop: Vec::new(),
};
for (event_key, matchers_val) in obj {
let target = match event_key.as_str() {
"PreToolUse" => &mut config.pre_tool_use,
"PostToolUse" => &mut config.post_tool_use,
"Stop" => &mut config.stop,
other => {
tracing::warn!(target: "nomi_skills", skill = %skill_name, event = %other, "unknown hook event, skipping");
continue;
}
};
let matchers = match matchers_val.as_array() {
Some(a) => a,
None => {
tracing::warn!(target: "nomi_skills", skill = %skill_name, event = %event_key, "hook event value is not an array, skipping");
continue;
}
};
for matcher_entry in matchers {
let matcher_str = matcher_entry["matcher"].as_str().map(|s| s.to_string());
let hooks_arr = match matcher_entry["hooks"].as_array() {
Some(a) => a,
None => continue,
};
for hook in hooks_arr {
// Only command-type hooks are supported.
match hook["type"].as_str() {
Some("command") => {}
Some(other) => {
tracing::warn!(target: "nomi_skills", skill = %skill_name, hook_type = %other, "unsupported hook type, skipping");
continue;
}
None => {
tracing::warn!(target: "nomi_skills", skill = %skill_name, "hook missing type field, skipping");
continue;
}
}
let command = match hook["command"].as_str() {
Some(c) => c.to_string(),
None => {
tracing::warn!(target: "nomi_skills", skill = %skill_name, "command-type hook missing command field, skipping");
continue;
}
};
let timeout_secs = hook["timeout"].as_u64();
target.push(SkillHookCommand {
command,
matcher: matcher_str.clone(),
timeout_secs,
});
}
}
}
// D-5: return None when all vecs are empty after parsing.
if config.pre_tool_use.is_empty() && config.post_tool_use.is_empty() && config.stop.is_empty() {
return None;
}
Some(config)
}
/// Convert a `SkillHooksConfig` into a `HooksConfig` (Vec<HookDef> per event).
///
/// Hook name format: `skill:{skill_name}:{event}:{index}` for idempotent merging.
pub fn to_hook_defs(config: &SkillHooksConfig, skill_name: &str) -> HooksConfig {
HooksConfig {
pre_tool_use: build_defs(&config.pre_tool_use, skill_name, "pre_tool_use"),
post_tool_use: build_defs(&config.post_tool_use, skill_name, "post_tool_use"),
stop: build_defs(&config.stop, skill_name, "stop"),
}
}
fn build_defs(cmds: &[SkillHookCommand], skill_name: &str, event: &str) -> Vec<HookDef> {
cmds.iter()
.enumerate()
.map(|(idx, cmd)| {
let tool_match = cmd
.matcher
.as_deref()
.map(|m| vec![m.to_string()])
.unwrap_or_default();
let timeout_ms = cmd
.timeout_secs
.map(|s| s.saturating_mul(1_000))
.unwrap_or(30_000);
HookDef {
name: format!("skill:{}:{}:{}", skill_name, event, idx),
tool_match,
file_match: Vec::new(),
command: cmd.command.clone(),
timeout_ms,
}
})
.collect()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::types::SkillSource;
use serde_json::json;
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
fn make_cmd(
command: &str,
matcher: Option<&str>,
timeout_secs: Option<u64>,
) -> SkillHookCommand {
SkillHookCommand {
command: command.to_string(),
matcher: matcher.map(|s| s.to_string()),
timeout_secs,
}
}
// -----------------------------------------------------------------------
// TC-11.1: full three-event hooks parse correctly
// -----------------------------------------------------------------------
#[test]
fn tc_11_1_full_three_event_parse() {
let raw = json!({
"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "echo pre", "timeout": 10}]}],
"PostToolUse": [{"matcher": "Read", "hooks": [{"type": "command", "command": "echo post"}]}],
"Stop": [{"hooks": [{"type": "command", "command": "echo stop"}]}]
});
let result = parse_skill_hooks(Some(&raw), "my-skill", SkillSource::User);
let config = result.expect("TC-11.1: should return Some");
assert_eq!(config.pre_tool_use.len(), 1);
assert_eq!(config.post_tool_use.len(), 1);
assert_eq!(config.stop.len(), 1);
}
// -----------------------------------------------------------------------
// TC-11.2: hooks_raw None returns None
// -----------------------------------------------------------------------
#[test]
fn tc_11_2_none_hooks_raw_returns_none() {
let result = parse_skill_hooks(None, "my-skill", SkillSource::User);
assert!(result.is_none(), "TC-11.2: None input must return None");
}
// -----------------------------------------------------------------------
// TC-11.3: MCP source returns None
// -----------------------------------------------------------------------
#[test]
fn tc_11_3_mcp_source_returns_none() {
let raw = json!({"PreToolUse": [{"hooks": [{"type": "command", "command": "echo x"}]}]});
let result = parse_skill_hooks(Some(&raw), "mcp-skill", SkillSource::Mcp);
assert!(result.is_none(), "TC-11.3: MCP source must return None");
}
// -----------------------------------------------------------------------
// TC-11.4: prompt type silently skipped → all vecs empty → None (AC-15)
// -----------------------------------------------------------------------
#[test]
fn tc_11_4_prompt_type_skipped_returns_none() {
let raw = json!({"PreToolUse": [{"hooks": [{"type": "prompt", "command": "echo x"}]}]});
let result = parse_skill_hooks(Some(&raw), "skill", SkillSource::User);
assert!(result.is_none(), "TC-11.4: prompt type only → None");
}
// -----------------------------------------------------------------------
// TC-11.5: http type silently skipped → None (AC-15)
// -----------------------------------------------------------------------
#[test]
fn tc_11_5_http_type_skipped_returns_none() {
let raw = json!({"PreToolUse": [{"hooks": [{"type": "http", "url": "http://x"}]}]});
let result = parse_skill_hooks(Some(&raw), "skill", SkillSource::User);
assert!(result.is_none(), "TC-11.5: http type only → None");
}
// -----------------------------------------------------------------------
// TC-11.6: agent type silently skipped → None (AC-15)
// -----------------------------------------------------------------------
#[test]
fn tc_11_6_agent_type_skipped_returns_none() {
let raw = json!({"PreToolUse": [{"hooks": [{"type": "agent", "agent": "foo"}]}]});
let result = parse_skill_hooks(Some(&raw), "skill", SkillSource::User);
assert!(result.is_none(), "TC-11.6: agent type only → None");
}
// -----------------------------------------------------------------------
// TC-11.7: unknown event SessionStart silently skipped → all vecs empty → None
// -----------------------------------------------------------------------
#[test]
fn tc_11_7_unknown_event_skipped_returns_none() {
let raw = json!({"SessionStart": [{"hooks": [{"type": "command", "command": "echo x"}]}]});
let result = parse_skill_hooks(Some(&raw), "skill", SkillSource::User);
assert!(result.is_none(), "TC-11.7: unknown event only → None");
}
// -----------------------------------------------------------------------
// TC-11.8: mixed known/unknown events — known event parsed correctly
// -----------------------------------------------------------------------
#[test]
fn tc_11_8_mixed_known_unknown_events() {
let raw = json!({
"PreToolUse": [{"hooks": [{"type": "command", "command": "echo pre"}]}],
"SessionStart": [{"hooks": [{"type": "command", "command": "echo x"}]}]
});
let result = parse_skill_hooks(Some(&raw), "skill", SkillSource::User);
let config = result.expect("TC-11.8: known event present → Some");
assert_eq!(config.pre_tool_use.len(), 1);
assert_eq!(config.stop.len(), 0);
}
// -----------------------------------------------------------------------
// TC-11.9: command entry missing command field → skipped → None (AC-15)
// -----------------------------------------------------------------------
#[test]
fn tc_11_9_missing_command_field_returns_none() {
let raw = json!({"PreToolUse": [{"hooks": [{"type": "command"}]}]});
let result = parse_skill_hooks(Some(&raw), "skill", SkillSource::User);
assert!(result.is_none(), "TC-11.9: missing command field → None");
}
// -----------------------------------------------------------------------
// TC-11.10: hooks_raw is array (not object) → None
// -----------------------------------------------------------------------
#[test]
fn tc_11_10_array_input_returns_none() {
let raw = json!([1, 2, 3]);
let result = parse_skill_hooks(Some(&raw), "skill", SkillSource::User);
assert!(result.is_none(), "TC-11.10: array input must return None");
}
// -----------------------------------------------------------------------
// TC-11.11: hooks_raw is null JSON → None
// -----------------------------------------------------------------------
#[test]
fn tc_11_11_null_json_returns_none() {
let raw = json!(null);
let result = parse_skill_hooks(Some(&raw), "skill", SkillSource::User);
assert!(result.is_none(), "TC-11.11: null JSON must return None");
}
// -----------------------------------------------------------------------
// TC-11.12: matcher field absent → None (match all tools)
// -----------------------------------------------------------------------
#[test]
fn tc_11_12_absent_matcher_is_none() {
let raw = json!({"PreToolUse": [{"hooks": [{"type": "command", "command": "echo x"}]}]});
let config = parse_skill_hooks(Some(&raw), "skill", SkillSource::User)
.expect("TC-11.12: should return Some");
assert!(
config.pre_tool_use[0].matcher.is_none(),
"TC-11.12: absent matcher should be None"
);
}
// -----------------------------------------------------------------------
// TC-11.13: matcher field present → preserved
// -----------------------------------------------------------------------
#[test]
fn tc_11_13_present_matcher_preserved() {
let raw = json!({"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "echo x"}]}]});
let config = parse_skill_hooks(Some(&raw), "skill", SkillSource::User)
.expect("TC-11.13: should return Some");
assert_eq!(config.pre_tool_use[0].matcher.as_deref(), Some("Bash"));
}
// -----------------------------------------------------------------------
// TC-11.14: timeout field present → preserved in seconds
// -----------------------------------------------------------------------
#[test]
fn tc_11_14_timeout_preserved() {
let raw = json!({"PreToolUse": [{"hooks": [{"type": "command", "command": "echo x", "timeout": 5}]}]});
let config = parse_skill_hooks(Some(&raw), "skill", SkillSource::User)
.expect("TC-11.14: should return Some");
assert_eq!(config.pre_tool_use[0].timeout_secs, Some(5));
}
// -----------------------------------------------------------------------
// TC-11.15: timeout field absent → None
// -----------------------------------------------------------------------
#[test]
fn tc_11_15_absent_timeout_is_none() {
let raw = json!({"PreToolUse": [{"hooks": [{"type": "command", "command": "echo x"}]}]});
let config = parse_skill_hooks(Some(&raw), "skill", SkillSource::User)
.expect("TC-11.15: should return Some");
assert!(config.pre_tool_use[0].timeout_secs.is_none());
}
// -----------------------------------------------------------------------
// TC-11.16: Project/Managed/Bundled/Legacy sources all parse successfully
// -----------------------------------------------------------------------
#[test]
fn tc_11_16_non_mcp_sources_parse_successfully() {
let raw = json!({"PreToolUse": [{"hooks": [{"type": "command", "command": "echo x"}]}]});
for source in [
SkillSource::Project,
SkillSource::Managed,
SkillSource::Bundled,
SkillSource::Legacy,
] {
let result = parse_skill_hooks(Some(&raw), "skill", source);
assert!(
result.is_some(),
"TC-11.16: source {:?} should return Some",
source
);
}
}
// -----------------------------------------------------------------------
// TC-11.17: mixed command + prompt in same matcher → only command kept
// -----------------------------------------------------------------------
#[test]
fn tc_11_17_mixed_command_and_prompt_keeps_command_only() {
let raw = json!({
"PreToolUse": [{"hooks": [
{"type": "command", "command": "echo x"},
{"type": "prompt", "prompt": "p"}
]}]
});
let config = parse_skill_hooks(Some(&raw), "skill", SkillSource::User)
.expect("TC-11.17: command present → Some");
assert_eq!(config.pre_tool_use.len(), 1);
assert_eq!(config.pre_tool_use[0].command, "echo x");
}
// -----------------------------------------------------------------------
// TC-11.18: empty hooks object {} → None (AC-15)
// -----------------------------------------------------------------------
#[test]
fn tc_11_18_empty_object_returns_none() {
let raw = json!({});
let result = parse_skill_hooks(Some(&raw), "skill", SkillSource::User);
assert!(result.is_none(), "TC-11.18: empty object → None");
}
// -----------------------------------------------------------------------
// TC-11.19: AC-15 mixed scenario: pre has command, post has prompt only → Some
// -----------------------------------------------------------------------
#[test]
fn tc_11_19_pre_command_post_prompt_returns_some() {
let raw = json!({
"PreToolUse": [{"hooks": [{"type": "command", "command": "echo pre"}]}],
"PostToolUse": [{"hooks": [{"type": "prompt", "prompt": "p"}]}]
});
let config = parse_skill_hooks(Some(&raw), "skill", SkillSource::User)
.expect("TC-11.19: pre has command → Some");
assert_eq!(config.pre_tool_use.len(), 1);
assert_eq!(config.post_tool_use.len(), 0);
}
// -----------------------------------------------------------------------
// TC-11.20: pre_tool_use hook correctly converted to HookDef
// -----------------------------------------------------------------------
#[test]
fn tc_11_20_pre_hook_converted_to_hookdef() {
let config = SkillHooksConfig {
pre_tool_use: vec![make_cmd("echo x", Some("Bash"), Some(5))],
post_tool_use: vec![],
stop: vec![],
};
let result = to_hook_defs(&config, "my-skill");
assert_eq!(result.pre_tool_use.len(), 1);
let def = &result.pre_tool_use[0];
assert!(
def.name.contains("my-skill"),
"TC-11.20: name must contain skill name"
);
assert_eq!(def.command, "echo x");
assert_eq!(def.tool_match, vec!["Bash"]);
assert_eq!(def.timeout_ms, 5_000);
}
// -----------------------------------------------------------------------
// TC-11.21: post_tool_use hook — no matcher → empty tool_match, default timeout
// -----------------------------------------------------------------------
#[test]
fn tc_11_21_post_hook_no_matcher_default_timeout() {
let config = SkillHooksConfig {
pre_tool_use: vec![],
post_tool_use: vec![make_cmd("echo y", None, None)],
stop: vec![],
};
let result = to_hook_defs(&config, "my-skill");
let def = &result.post_tool_use[0];
assert!(
def.tool_match.is_empty(),
"TC-11.21: None matcher → empty tool_match"
);
assert_eq!(
def.timeout_ms, 30_000,
"TC-11.21: absent timeout → 30s default"
);
}
// -----------------------------------------------------------------------
// TC-11.22: stop hook converted with skill name prefix
// -----------------------------------------------------------------------
#[test]
fn tc_11_22_stop_hook_name_has_prefix() {
let config = SkillHooksConfig {
pre_tool_use: vec![],
post_tool_use: vec![],
stop: vec![make_cmd("echo z", None, None)],
};
let result = to_hook_defs(&config, "my-stopper");
assert_eq!(result.stop.len(), 1);
assert!(
result.stop[0].name.starts_with("skill:my-stopper:"),
"TC-11.22: stop hook name must start with 'skill:my-stopper:', got: {}",
result.stop[0].name
);
}
// -----------------------------------------------------------------------
// TC-11.23: hook name includes skill name as prefix
// -----------------------------------------------------------------------
#[test]
fn tc_11_23_hook_name_starts_with_skill_name() {
let config = SkillHooksConfig {
pre_tool_use: vec![make_cmd("echo", None, None)],
post_tool_use: vec![],
stop: vec![],
};
let result = to_hook_defs(&config, "linter");
assert!(
result.pre_tool_use[0].name.starts_with("skill:linter"),
"TC-11.23: name must start with 'skill:linter', got: {}",
result.pre_tool_use[0].name
);
}
// -----------------------------------------------------------------------
// TC-11.24: timeout seconds converted to milliseconds (×1000)
// -----------------------------------------------------------------------
#[test]
fn tc_11_24_timeout_secs_to_ms() {
let config = SkillHooksConfig {
pre_tool_use: vec![make_cmd("echo", None, Some(10))],
post_tool_use: vec![],
stop: vec![],
};
let result = to_hook_defs(&config, "skill");
assert_eq!(result.pre_tool_use[0].timeout_ms, 10_000);
}
// -----------------------------------------------------------------------
// TC-11.25: timeout = 0 seconds → 0 ms (boundary)
// -----------------------------------------------------------------------
#[test]
fn tc_11_25_timeout_zero_secs() {
let config = SkillHooksConfig {
pre_tool_use: vec![make_cmd("echo", None, Some(0))],
post_tool_use: vec![],
stop: vec![],
};
let result = to_hook_defs(&config, "skill");
assert_eq!(result.pre_tool_use[0].timeout_ms, 0);
}
// -----------------------------------------------------------------------
// TC-11.26: empty SkillHooksConfig → all vecs empty in result
// -----------------------------------------------------------------------
#[test]
fn tc_11_26_empty_config_produces_empty_hooksdconfig() {
let config = SkillHooksConfig {
pre_tool_use: vec![],
post_tool_use: vec![],
stop: vec![],
};
let result = to_hook_defs(&config, "skill");
assert!(result.pre_tool_use.is_empty());
assert!(result.post_tool_use.is_empty());
assert!(result.stop.is_empty());
}
// -----------------------------------------------------------------------
// TC-11.27: multiple pre hooks — all converted
// -----------------------------------------------------------------------
#[test]
fn tc_11_27_multiple_pre_hooks_all_converted() {
let config = SkillHooksConfig {
pre_tool_use: vec![
make_cmd("echo 1", None, None),
make_cmd("echo 2", None, None),
make_cmd("echo 3", None, None),
],
post_tool_use: vec![],
stop: vec![],
};
let result = to_hook_defs(&config, "skill");
assert_eq!(result.pre_tool_use.len(), 3);
}
// -----------------------------------------------------------------------
// TC-11.50: all three events with multiple matchers each
// -----------------------------------------------------------------------
#[test]
fn tc_11_50_all_three_events_multiple_matchers() {
let raw = json!({
"PreToolUse": [
{"hooks": [{"type": "command", "command": "echo pre-1"}]},
{"hooks": [{"type": "command", "command": "echo pre-2"}]}
],
"PostToolUse": [
{"hooks": [{"type": "command", "command": "echo post-1"}]},
{"hooks": [{"type": "command", "command": "echo post-2"}]}
],
"Stop": [
{"hooks": [{"type": "command", "command": "echo stop-1"}]},
{"hooks": [{"type": "command", "command": "echo stop-2"}]}
]
});
let config = parse_skill_hooks(Some(&raw), "skill", SkillSource::User)
.expect("TC-11.50: should return Some");
assert_eq!(config.pre_tool_use.len(), 2);
assert_eq!(config.post_tool_use.len(), 2);
assert_eq!(config.stop.len(), 2);
}
// -----------------------------------------------------------------------
// TC-11.51: empty skill_name in to_hook_defs — no panic
// -----------------------------------------------------------------------
#[test]
fn tc_11_51_empty_skill_name_no_panic() {
let config = SkillHooksConfig {
pre_tool_use: vec![make_cmd("echo", None, None)],
post_tool_use: vec![],
stop: vec![],
};
let result = to_hook_defs(&config, "");
assert_eq!(
result.pre_tool_use.len(),
1,
"TC-11.51: should produce 1 HookDef without panic"
);
}
// -----------------------------------------------------------------------
// TC-11.52: command field is empty string — parse succeeds
// -----------------------------------------------------------------------
#[test]
fn tc_11_52_empty_command_string_succeeds() {
let raw = json!({"PreToolUse": [{"hooks": [{"type": "command", "command": ""}]}]});
let config = parse_skill_hooks(Some(&raw), "skill", SkillSource::User)
.expect("TC-11.52: empty command string should still parse");
assert_eq!(config.pre_tool_use[0].command, "");
}
// -----------------------------------------------------------------------
// TC-11.53: very large timeout — saturating_mul prevents overflow
// -----------------------------------------------------------------------
#[test]
fn tc_11_53_large_timeout_no_overflow() {
let large_secs = u64::MAX / 1_000;
let config = SkillHooksConfig {
pre_tool_use: vec![make_cmd("echo", None, Some(large_secs))],
post_tool_use: vec![],
stop: vec![],
};
// saturating_mul: (u64::MAX / 1000) * 1000 should not overflow
let result = to_hook_defs(&config, "skill");
assert!(
result.pre_tool_use[0].timeout_ms > 0,
"TC-11.53: large timeout must not overflow to 0"
);
}
// -----------------------------------------------------------------------
// TC-11.54: hooks_raw is a string (not object) → None
// -----------------------------------------------------------------------
#[test]
fn tc_11_54_string_input_returns_none() {
let raw = json!("not an object");
let result = parse_skill_hooks(Some(&raw), "skill", SkillSource::User);
assert!(result.is_none(), "TC-11.54: string input must return None");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
pub mod bundled;
pub mod conditional;
pub mod context_modifier;
pub mod discovery;
pub mod executor;
pub mod frontmatter;
pub mod hooks;
pub mod loader;
pub mod mcp;
pub mod paths;
pub mod permissions;
pub mod prompt;
pub mod shell;
pub mod substitution;
pub mod types;
pub mod watcher;
#[cfg(test)]
mod permissions_supplemental_tests;
#[cfg(test)]
#[path = "integration_tests.rs"]
mod integration_tests;
#[cfg(test)]
mod bundled_supplemental_tests;
#[cfg(test)]
mod watcher_tests;
@@ -0,0 +1,458 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use futures::future::join_all;
use crate::bundled;
use crate::frontmatter::{parse_frontmatter, parse_skill_fields};
use crate::mcp::load_mcp_skills;
use crate::paths::{
additional_skills_dirs, project_commands_dirs, project_skills_dirs, user_commands_dir,
user_skills_dir,
};
use crate::types::{LoadedFrom, SkillMetadata, SkillSource};
use nomi_mcp::manager::McpManager;
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/// A loaded skill paired with its canonical filesystem path for deduplication.
pub struct LoadedSkill {
pub metadata: SkillMetadata,
/// Canonicalized path used for dedup (symlinks resolved, `.`/`..` removed).
pub resolved_path: PathBuf,
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Load all skills from the filesystem and optionally from MCP servers.
///
/// Priority order (highest first): bundled → MCP → user → project → additional → legacy.
/// Deduplicates first by canonical path (symlinks resolved), then by name (first wins).
/// Bundled skills always take precedence over same-named MCP or filesystem skills.
///
/// If `bare` is true, only `add_dirs` are consulted (used for isolated
/// environments where the user/project directories should be ignored).
/// Bundled skills are included in bare mode as well.
///
/// Pass `mcp_manager: Some(&manager)` to include MCP-discovered skills.
pub async fn load_all_skills(
cwd: &Path,
add_dirs: &[PathBuf],
bare: bool,
mcp_manager: Option<&McpManager>,
) -> Vec<SkillMetadata> {
// Resolve bundled skills with file extraction (async context).
let bundled_loaded = prepare_bundled_loaded().await;
let mut all: Vec<LoadedSkill> = Vec::new();
if bare {
// Bare mode: only load from explicit add_dirs
let dirs = additional_skills_dirs(add_dirs);
let futures: Vec<_> = dirs
.iter()
.map(|d| load_skills_from_dir(d, SkillSource::Project, LoadedFrom::Skills))
.collect();
for batch in join_all(futures).await {
all.extend(batch);
}
// Bundled skills prepended so they win deduplication
all.splice(0..0, bundled_loaded);
return deduplicate_by_name(deduplicate(all));
}
// 1. User-level skills (highest priority)
if let Some(dir) = user_skills_dir()
&& dir.is_dir()
{
all.extend(load_skills_from_dir(&dir, SkillSource::User, LoadedFrom::Skills).await);
}
// 2. Project-level skills (parallel across all dirs)
let project_dirs = project_skills_dirs(cwd);
let futures: Vec<_> = project_dirs
.iter()
.map(|d| load_skills_from_dir(d, SkillSource::Project, LoadedFrom::Skills))
.collect();
for batch in join_all(futures).await {
all.extend(batch);
}
// 3. Additional dirs from --add-dir
let add_skill_dirs = additional_skills_dirs(add_dirs);
let futures: Vec<_> = add_skill_dirs
.iter()
.map(|d| load_skills_from_dir(d, SkillSource::Project, LoadedFrom::Skills))
.collect();
for batch in join_all(futures).await {
all.extend(batch);
}
// 4. User-level legacy commands (lowest user priority)
if let Some(dir) = user_commands_dir()
&& dir.is_dir()
{
all.extend(load_skills_from_commands_dir(&dir, SkillSource::User).await);
}
// 5. Project-level legacy commands (parallel)
let cmd_dirs = project_commands_dirs(cwd);
let futures: Vec<_> = cmd_dirs
.iter()
.map(|d| load_skills_from_commands_dir(d, SkillSource::Project))
.collect();
for batch in join_all(futures).await {
all.extend(batch);
}
// MCP skills inserted after bundled (highest priority) but before filesystem
// skills, so: bundled > MCP > user > project > additional > legacy.
let mcp_loaded = match mcp_manager {
Some(mgr) => load_mcp_skills(mgr).await,
None => Vec::new(),
};
// Bundled skills first, then MCP, then filesystem
all.splice(0..0, mcp_loaded);
all.splice(0..0, bundled_loaded);
// Path-based dedup first (handles symlinked duplicates), then name-based
// dedup to enforce MCP vs. filesystem priority.
deduplicate_by_name(deduplicate(all))
}
/// Call `bundled::prepare_bundled_skills()` and wrap results as `LoadedSkill`.
///
/// Each bundled skill is assigned a virtual path `<bundled:name>` for
/// deduplication purposes (these paths can never match real filesystem paths).
async fn prepare_bundled_loaded() -> Vec<LoadedSkill> {
bundled::prepare_bundled_skills()
.await
.into_iter()
.map(|meta| {
let virtual_path = PathBuf::from(format!("<bundled:{}>", meta.name));
LoadedSkill {
metadata: meta,
resolved_path: virtual_path,
}
})
.collect()
}
// ---------------------------------------------------------------------------
// Internal: load from skills/ directory (directory-only format)
// ---------------------------------------------------------------------------
/// Load skills from a `skills/` directory.
///
/// Only the directory format is supported: each direct or nested subdirectory
/// that contains a `SKILL.md` file (case-sensitive) is loaded.
/// The skill name is derived from the relative path using colon separators.
pub(crate) async fn load_skills_from_dir(
base_dir: &Path,
source: SkillSource,
loaded_from: LoadedFrom,
) -> Vec<LoadedSkill> {
let mut results = Vec::new();
collect_skill_md(base_dir, base_dir, source, loaded_from, &mut results).await;
results
}
/// Recursively scan `dir` for `SKILL.md` files.
// This is a recursive async function — we use a Box::pin to satisfy the compiler.
fn collect_skill_md<'a>(
base_dir: &'a Path,
dir: &'a Path,
source: SkillSource,
loaded_from: LoadedFrom,
results: &'a mut Vec<LoadedSkill>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
let mut read_dir = match tokio::fs::read_dir(dir).await {
Ok(rd) => rd,
Err(_) => return,
};
while let Ok(Some(entry)) = read_dir.next_entry().await {
let path = entry.path();
// Follow symlinks: entry.file_type() does NOT traverse symlinks,
// so use tokio::fs::metadata() which resolves the target type.
let is_dir = match tokio::fs::metadata(&path).await {
Ok(meta) => meta.is_dir(),
Err(_) => continue,
};
if is_dir {
// Check for SKILL.md directly inside this subdirectory using an
// exact case-sensitive name comparison (important on case-insensitive
// filesystems like macOS APFS).
if let Some(skill_file) = find_exact_file(&path, "SKILL.md").await {
if let Some(skill) =
load_skill_file(&skill_file, base_dir, &path, source, loaded_from).await
{
results.push(skill);
}
} else {
// Recurse into subdirectory (namespace nesting)
collect_skill_md(base_dir, &path, source, loaded_from, results).await;
}
}
}
})
}
// ---------------------------------------------------------------------------
// Internal: load from commands/ directory (legacy flat + directory format)
// ---------------------------------------------------------------------------
/// Load skills from a legacy `commands/` directory.
///
/// Supports two formats:
/// - Directory format: `<name>/SKILL.md` (takes precedence over flat `.md`)
/// - Flat format: `<name>.md` or `<subdir>/<name>.md`
async fn load_skills_from_commands_dir(base_dir: &Path, source: SkillSource) -> Vec<LoadedSkill> {
let mut results = Vec::new();
collect_commands(base_dir, base_dir, source, &mut results).await;
results
}
fn collect_commands<'a>(
base_dir: &'a Path,
dir: &'a Path,
source: SkillSource,
results: &'a mut Vec<LoadedSkill>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
let mut read_dir = match tokio::fs::read_dir(dir).await {
Ok(rd) => rd,
Err(_) => return,
};
// Collect all entries first so we can check for directory/flat conflicts
let mut entries = Vec::new();
while let Ok(Some(entry)) = read_dir.next_entry().await {
entries.push(entry);
}
// Track names that have a directory format (to skip their flat counterpart)
let mut dir_names: HashSet<String> = HashSet::new();
// First pass: handle directory format
for entry in &entries {
let path = entry.path();
// Follow symlinks: use metadata() which resolves symlink targets.
let is_dir = match tokio::fs::metadata(&path).await {
Ok(meta) => meta.is_dir(),
Err(_) => continue,
};
if is_dir {
// Use exact case-sensitive lookup to avoid false positives on
// case-insensitive filesystems (e.g., macOS APFS).
if let Some(skill_file) = find_exact_file(&path, "SKILL.md").await {
// Directory format — load it
if let Some(skill) = load_skill_file(
&skill_file,
base_dir,
&path,
source,
LoadedFrom::CommandsDeprecated,
)
.await
{
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
dir_names.insert(name);
results.push(skill);
}
} else {
// Recurse: this is a namespace subdirectory (e.g., db/migrate.md)
collect_commands(base_dir, &path, source, results).await;
}
}
}
// Second pass: handle flat .md files (skip if directory version exists)
for entry in &entries {
let path = entry.path();
// Follow symlinks: use metadata() to check if this is a file (not a dir symlink).
let is_file = match tokio::fs::metadata(&path).await {
Ok(meta) => meta.is_file(),
Err(_) => continue,
};
if is_file && path.extension().and_then(|e| e.to_str()) == Some("md") {
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
// Skip if a directory format was already loaded for this name
if dir_names.contains(&stem) {
continue;
}
// The "skill directory" for flat files is their parent dir + stem
let pseudo_dir = path.parent().unwrap_or(base_dir).join(&stem);
if let Some(skill) = load_skill_file(
&path,
base_dir,
&pseudo_dir,
source,
LoadedFrom::CommandsDeprecated,
)
.await
{
results.push(skill);
}
}
}
})
}
// ---------------------------------------------------------------------------
// Internal: load a single skill file
// ---------------------------------------------------------------------------
/// Read, parse, and return a `LoadedSkill` for a single Markdown file.
/// Returns `None` if the file cannot be read.
async fn load_skill_file(
file_path: &Path,
base_dir: &Path,
skill_dir: &Path,
source: SkillSource,
loaded_from: LoadedFrom,
) -> Option<LoadedSkill> {
let content = tokio::fs::read_to_string(file_path).await.ok()?;
let parsed = parse_frontmatter(&content);
let resolved_name = build_namespace(base_dir, skill_dir);
// skill_root is the directory containing SKILL.md (i.e., skill_dir itself),
// used for ${NOMI_SKILL_DIR} variable substitution in skill content.
let skill_root = Some(skill_dir.to_string_lossy().into_owned());
let metadata = parse_skill_fields(
&parsed.frontmatter,
&parsed.content,
&resolved_name,
source,
loaded_from,
skill_root.as_deref(),
);
let resolved_path = try_canonicalize(file_path).unwrap_or_else(|| file_path.to_owned());
Some(LoadedSkill {
metadata,
resolved_path,
})
}
// ---------------------------------------------------------------------------
// Internal: namespace building
// ---------------------------------------------------------------------------
/// Build a colon-separated namespace from a directory hierarchy.
///
/// Examples:
/// - base=`<config_dir>/nomi/skills`, target=`<config_dir>/nomi/skills/db/migrate` → `"db:migrate"`
/// - base=`<config_dir>/nomi/skills`, target=`<config_dir>/nomi/skills/my-skill` → `"my-skill"`
pub(crate) fn build_namespace(base_dir: &Path, target_dir: &Path) -> String {
match target_dir.strip_prefix(base_dir) {
Ok(relative) => relative
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(":"),
Err(_) => target_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
}
}
// ---------------------------------------------------------------------------
// Internal: deduplication
// ---------------------------------------------------------------------------
/// Deduplicate loaded skills by canonical path. First occurrence wins.
fn deduplicate(skills: Vec<LoadedSkill>) -> Vec<SkillMetadata> {
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut result = Vec::new();
for skill in skills {
if seen.insert(skill.resolved_path) {
result.push(skill.metadata);
}
}
result
}
/// Deduplicate by skill name (case-sensitive). First occurrence wins.
///
/// Called after path-based dedup to enforce priority between bundled, MCP,
/// and filesystem skills that share the same name but have different paths.
fn deduplicate_by_name(skills: Vec<SkillMetadata>) -> Vec<SkillMetadata> {
let mut seen: HashMap<String, ()> = HashMap::new();
let mut result = Vec::new();
for skill in skills {
if seen.insert(skill.name.clone(), ()).is_none() {
result.push(skill);
}
}
result
}
// ---------------------------------------------------------------------------
// Internal: safe canonicalize
// ---------------------------------------------------------------------------
/// Canonicalize a path, returning `None` if the path does not exist.
/// Never panics.
pub(crate) fn try_canonicalize(path: &Path) -> Option<PathBuf> {
std::fs::canonicalize(path).ok()
}
/// Find a file with an exact case-sensitive name inside `dir`.
///
/// On case-insensitive filesystems (e.g., macOS APFS), `Path::is_file()` may
/// return `true` for `SKILL.md` even when only `skill.md` exists. This
/// function reads the directory entries and performs a byte-for-byte name
/// comparison to avoid false positives.
///
/// Returns `None` if no entry with that exact name exists or if the directory
/// cannot be read.
async fn find_exact_file(dir: &Path, name: &str) -> Option<PathBuf> {
let mut rd = tokio::fs::read_dir(dir).await.ok()?;
while let Ok(Some(entry)) = rd.next_entry().await {
if entry.file_name().to_string_lossy() == name {
let path = entry.path();
let ft = entry.file_type().await.ok()?;
if ft.is_file() {
return Some(path);
}
}
}
None
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[path = "loader_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "loader_supplemental_tests.rs"]
mod supplemental_tests;
@@ -0,0 +1,524 @@
use super::*;
use crate::types::{FrontmatterData, LoadedFrom, SkillSource};
use std::fs;
use tempfile::TempDir;
fn write_skill(dir: &Path, rel_path: &str, content: &str) {
let full = dir.join(rel_path);
fs::create_dir_all(full.parent().unwrap()).unwrap();
fs::write(full, content).unwrap();
}
fn make_loaded_skill(path: PathBuf, name: &str) -> LoadedSkill {
let fm = FrontmatterData::default();
let metadata = crate::frontmatter::parse_skill_fields(
&fm,
"",
name,
SkillSource::User,
LoadedFrom::Skills,
None,
);
LoadedSkill {
metadata,
resolved_path: path,
}
}
// -----------------------------------------------------------------------
// TC-7.x: build_namespace
// -----------------------------------------------------------------------
#[test]
fn tc_7_1_build_namespace_single_level() {
let base = Path::new("/skills");
let target = Path::new("/skills/my-tool");
assert_eq!(build_namespace(base, target), "my-tool");
}
#[test]
fn tc_7_2_build_namespace_two_levels() {
let base = Path::new("/skills");
let target = Path::new("/skills/db/migrate");
assert_eq!(build_namespace(base, target), "db:migrate");
}
#[test]
fn tc_7_3_build_namespace_three_levels() {
let base = Path::new("/skills");
let target = Path::new("/skills/a/b/c");
assert_eq!(build_namespace(base, target), "a:b:c");
}
#[test]
fn tc_7_4_build_namespace_same_dir_returns_empty() {
let base = Path::new("/skills");
let result = build_namespace(base, base);
assert_eq!(result, "", "base == target should produce empty string");
}
// -----------------------------------------------------------------------
// TC-8.x: load_skills_from_dir supplemental cases
// -----------------------------------------------------------------------
#[tokio::test]
async fn tc_8_4_dir_without_skill_md_skipped() {
let tmp = TempDir::new().unwrap();
// empty-dir has no SKILL.md; valid-skill does
fs::create_dir_all(tmp.path().join("empty-dir")).unwrap();
write_skill(tmp.path(), "valid-skill/SKILL.md", "---\n---\n");
let skills = load_skills_from_dir(tmp.path(), SkillSource::User, LoadedFrom::Skills).await;
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].metadata.name, "valid-skill");
}
#[tokio::test]
async fn tc_8_7_source_and_loaded_from_passed_through() {
let tmp = TempDir::new().unwrap();
write_skill(tmp.path(), "test-skill/SKILL.md", "---\n---\n");
let skills = load_skills_from_dir(tmp.path(), SkillSource::Project, LoadedFrom::Skills).await;
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].metadata.source, SkillSource::Project);
assert_eq!(skills[0].metadata.loaded_from, LoadedFrom::Skills);
}
#[tokio::test]
async fn tc_8_9_resolved_path_is_canonical() {
let tmp = TempDir::new().unwrap();
write_skill(tmp.path(), "my-skill/SKILL.md", "---\n---\n");
let skills = load_skills_from_dir(tmp.path(), SkillSource::User, LoadedFrom::Skills).await;
assert_eq!(skills.len(), 1);
let skill_file = tmp.path().join("my-skill").join("SKILL.md");
let expected_canonical = std::fs::canonicalize(&skill_file).unwrap();
assert_eq!(skills[0].resolved_path, expected_canonical);
}
#[tokio::test]
async fn tc_8_x_full_frontmatter_parsed() {
let tmp = TempDir::new().unwrap();
write_skill(
tmp.path(),
"my-skill/SKILL.md",
"---\ndescription: My skill description\nallowed-tools: Bash\n---\n# Body\n",
);
let skills = load_skills_from_dir(tmp.path(), SkillSource::User, LoadedFrom::Skills).await;
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].metadata.description, "My skill description");
assert_eq!(skills[0].metadata.allowed_tools, vec!["Bash"]);
}
#[tokio::test]
async fn tc_8_x_no_frontmatter_description_from_body() {
let tmp = TempDir::new().unwrap();
write_skill(
tmp.path(),
"my-skill/SKILL.md",
"# My Title\nDoes things.\n",
);
let skills = load_skills_from_dir(tmp.path(), SkillSource::User, LoadedFrom::Skills).await;
assert_eq!(skills.len(), 1);
// description extracted from first non-heading line
assert_eq!(skills[0].metadata.description, "Does things.");
assert!(!skills[0].metadata.has_user_specified_description);
}
// -----------------------------------------------------------------------
// TC-9.x: load_skills_from_commands_dir supplemental cases
// -----------------------------------------------------------------------
#[tokio::test]
async fn tc_9_2_flat_md_name_without_extension() {
let tmp = TempDir::new().unwrap();
write_skill(tmp.path(), "simple.md", "---\ndescription: Simple\n---\n");
let skills = load_skills_from_commands_dir(tmp.path(), SkillSource::User).await;
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].metadata.name, "simple");
}
#[tokio::test]
async fn tc_9_3_nested_flat_format_namespace() {
let tmp = TempDir::new().unwrap();
write_skill(
tmp.path(),
"db/migrate.md",
"---\ndescription: DB migrate\n---\n",
);
let skills = load_skills_from_commands_dir(tmp.path(), SkillSource::User).await;
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].metadata.name, "db:migrate");
}
#[tokio::test]
async fn tc_9_5_non_md_files_ignored() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("notes.txt"), "just notes").unwrap();
fs::write(tmp.path().join("config.yaml"), "key: value").unwrap();
write_skill(tmp.path(), "valid.md", "---\ndescription: Valid\n---\n");
let skills = load_skills_from_commands_dir(tmp.path(), SkillSource::User).await;
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].metadata.name, "valid");
}
#[tokio::test]
async fn tc_9_6_empty_commands_dir() {
let tmp = TempDir::new().unwrap();
let skills = load_skills_from_commands_dir(tmp.path(), SkillSource::User).await;
assert!(skills.is_empty());
}
#[tokio::test]
async fn tc_9_7_nonexistent_commands_dir_no_panic() {
let skills = load_skills_from_commands_dir(
Path::new("/nonexistent/commands/dir/xyz"),
SkillSource::User,
)
.await;
assert!(skills.is_empty());
}
#[tokio::test]
async fn tc_9_1_commands_directory_format_loaded_from_deprecated() {
let tmp = TempDir::new().unwrap();
write_skill(
tmp.path(),
"my-cmd/SKILL.md",
"---\ndescription: A command\n---\n",
);
let skills = load_skills_from_commands_dir(tmp.path(), SkillSource::Project).await;
assert_eq!(skills.len(), 1);
assert_eq!(
skills[0].metadata.loaded_from,
LoadedFrom::CommandsDeprecated
);
assert_eq!(skills[0].metadata.source, SkillSource::Project);
}
// -----------------------------------------------------------------------
// TC-10.x: deduplicate supplemental cases
// -----------------------------------------------------------------------
#[test]
fn tc_10_1_deduplicate_no_duplicates_all_preserved() {
let tmp = TempDir::new().unwrap();
let f1 = tmp.path().join("a.md");
let f2 = tmp.path().join("b.md");
let f3 = tmp.path().join("c.md");
fs::write(&f1, "").unwrap();
fs::write(&f2, "").unwrap();
fs::write(&f3, "").unwrap();
let skills = vec![
make_loaded_skill(std::fs::canonicalize(&f1).unwrap(), "skill-a"),
make_loaded_skill(std::fs::canonicalize(&f2).unwrap(), "skill-b"),
make_loaded_skill(std::fs::canonicalize(&f3).unwrap(), "skill-c"),
];
let result = deduplicate(skills);
assert_eq!(result.len(), 3);
}
#[test]
fn tc_10_2_deduplicate_first_occurrence_wins() {
let tmp = TempDir::new().unwrap();
let f = tmp.path().join("skill.md");
fs::write(&f, "").unwrap();
let canonical = std::fs::canonicalize(&f).unwrap();
// Two LoadedSkill with the same path but different names (first should win)
let fm = FrontmatterData::default();
let meta_first = crate::frontmatter::parse_skill_fields(
&fm,
"",
"first-name",
SkillSource::User,
LoadedFrom::Skills,
None,
);
let meta_second = crate::frontmatter::parse_skill_fields(
&fm,
"",
"second-name",
SkillSource::User,
LoadedFrom::Skills,
None,
);
let skills = vec![
LoadedSkill {
metadata: meta_first,
resolved_path: canonical.clone(),
},
LoadedSkill {
metadata: meta_second,
resolved_path: canonical,
},
];
let result = deduplicate(skills);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "first-name", "first occurrence should win");
}
#[test]
fn tc_10_3_deduplicate_empty_input() {
let result = deduplicate(vec![]);
assert!(result.is_empty());
}
#[test]
fn tc_10_4_deduplicate_mixed_unique_and_duplicate() {
let tmp = TempDir::new().unwrap();
let f1 = tmp.path().join("a.md");
let f2 = tmp.path().join("b.md");
let f3 = tmp.path().join("c.md");
fs::write(&f1, "").unwrap();
fs::write(&f2, "").unwrap();
fs::write(&f3, "").unwrap();
let c1 = std::fs::canonicalize(&f1).unwrap();
let c2 = std::fs::canonicalize(&f2).unwrap();
let c3 = std::fs::canonicalize(&f3).unwrap();
// f1 appears twice, f2 appears twice, f3 appears once → 3 unique
let skills = vec![
make_loaded_skill(c1.clone(), "a1"),
make_loaded_skill(c1, "a2"), // duplicate of a1
make_loaded_skill(c2.clone(), "b1"),
make_loaded_skill(c2, "b2"), // duplicate of b1
make_loaded_skill(c3, "c1"),
];
let result = deduplicate(skills);
assert_eq!(result.len(), 3);
let names: Vec<_> = result.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"a1"));
assert!(names.contains(&"b1"));
assert!(names.contains(&"c1"));
}
// -----------------------------------------------------------------------
// TC-11.x: load_all_skills supplemental cases
// -----------------------------------------------------------------------
#[tokio::test]
async fn tc_11_1_bare_mode_only_loads_add_dirs() {
let user_tmp = TempDir::new().unwrap();
let add_tmp = TempDir::new().unwrap();
// Put a skill in add_dir's .nomi/skills/
let add_skills_dir = add_tmp.path().join(".nomi").join("skills");
fs::create_dir_all(&add_skills_dir).unwrap();
write_skill(&add_skills_dir, "add-skill/SKILL.md", "---\n---\n");
// Use a fake nonexistent cwd (bare should not need it)
let result = load_all_skills(
Path::new("/nonexistent_cwd_xyz"),
&[add_tmp.path().to_path_buf()],
true,
None,
)
.await;
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "add-skill");
// user_tmp was not consulted (no skills from there)
let _ = user_tmp;
}
#[tokio::test]
async fn tc_11_4_nonexistent_dirs_silently_skipped() {
let add_tmp = TempDir::new().unwrap();
let add_skills_dir = add_tmp.path().join(".nomi").join("skills");
fs::create_dir_all(&add_skills_dir).unwrap();
write_skill(&add_skills_dir, "extra/SKILL.md", "---\n---\n");
// cwd does not exist — no project skills loaded, no panic
let result = load_all_skills(
Path::new("/tmp/nonexistent_project_abc_xyz"),
&[add_tmp.path().to_path_buf()],
false,
None,
)
.await;
// Should load the add_dir skill; no panic
assert!(result.iter().any(|s| s.name == "extra"));
}
#[tokio::test]
async fn tc_11_5_empty_scenario_returns_empty_vec() {
// All dirs nonexistent, no add_dirs
let tmp = TempDir::new().unwrap();
// tmp exists but has no .nomi/skills
let result = load_all_skills(tmp.path(), &[], false, None).await;
// May have skills from user dir if it exists, but must not panic
let _ = result;
}
#[tokio::test]
async fn tc_11_6_empty_add_dirs_no_effect() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir(root.join(".git")).unwrap();
let skills_dir = root.join(".nomi").join("skills");
fs::create_dir_all(&skills_dir).unwrap();
write_skill(&skills_dir, "proj-skill/SKILL.md", "---\n---\n");
let result = load_all_skills(root, &[], false, None).await;
let names: Vec<_> = result.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"proj-skill"),
"project skill should load with empty add_dirs"
);
}
// -----------------------------------------------------------------------
// TC-8.8: skill_root semantic — must be skill_dir itself (the dir containing SKILL.md),
// not skill_dir's parent. This verifies the L-5 fix: skill_root = skill_dir,
// matching TS skillRoot used for ${NOMI_SKILL_DIR} substitution.
// -----------------------------------------------------------------------
#[tokio::test]
async fn tc_8_8_skill_root_is_skill_dir_not_parent() {
let tmp = TempDir::new().unwrap();
// Creates: /tmp/xxx/my-skill/SKILL.md
write_skill(tmp.path(), "my-skill/SKILL.md", "---\n---\n");
let skills = load_skills_from_dir(tmp.path(), SkillSource::User, LoadedFrom::Skills).await;
assert_eq!(skills.len(), 1);
// skill_root should be the skill's own directory (containing SKILL.md),
// not the base skills/ directory (the parent).
let expected_skill_dir = tmp.path().join("my-skill").to_string_lossy().into_owned();
assert_eq!(
skills[0].metadata.skill_root.as_deref(),
Some(expected_skill_dir.as_str()),
"skill_root should be the skill dir itself (containing SKILL.md), not its parent"
);
}
// -----------------------------------------------------------------------
// TC-WB: deduplicate_by_name (white-box tests for private function)
// -----------------------------------------------------------------------
#[test]
fn tc_wb_deduplicate_by_name_first_wins() {
// [白盒] TC-WB: deduplicate_by_name keeps first occurrence (first-wins semantic)
// Decision 6: HashMap<String, ()> with .insert().is_none() check
let fm = FrontmatterData::default();
let make_meta = |name: &str, source: SkillSource| {
crate::frontmatter::parse_skill_fields(&fm, "", name, source, LoadedFrom::Skills, None)
};
let skills = vec![
make_meta("my-skill", SkillSource::User), // first — should win
make_meta("my-skill", SkillSource::Project), // second — should be removed
make_meta("other-skill", SkillSource::User),
];
let result = deduplicate_by_name(skills);
assert_eq!(result.len(), 2);
assert_eq!(result[0].name, "my-skill");
assert_eq!(
result[0].source,
SkillSource::User,
"first occurrence (User) should win over Project"
);
assert_eq!(result[1].name, "other-skill");
}
#[test]
fn tc_wb_deduplicate_by_name_empty() {
// [白盒] empty input → empty output
let result = deduplicate_by_name(vec![]);
assert!(result.is_empty());
}
#[test]
fn tc_wb_deduplicate_by_name_all_unique() {
// [白盒] no duplicates — all preserved in order
let fm = FrontmatterData::default();
let make_meta = |name: &str| {
crate::frontmatter::parse_skill_fields(
&fm,
"",
name,
SkillSource::User,
LoadedFrom::Skills,
None,
)
};
let skills = vec![make_meta("a"), make_meta("b"), make_meta("c")];
let result = deduplicate_by_name(skills);
assert_eq!(result.len(), 3);
assert_eq!(result[0].name, "a");
assert_eq!(result[1].name, "b");
assert_eq!(result[2].name, "c");
}
#[test]
fn tc_wb_deduplicate_by_name_case_sensitive() {
// [白盒] name matching is case-sensitive — "Skill" and "skill" are different
let fm = FrontmatterData::default();
let make_meta = |name: &str| {
crate::frontmatter::parse_skill_fields(
&fm,
"",
name,
SkillSource::User,
LoadedFrom::Skills,
None,
)
};
let skills = vec![make_meta("Skill"), make_meta("skill")];
let result = deduplicate_by_name(skills);
assert_eq!(
result.len(),
2,
"case-sensitive: 'Skill' and 'skill' are distinct"
);
}
// -----------------------------------------------------------------------
// TC-4.x: load_all_skills MCP integration (white-box using McpManager::new_for_test)
// -----------------------------------------------------------------------
#[tokio::test]
async fn tc_4_5_mcp_manager_none_returns_no_mcp_skills() {
// [黑盒] TC-4.5: mcp_manager=None → no MCP skills in result
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir(root.join(".git")).unwrap();
let skills_dir = root.join(".nomi").join("skills");
fs::create_dir_all(&skills_dir).unwrap();
write_skill(
&skills_dir,
"local-skill/SKILL.md",
"---\ndescription: local\n---\n",
);
let result = load_all_skills(root, &[], false, None).await;
let names: Vec<_> = result.iter().map(|s| s.name.as_str()).collect();
// No skill with source=Mcp
for skill in &result {
assert_ne!(
skill.source,
crate::types::SkillSource::Mcp,
"mcp_manager=None should produce no MCP skills"
);
}
assert!(names.contains(&"local-skill"));
}
@@ -0,0 +1,301 @@
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_skill(dir: &Path, rel_path: &str, content: &str) {
let full = dir.join(rel_path);
fs::create_dir_all(full.parent().unwrap()).unwrap();
fs::write(full, content).unwrap();
}
// --- build_namespace ---
#[test]
fn test_build_namespace_simple() {
let base = Path::new("/skills");
let target = Path::new("/skills/my-skill");
assert_eq!(build_namespace(base, target), "my-skill");
}
#[test]
fn test_build_namespace_nested() {
let base = Path::new("/skills");
let target = Path::new("/skills/db/migrate");
assert_eq!(build_namespace(base, target), "db:migrate");
}
#[test]
fn test_build_namespace_three_levels() {
let base = Path::new("/skills");
let target = Path::new("/skills/a/b/c");
assert_eq!(build_namespace(base, target), "a:b:c");
}
#[test]
fn test_build_namespace_same_dir() {
let base = Path::new("/skills");
// target == base → empty string
let result = build_namespace(base, base);
assert_eq!(result, "");
}
// --- try_canonicalize ---
#[test]
fn test_try_canonicalize_existing_path() {
let tmp = TempDir::new().unwrap();
let result = try_canonicalize(tmp.path());
assert!(result.is_some());
}
#[test]
fn test_try_canonicalize_nonexistent_returns_none() {
let result = try_canonicalize(Path::new("/nonexistent/path/xyz"));
assert!(result.is_none());
}
// --- deduplicate ---
#[test]
fn test_deduplicate_removes_duplicates() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("skill.md");
fs::write(&file, "").unwrap();
let canonical = std::fs::canonicalize(&file).unwrap();
let fm = crate::types::FrontmatterData::default();
let make_meta = || {
crate::frontmatter::parse_skill_fields(
&fm,
"",
"test",
SkillSource::User,
LoadedFrom::Skills,
None,
)
};
let skills = vec![
LoadedSkill {
metadata: make_meta(),
resolved_path: canonical.clone(),
},
LoadedSkill {
metadata: make_meta(),
resolved_path: canonical.clone(),
},
];
let result = deduplicate(skills);
assert_eq!(result.len(), 1);
}
#[test]
fn test_deduplicate_different_paths_preserved() {
let tmp = TempDir::new().unwrap();
let file1 = tmp.path().join("skill1.md");
let file2 = tmp.path().join("skill2.md");
fs::write(&file1, "").unwrap();
fs::write(&file2, "").unwrap();
let fm = crate::types::FrontmatterData::default();
let make_meta = || {
crate::frontmatter::parse_skill_fields(
&fm,
"",
"test",
SkillSource::User,
LoadedFrom::Skills,
None,
)
};
let skills = vec![
LoadedSkill {
metadata: make_meta(),
resolved_path: std::fs::canonicalize(&file1).unwrap(),
},
LoadedSkill {
metadata: make_meta(),
resolved_path: std::fs::canonicalize(&file2).unwrap(),
},
];
let result = deduplicate(skills);
assert_eq!(result.len(), 2);
}
// --- load_skills_from_dir ---
#[tokio::test]
async fn test_load_skills_from_dir_basic() {
let tmp = TempDir::new().unwrap();
write_skill(
tmp.path(),
"my-skill/SKILL.md",
"---\nname: my-skill\ndescription: A test skill\n---\n# Body\n",
);
let skills = load_skills_from_dir(tmp.path(), SkillSource::User, LoadedFrom::Skills).await;
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].metadata.name, "my-skill");
}
#[tokio::test]
async fn test_load_skills_from_dir_nested_namespace() {
let tmp = TempDir::new().unwrap();
write_skill(
tmp.path(),
"db/migrate/SKILL.md",
"---\ndescription: Migrate DB\n---\n",
);
let skills = load_skills_from_dir(tmp.path(), SkillSource::User, LoadedFrom::Skills).await;
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].metadata.name, "db:migrate");
}
#[tokio::test]
async fn test_load_skills_from_dir_case_sensitive_skill_md() {
let tmp = TempDir::new().unwrap();
// Only lowercase "skill.md" — should NOT be loaded
write_skill(tmp.path(), "my-skill/skill.md", "---\n---\n# Body\n");
let skills = load_skills_from_dir(tmp.path(), SkillSource::User, LoadedFrom::Skills).await;
assert!(
skills.is_empty(),
"skill.md (lowercase) should not be loaded"
);
}
#[tokio::test]
async fn test_load_skills_from_dir_empty_dir() {
let tmp = TempDir::new().unwrap();
let skills = load_skills_from_dir(tmp.path(), SkillSource::User, LoadedFrom::Skills).await;
assert!(skills.is_empty());
}
#[tokio::test]
async fn test_load_skills_from_dir_nonexistent_silently_skipped() {
let skills = load_skills_from_dir(
Path::new("/nonexistent/path"),
SkillSource::User,
LoadedFrom::Skills,
)
.await;
assert!(skills.is_empty());
}
// --- load_skills_from_commands_dir ---
#[tokio::test]
async fn test_load_commands_directory_format() {
let tmp = TempDir::new().unwrap();
write_skill(
tmp.path(),
"my-cmd/SKILL.md",
"---\ndescription: A command\n---\n",
);
let skills = load_skills_from_commands_dir(tmp.path(), SkillSource::User).await;
assert_eq!(skills.len(), 1);
assert_eq!(
skills[0].metadata.loaded_from,
LoadedFrom::CommandsDeprecated
);
}
#[tokio::test]
async fn test_load_commands_flat_format() {
let tmp = TempDir::new().unwrap();
write_skill(tmp.path(), "simple.md", "---\ndescription: Simple\n---\n");
let skills = load_skills_from_commands_dir(tmp.path(), SkillSource::User).await;
assert_eq!(skills.len(), 1);
assert_eq!(
skills[0].metadata.loaded_from,
LoadedFrom::CommandsDeprecated
);
}
#[tokio::test]
async fn test_load_commands_dir_format_takes_precedence_over_flat() {
let tmp = TempDir::new().unwrap();
// Both my-cmd/SKILL.md and my-cmd.md exist — directory format wins
write_skill(
tmp.path(),
"my-cmd/SKILL.md",
"---\ndescription: Directory version\n---\n",
);
write_skill(
tmp.path(),
"my-cmd.md",
"---\ndescription: Flat version\n---\n",
);
let skills = load_skills_from_commands_dir(tmp.path(), SkillSource::User).await;
let descriptions: Vec<_> = skills
.iter()
.map(|s| s.metadata.description.as_str())
.collect();
assert!(
descriptions.contains(&"Directory version"),
"directory format should be loaded"
);
assert!(
!descriptions.contains(&"Flat version"),
"flat format should be skipped when directory exists"
);
}
#[tokio::test]
async fn test_load_commands_nested_flat() {
let tmp = TempDir::new().unwrap();
write_skill(
tmp.path(),
"db/migrate.md",
"---\ndescription: DB migrate\n---\n",
);
let skills = load_skills_from_commands_dir(tmp.path(), SkillSource::User).await;
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].metadata.name, "db:migrate");
}
// --- load_all_skills ---
#[tokio::test]
async fn test_load_all_skills_bare_mode() {
let tmp = TempDir::new().unwrap();
// Create .nomi/skills/ under the add_dir
let skills_dir = tmp.path().join(".nomi").join("skills");
fs::create_dir_all(&skills_dir).unwrap();
write_skill(&skills_dir, "my-skill/SKILL.md", "---\n---\n");
let result = load_all_skills(
Path::new("/nonexistent"),
&[tmp.path().to_owned()],
true,
None,
)
.await;
assert_eq!(result.len(), 1);
}
#[tokio::test]
async fn test_load_all_skills_deduplicates() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
// Create git root
fs::create_dir(root.join(".git")).unwrap();
// Create same skill in project dir (will appear twice due to walk)
let skills_dir = root.join(".nomi").join("skills");
fs::create_dir_all(&skills_dir).unwrap();
write_skill(&skills_dir, "my-skill/SKILL.md", "---\n---\n");
let result = load_all_skills(root, &[], false, None).await;
let names: Vec<_> = result.iter().map(|s| s.name.as_str()).collect();
let count = names.iter().filter(|&&n| n == "my-skill").count();
assert_eq!(count, 1, "skill should appear exactly once after dedup");
}
@@ -0,0 +1,478 @@
use std::path::PathBuf;
use crate::frontmatter::{parse_frontmatter, parse_skill_fields};
use crate::loader::LoadedSkill;
use crate::types::{LoadedFrom, SkillSource};
use nomi_mcp::manager::McpManager;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Discover and load skills from all connected MCP servers.
///
/// For each server that supports resources:
/// 1. Call resources/list
/// 2. Filter URIs starting with "skill://"
/// 3. Call resources/read for each skill resource
/// 4. Parse Markdown frontmatter → SkillMetadata
/// 5. Set source=Mcp, loaded_from=Mcp, name=<server>:<skill_name>
///
/// Individual resource or server failures are non-fatal: logged via eprintln
/// and skipped so that other servers/resources continue loading.
pub async fn load_mcp_skills(manager: &McpManager) -> Vec<LoadedSkill> {
let mut results = Vec::new();
for server_name in manager.server_names() {
if !manager.server_supports_resources(&server_name) {
continue;
}
let resources = match manager.list_resources(&server_name).await {
Ok(r) => r,
Err(e) => {
tracing::warn!(target: "nomi_skills", server = %server_name, error = %e, "failed to list mcp resources");
continue;
}
};
for resource in resources {
// Only handle skill:// URIs
if !resource.uri.starts_with("skill://") {
continue;
}
let text = match manager.read_resource(&server_name, &resource.uri).await {
Ok(t) => t,
Err(e) => {
tracing::warn!(target: "nomi_skills", server = %server_name, uri = %resource.uri, error = %e, "failed to read mcp resource");
continue;
}
};
let skill_name = uri_to_skill_name(&server_name, &resource.uri);
let parsed = parse_frontmatter(&text);
let metadata = parse_skill_fields(
&parsed.frontmatter,
&parsed.content,
&skill_name,
SkillSource::Mcp,
LoadedFrom::Mcp,
None, // MCP skills have no local skill_root directory
);
// Virtual path used for deduplication — never matches real filesystem paths
let virtual_path = PathBuf::from(format!("<mcp:{}>", skill_name));
results.push(LoadedSkill {
metadata,
resolved_path: virtual_path,
});
}
}
results
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Convert a skill:// URI and server name into a colon-separated skill name.
///
/// Examples:
/// - server="my-server", uri="skill://my-skill" → "my-server:my-skill"
/// - server="my-server", uri="skill://db/migrate" → "my-server:db:migrate"
fn uri_to_skill_name(server_name: &str, uri: &str) -> String {
let stripped = uri.strip_prefix("skill://").unwrap_or(uri);
// Replace path separators with colon-namespace separators
let name_part = stripped.replace('/', ":");
format!("{}:{}", server_name, name_part)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use nomi_mcp::manager::McpManager;
use nomi_mcp::protocol::{JsonRpcRequest, JsonRpcResponse};
use nomi_mcp::transport::{McpError, McpTransport};
use std::sync::Mutex;
// -----------------------------------------------------------------------
// MockTransport for mcp.rs tests
// -----------------------------------------------------------------------
struct MockTransport {
responses: Mutex<Vec<serde_json::Value>>,
}
impl MockTransport {
fn new(responses: Vec<serde_json::Value>) -> Self {
Self {
responses: Mutex::new(responses),
}
}
}
#[async_trait]
impl McpTransport for MockTransport {
async fn request(&self, _req: &JsonRpcRequest) -> Result<JsonRpcResponse, McpError> {
let mut guard = self.responses.lock().unwrap();
let value = if guard.is_empty() {
serde_json::json!(null)
} else {
guard.remove(0)
};
Ok(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: Some(1),
result: Some(value),
error: None,
})
}
async fn notify(&self, _req: &JsonRpcRequest) -> Result<(), McpError> {
Ok(())
}
async fn close(&self) -> Result<(), McpError> {
Ok(())
}
}
struct ErrorTransport;
#[async_trait]
impl McpTransport for ErrorTransport {
async fn request(&self, _req: &JsonRpcRequest) -> Result<JsonRpcResponse, McpError> {
Err(McpError::Transport("mock error".into()))
}
async fn notify(&self, _req: &JsonRpcRequest) -> Result<(), McpError> {
Ok(())
}
async fn close(&self) -> Result<(), McpError> {
Ok(())
}
}
fn make_list_response(uris: Vec<&str>) -> serde_json::Value {
let resources: Vec<_> = uris
.into_iter()
.map(|u| serde_json::json!({"uri": u}))
.collect();
serde_json::json!({"resources": resources})
}
fn make_read_response(text: &str) -> serde_json::Value {
serde_json::json!({
"contents": [{"uri": "skill://x", "mimeType": "text/plain", "text": text}]
})
}
// -----------------------------------------------------------------------
// TC-WB: uri_to_skill_name (private function — white-box inline tests)
// -----------------------------------------------------------------------
#[test]
fn tc_wb_uri_simple() {
// [白盒] skill://my-skill → server:my-skill
assert_eq!(
uri_to_skill_name("my-server", "skill://my-skill"),
"my-server:my-skill"
);
}
#[test]
fn tc_wb_uri_nested_one_slash() {
// [白盒] TC-3.4: skill://db/migrate → server:db:migrate
assert_eq!(
uri_to_skill_name("demo", "skill://db/migrate"),
"demo:db:migrate"
);
}
#[test]
fn tc_wb_uri_nested_two_slashes() {
// [白盒] TC-3.5: skill://a/b/c → server:a:b:c
assert_eq!(uri_to_skill_name("demo", "skill://a/b/c"), "demo:a:b:c");
}
#[test]
fn tc_wb_uri_no_skill_prefix_passthrough() {
// [白盒] strip_prefix returns uri unchanged when prefix not present;
// then replace('/', ':') is applied to the entire uri including "://"
// so "tool://something" → "tool:::something" after replace
assert_eq!(
uri_to_skill_name("srv", "tool://something"),
"srv:tool:::something"
);
}
#[test]
fn tc_wb_uri_empty_path_after_prefix() {
// [白盒] skill:// → server: (empty name part)
assert_eq!(uri_to_skill_name("srv", "skill://"), "srv:");
}
// -----------------------------------------------------------------------
// TC-3.x: load_mcp_skills [黑盒 + 白盒]
// -----------------------------------------------------------------------
#[tokio::test]
async fn tc_3_1_load_mcp_skills_normal() {
// [黑盒] TC-3.1: normal discovery — skill:// resource parsed to SkillMetadata
let list_resp = make_list_response(vec!["skill://my-skill"]);
let read_resp = make_read_response("---\ndescription: My MCP skill\n---\n# My MCP Skill\n");
let manager = McpManager::new_for_test(vec![(
"my-server",
true,
Box::new(MockTransport::new(vec![list_resp, read_resp])),
)]);
let results = load_mcp_skills(&manager).await;
assert_eq!(results.len(), 1);
let meta = &results[0].metadata;
assert_eq!(meta.name, "my-server:my-skill");
assert_eq!(meta.source, crate::types::SkillSource::Mcp);
assert_eq!(meta.loaded_from, crate::types::LoadedFrom::Mcp);
assert!(meta.skill_root.is_none());
}
#[tokio::test]
async fn tc_3_2_uri_filter_skips_non_skill_uris() {
// [黑盒] TC-3.2: only skill:// URIs processed — tool:// and file:// are skipped
let list_resp =
make_list_response(vec!["skill://valid-skill", "tool://other", "file://doc.md"]);
let read_resp = make_read_response("---\ndescription: Valid\n---\n");
let manager = McpManager::new_for_test(vec![(
"my-server",
true,
Box::new(MockTransport::new(vec![list_resp, read_resp])),
)]);
let results = load_mcp_skills(&manager).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].metadata.name, "my-server:valid-skill");
}
#[tokio::test]
async fn tc_3_3_naming_rule_simple() {
// [黑盒] TC-3.3: skill://my-skill → demo:my-skill
let list_resp = make_list_response(vec!["skill://my-skill"]);
let read_resp = make_read_response("---\ndescription: x\n---\n");
let manager = McpManager::new_for_test(vec![(
"demo",
true,
Box::new(MockTransport::new(vec![list_resp, read_resp])),
)]);
let results = load_mcp_skills(&manager).await;
assert_eq!(results[0].metadata.name, "demo:my-skill");
}
#[tokio::test]
async fn tc_3_4_naming_rule_slash_to_colon() {
// [黑盒] TC-3.4: skill://db/migrate → demo:db:migrate
let list_resp = make_list_response(vec!["skill://db/migrate"]);
let read_resp = make_read_response("---\ndescription: migrate\n---\n");
let manager = McpManager::new_for_test(vec![(
"demo",
true,
Box::new(MockTransport::new(vec![list_resp, read_resp])),
)]);
let results = load_mcp_skills(&manager).await;
assert_eq!(results[0].metadata.name, "demo:db:migrate");
}
#[tokio::test]
async fn tc_3_6_source_and_loaded_from_mcp() {
// [黑盒] TC-3.6/3.7: source=Mcp, loaded_from=Mcp, skill_root=None
let list_resp = make_list_response(vec!["skill://skill-x"]);
let read_resp = make_read_response("---\ndescription: x\n---\n");
let manager = McpManager::new_for_test(vec![(
"srv",
true,
Box::new(MockTransport::new(vec![list_resp, read_resp])),
)]);
let results = load_mcp_skills(&manager).await;
let meta = &results[0].metadata;
assert_eq!(meta.source, crate::types::SkillSource::Mcp);
assert_eq!(meta.loaded_from, crate::types::LoadedFrom::Mcp);
assert!(meta.skill_root.is_none());
}
#[tokio::test]
async fn tc_3_8_frontmatter_parsed() {
// [黑盒] TC-3.8: frontmatter fields properly parsed from MCP skill content
let list_resp = make_list_response(vec!["skill://test-skill"]);
let read_resp = make_read_response(
"---\ndescription: Test skill description\nallowed-tools: Bash\n---\n# Test\n",
);
let manager = McpManager::new_for_test(vec![(
"srv",
true,
Box::new(MockTransport::new(vec![list_resp, read_resp])),
)]);
let results = load_mcp_skills(&manager).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].metadata.description, "Test skill description");
assert!(results[0].metadata.has_user_specified_description);
}
#[tokio::test]
async fn tc_3_9_single_resource_failure_does_not_affect_others() {
// [黑盒] TC-3.9: when read_resource fails for one skill, others still load
// Use a transport where resources/list returns two skills, but second read errors
use std::sync::atomic::{AtomicUsize, Ordering};
struct PartialErrorTransport {
call_count: AtomicUsize,
}
#[async_trait::async_trait]
impl McpTransport for PartialErrorTransport {
async fn request(&self, _req: &JsonRpcRequest) -> Result<JsonRpcResponse, McpError> {
let count = self.call_count.fetch_add(1, Ordering::Relaxed);
match count {
0 => Ok(JsonRpcResponse {
// resources/list
jsonrpc: "2.0".to_string(),
id: Some(1),
result: Some(serde_json::json!({
"resources": [{"uri": "skill://good-skill"}, {"uri": "skill://bad-skill"}]
})),
error: None,
}),
1 => Ok(JsonRpcResponse {
// read good-skill
jsonrpc: "2.0".to_string(),
id: Some(2),
result: Some(serde_json::json!({
"contents": [{"uri": "skill://good-skill", "text": "---\ndescription: Good\n---\n"}]
})),
error: None,
}),
_ => Err(McpError::Transport("bad resource".into())),
}
}
async fn notify(&self, _req: &JsonRpcRequest) -> Result<(), McpError> {
Ok(())
}
async fn close(&self) -> Result<(), McpError> {
Ok(())
}
}
let manager = McpManager::new_for_test(vec![(
"srv",
true,
Box::new(PartialErrorTransport {
call_count: AtomicUsize::new(0),
}),
)]);
let results = load_mcp_skills(&manager).await;
// good-skill loaded, bad-skill skipped
assert_eq!(results.len(), 1);
assert_eq!(results[0].metadata.name, "srv:good-skill");
}
#[tokio::test]
async fn tc_3_10_server_list_failure_does_not_affect_other_servers() {
// [黑盒] TC-3.10: when list_resources fails for one server, other servers still load
let list_resp = make_list_response(vec!["skill://ok-skill"]);
let read_resp = make_read_response("---\ndescription: OK\n---\n");
let manager = McpManager::new_for_test(vec![
(
"server-ok",
true,
Box::new(MockTransport::new(vec![list_resp, read_resp])),
),
("server-fail", true, Box::new(ErrorTransport)),
]);
let results = load_mcp_skills(&manager).await;
// At least one skill from server-ok; server-fail's error is ignored
assert!(
results
.iter()
.any(|r| r.metadata.name == "server-ok:ok-skill")
);
}
#[tokio::test]
async fn tc_3_12_server_without_resources_capability_skipped() {
// [黑盒] TC-3.12: server without resources capability is not queried
let manager = McpManager::new_for_test(vec![(
"no-resources-server",
false, // does not support resources
Box::new(ErrorTransport), // would fail if called
)]);
let results = load_mcp_skills(&manager).await;
assert!(results.is_empty());
}
#[tokio::test]
async fn tc_3_13_multiple_servers_aggregated() {
// [黑盒] TC-3.13: skills from multiple servers all appear in results
let list_a = make_list_response(vec!["skill://x"]);
let read_a = make_read_response("---\ndescription: X\n---\n");
let list_b = make_list_response(vec!["skill://y", "skill://z"]);
let read_b1 = make_read_response("---\ndescription: Y\n---\n");
let read_b2 = make_read_response("---\ndescription: Z\n---\n");
let manager = McpManager::new_for_test(vec![
(
"server-a",
true,
Box::new(MockTransport::new(vec![list_a, read_a])),
),
(
"server-b",
true,
Box::new(MockTransport::new(vec![list_b, read_b1, read_b2])),
),
]);
let results = load_mcp_skills(&manager).await;
assert_eq!(results.len(), 3);
let names: Vec<_> = results.iter().map(|r| r.metadata.name.as_str()).collect();
assert!(names.contains(&"server-a:x"));
assert!(names.contains(&"server-b:y"));
assert!(names.contains(&"server-b:z"));
}
#[tokio::test]
async fn tc_3_wb_virtual_path_format() {
// [白盒] MCP skill virtual path is "<mcp:server:name>" for deduplication
let list_resp = make_list_response(vec!["skill://my-skill"]);
let read_resp = make_read_response("---\ndescription: x\n---\n");
let manager = McpManager::new_for_test(vec![(
"srv",
true,
Box::new(MockTransport::new(vec![list_resp, read_resp])),
)]);
let results = load_mcp_skills(&manager).await;
assert_eq!(results.len(), 1);
let path_str = results[0].resolved_path.to_string_lossy();
assert_eq!(path_str, "<mcp:srv:my-skill>");
}
}
@@ -0,0 +1,450 @@
use std::path::{Path, PathBuf};
use nomi_config::config::app_config_dir;
// ---------------------------------------------------------------------------
// User-level directories (<config_dir>/nomi/)
// ---------------------------------------------------------------------------
/// Return the user-level skills directory: `<config_dir>/nomi/skills/`
///
/// Returns `None` if the platform config directory cannot be determined.
pub fn user_skills_dir() -> Option<PathBuf> {
app_config_dir().map(|d| d.join("skills"))
}
/// Return the user-level legacy commands directory: `<config_dir>/nomi/commands/`
pub fn user_commands_dir() -> Option<PathBuf> {
app_config_dir().map(|d| d.join("commands"))
}
// ---------------------------------------------------------------------------
// Project-level directories (walk up from cwd)
// ---------------------------------------------------------------------------
/// Find all project-level `.nomi/skills/` directories by walking up from
/// `cwd` to the nearest git root (or home directory), returning deepest-first.
///
/// Deepest-first means the most-specific project directory wins in the
/// priority ordering (closer to cwd = higher priority).
pub fn project_skills_dirs(cwd: &Path) -> Vec<PathBuf> {
walk_up_dirs(cwd, "skills")
}
/// Find all project-level `.nomi/commands/` directories (legacy), same walk.
pub fn project_commands_dirs(cwd: &Path) -> Vec<PathBuf> {
walk_up_dirs(cwd, "commands")
}
/// Resolve additional skill directories from `--add-dir` paths.
///
/// Each path in `add_dirs` is checked for a `.nomi/skills/` subdirectory.
/// Only directories that exist are included.
pub fn additional_skills_dirs(add_dirs: &[PathBuf]) -> Vec<PathBuf> {
add_dirs
.iter()
.map(|d| d.join(".nomi").join("skills"))
.filter(|p| p.is_dir())
.collect()
}
// ---------------------------------------------------------------------------
// Git root detection
// ---------------------------------------------------------------------------
/// Find the nearest git root from `start` by walking up looking for a `.git`
/// entry (file or directory). Returns `None` if no `.git` is found before
/// reaching the filesystem root.
pub fn find_git_root(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
loop {
if current.join(".git").exists() {
return Some(current);
}
match current.parent() {
Some(parent) if parent != current => current = parent.to_path_buf(),
_ => return None,
}
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Walk up from `cwd` to the git root (or home directory), collecting all
/// `.nomi/<subdir>/` directories that exist. Returns deepest-first.
fn walk_up_dirs(cwd: &Path, subdir: &str) -> Vec<PathBuf> {
let stop_at = stop_boundary(cwd);
let mut dirs = Vec::new();
let mut current = cwd.to_path_buf();
loop {
let candidate = current.join(".nomi").join(subdir);
if candidate.is_dir() {
dirs.push(candidate);
}
// Stop if we've reached the boundary or the filesystem root
if Some(&current) == stop_at.as_ref() || current.parent().is_none() {
break;
}
match current.parent() {
Some(parent) if parent != current.as_path() => {
current = parent.to_path_buf();
}
_ => break,
}
}
dirs
}
/// Determine where to stop walking up. Stops at git root if found,
/// otherwise at the user home directory.
pub fn stop_boundary(cwd: &Path) -> Option<PathBuf> {
find_git_root(cwd).or_else(dirs::home_dir)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn make_dir(base: &Path, rel: &str) -> PathBuf {
let p = base.join(rel);
fs::create_dir_all(&p).unwrap();
p
}
// --- user_skills_dir ---
#[test]
fn test_user_skills_dir_contains_nomi_skills() {
if let Some(dir) = user_skills_dir() {
let s = dir.to_string_lossy();
assert!(s.contains("nomi"), "expected 'nomi' in path: {s}");
assert!(
s.ends_with("skills"),
"expected path to end with 'skills': {s}"
);
}
// If app_config_dir() returns None (rare), that's acceptable.
}
#[test]
fn test_user_commands_dir_contains_nomi_commands() {
if let Some(dir) = user_commands_dir() {
let s = dir.to_string_lossy();
assert!(s.contains("nomi"));
assert!(s.ends_with("commands"));
}
}
// --- find_git_root ---
#[test]
fn test_find_git_root_finds_git_dir() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let nested = root.join("a").join("b").join("c");
fs::create_dir_all(&nested).unwrap();
fs::create_dir(root.join(".git")).unwrap();
let found = find_git_root(&nested).unwrap();
assert_eq!(found, root);
}
#[test]
fn test_find_git_root_returns_none_when_absent() {
let tmp = TempDir::new().unwrap();
// No .git anywhere under tmp
let result = find_git_root(tmp.path());
// May or may not find a .git in an ancestor of tmp — we just ensure no panic.
// If the test environment has a .git above tmp, that's ok.
let _ = result;
}
#[test]
fn test_find_git_root_at_root_itself() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".git")).unwrap();
let found = find_git_root(tmp.path()).unwrap();
assert_eq!(found, tmp.path());
}
// --- project_skills_dirs ---
#[test]
fn test_project_skills_dirs_finds_dirs() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
// Create git root marker
fs::create_dir(root.join(".git")).unwrap();
// Create skills dirs at root and nested level
make_dir(root, ".nomi/skills");
let nested = root.join("sub").join("project");
fs::create_dir_all(&nested).unwrap();
make_dir(&nested, ".nomi/skills");
let dirs = project_skills_dirs(&nested);
// Should find both (deepest first)
assert_eq!(dirs.len(), 2);
// First one is deeper (closest to cwd)
assert!(dirs[0].starts_with(&nested));
assert!(dirs[1].starts_with(root));
}
#[test]
fn test_project_skills_dirs_skips_missing() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".git")).unwrap();
// No .nomi/skills/ anywhere
let dirs = project_skills_dirs(tmp.path());
assert!(dirs.is_empty());
}
// --- additional_skills_dirs ---
#[test]
fn test_additional_skills_dirs_existing() {
let tmp = TempDir::new().unwrap();
make_dir(tmp.path(), ".nomi/skills");
let result = additional_skills_dirs(&[tmp.path().to_path_buf()]);
assert_eq!(result.len(), 1);
}
#[test]
fn test_additional_skills_dirs_missing_silently_skipped() {
let tmp = TempDir::new().unwrap();
// No .nomi/skills/ under tmp
let result = additional_skills_dirs(&[tmp.path().to_path_buf()]);
assert!(result.is_empty());
}
#[test]
fn test_additional_skills_dirs_empty_input() {
let result = additional_skills_dirs(&[]);
assert!(result.is_empty());
}
}
// ---------------------------------------------------------------------------
// Supplemental tests (tester role — covers test-plan.md cases not in impl tests)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod supplemental_tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn make_dir(base: &Path, rel: &str) -> PathBuf {
let p = base.join(rel);
fs::create_dir_all(&p).unwrap();
p
}
// -----------------------------------------------------------------------
// TC-1.x: find_git_root
// -----------------------------------------------------------------------
#[test]
fn tc_1_1_find_git_root_at_root_dir() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".git")).unwrap();
let found = find_git_root(tmp.path()).unwrap();
assert_eq!(found, tmp.path());
}
#[test]
fn tc_1_2_find_git_root_from_subdirectory() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir(root.join(".git")).unwrap();
let sub = root.join("src").join("module");
fs::create_dir_all(&sub).unwrap();
let found = find_git_root(&sub).unwrap();
assert_eq!(found, root);
}
#[test]
fn tc_1_4_find_git_root_deep_nesting() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir(root.join(".git")).unwrap();
let deep = root.join("a").join("b").join("c").join("d").join("e");
fs::create_dir_all(&deep).unwrap();
let found = find_git_root(&deep).unwrap();
assert_eq!(found, root);
}
#[test]
fn tc_1_5_find_git_root_git_is_file_not_dir() {
// git worktree: .git is a file, not a directory
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join(".git"), "gitdir: ../main/.git/worktrees/wt").unwrap();
// Implementation uses .exists() which is true for both files and dirs
let found = find_git_root(root);
assert!(
found.is_some(),
".git file should be recognized as git root"
);
assert_eq!(found.unwrap(), root);
}
// -----------------------------------------------------------------------
// TC-2.x / TC-3.x: user_skills_dir / user_commands_dir
// -----------------------------------------------------------------------
#[test]
fn tc_2_1_user_skills_dir_ends_with_skills() {
if let Some(dir) = user_skills_dir() {
let s = dir.to_string_lossy();
assert!(s.ends_with("skills"), "path should end with 'skills': {s}");
assert!(s.contains("nomi"), "path should contain 'nomi': {s}");
}
}
#[test]
fn tc_3_1_user_commands_dir_ends_with_commands() {
if let Some(dir) = user_commands_dir() {
let s = dir.to_string_lossy();
assert!(
s.ends_with("commands"),
"path should end with 'commands': {s}"
);
assert!(s.contains("nomi"), "path should contain 'nomi': {s}");
}
}
// -----------------------------------------------------------------------
// TC-4.x: project_skills_dirs
// -----------------------------------------------------------------------
#[test]
fn tc_4_2_project_skills_dirs_nonexistent_subdir_not_returned() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".git")).unwrap();
// No .nomi/skills/ created
let dirs = project_skills_dirs(tmp.path());
assert!(
dirs.is_empty(),
"should be empty when .nomi/skills/ doesn't exist"
);
}
#[test]
fn tc_4_3_project_skills_dirs_deepest_first() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir(root.join(".git")).unwrap();
make_dir(root, ".nomi/skills");
let inner = root.join("sub");
fs::create_dir_all(&inner).unwrap();
make_dir(&inner, ".nomi/skills");
let dirs = project_skills_dirs(&inner);
assert_eq!(dirs.len(), 2);
// First element should be closest to cwd (deepest)
assert!(
dirs[0].starts_with(&inner),
"first dir should be the inner one (deepest): {:?}",
dirs[0]
);
}
#[test]
fn tc_4_4_project_skills_dirs_stops_at_git_root() {
let tmp = TempDir::new().unwrap();
let grandparent = tmp.path();
// .nomi/skills in grandparent (above git root) — should NOT be collected
make_dir(grandparent, ".nomi/skills");
let repo = grandparent.join("repo");
fs::create_dir_all(&repo).unwrap();
fs::create_dir(repo.join(".git")).unwrap();
make_dir(&repo, ".nomi/skills");
let sub = repo.join("sub");
fs::create_dir_all(&sub).unwrap();
let dirs = project_skills_dirs(&sub);
// Only repo's .nomi/skills should be included
assert!(
dirs.iter().all(|d| d.starts_with(&repo)),
"should not include dirs above git root, got: {dirs:?}"
);
assert_eq!(dirs.len(), 1);
}
#[test]
fn tc_4_6_project_skills_dirs_nonexistent_cwd_no_panic() {
// Should not panic even if cwd does not exist
let dirs = project_skills_dirs(Path::new("/tmp/nonexistent_cwd_xyz_abc_123"));
// Result may be empty or not (depends on ancestor dirs) — just must not panic
let _ = dirs;
}
// -----------------------------------------------------------------------
// TC-5.x: project_commands_dirs
// -----------------------------------------------------------------------
#[test]
fn tc_5_1_project_commands_dirs_finds_commands_dir() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir(root.join(".git")).unwrap();
make_dir(root, ".nomi/commands");
let dirs = project_commands_dirs(root);
assert_eq!(dirs.len(), 1);
assert!(dirs[0].ends_with(".nomi/commands"));
}
// -----------------------------------------------------------------------
// TC-6.x: additional_skills_dirs
// -----------------------------------------------------------------------
#[test]
fn tc_6_1_additional_skills_dirs_with_existing_subdir() {
let tmp = TempDir::new().unwrap();
make_dir(tmp.path(), ".nomi/skills");
let result = additional_skills_dirs(&[tmp.path().to_path_buf()]);
assert_eq!(result.len(), 1);
assert!(result[0].ends_with(".nomi/skills"));
}
#[test]
fn tc_6_2_additional_skills_dirs_no_subdir_skipped() {
let tmp = TempDir::new().unwrap();
// No .nomi/skills/ subdirectory
let result = additional_skills_dirs(&[tmp.path().to_path_buf()]);
assert!(result.is_empty());
}
#[test]
fn tc_6_4_additional_skills_dirs_multiple_add_dirs() {
let tmp1 = TempDir::new().unwrap();
let tmp2 = TempDir::new().unwrap();
make_dir(tmp1.path(), ".nomi/skills");
make_dir(tmp2.path(), ".nomi/skills");
let result =
additional_skills_dirs(&[tmp1.path().to_path_buf(), tmp2.path().to_path_buf()]);
assert_eq!(result.len(), 2);
}
}
@@ -0,0 +1,332 @@
use crate::types::SkillMetadata;
/// A parsed permission rule for skill name matching.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionRule {
/// Exact name match: `"commit"` matches only `"commit"`.
Exact(String),
/// Prefix match with trailing colon: `"db:*"` is stored as `Prefix("db:")`.
/// Stored WITH the colon to prevent `"db:*"` from matching `"database"`.
Prefix(String),
}
impl PermissionRule {
/// Parse a rule string.
/// - `"db:*"` → `Prefix("db:")` (trailing `*` stripped, colon kept)
/// - `"commit"` → `Exact("commit")`
pub fn parse(rule: &str) -> Self {
if let Some(prefix) = rule.strip_suffix('*') {
PermissionRule::Prefix(prefix.to_string())
} else {
PermissionRule::Exact(rule.to_string())
}
}
/// Returns true if this rule matches the given skill name.
pub fn matches(&self, name: &str) -> bool {
match self {
PermissionRule::Exact(s) => s == name,
PermissionRule::Prefix(p) => name.starts_with(p.as_str()),
}
}
}
/// Result of a skill permission check.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillPermission {
/// Skill is allowed to execute.
Allow,
/// Skill is denied by configuration (always blocks, even with auto_approve).
Deny,
/// Skill requires user confirmation before execution.
Ask { reason: String },
}
/// Checks whether a specific skill is allowed to execute.
///
/// Decision chain (evaluated in order):
/// 1. deny rules → `Deny` (always enforced, even when `auto_approve = true`)
/// 2. allow rules → `Allow`
/// 3. safe-properties: `hooks_raw.is_none() && allowed_tools.is_empty()` → `Allow`
/// 4. `auto_approve` flag → `Allow` (converts what would be `Ask` into `Allow`)
/// 5. fallback → `Ask { reason }`
pub struct SkillPermissionChecker {
deny_rules: Vec<PermissionRule>,
allow_rules: Vec<PermissionRule>,
/// When true, Step 4 converts Ask → Allow (but does not bypass Deny).
auto_approve: bool,
}
impl SkillPermissionChecker {
/// Create a checker from config deny/allow string lists.
pub fn new(deny: Vec<String>, allow: Vec<String>, auto_approve: bool) -> Self {
Self {
deny_rules: deny.iter().map(|s| PermissionRule::parse(s)).collect(),
allow_rules: allow.iter().map(|s| PermissionRule::parse(s)).collect(),
auto_approve,
}
}
/// Run the 5-step permission decision chain.
pub fn check(&self, skill: &SkillMetadata) -> SkillPermission {
let name = &skill.name;
// Step 1: deny rules always win.
if self.deny_rules.iter().any(|r| r.matches(name)) {
return SkillPermission::Deny;
}
// Step 2: explicit allow.
if self.allow_rules.iter().any(|r| r.matches(name)) {
return SkillPermission::Allow;
}
// Step 3: safe-properties.
// Note: hooks_raw is Option<serde_json::Value> (None check),
// allowed_tools is Vec<String> (is_empty check). The two differ by design.
let is_safe = skill.hooks_raw.is_none() && skill.allowed_tools.is_empty();
if is_safe {
return SkillPermission::Allow;
}
// Step 4: auto_approve converts Ask → Allow.
if self.auto_approve {
return SkillPermission::Allow;
}
// Step 5: require user confirmation.
let reason = build_ask_reason(skill);
SkillPermission::Ask { reason }
}
}
/// Build a human-readable reason string for why a skill needs confirmation.
fn build_ask_reason(skill: &SkillMetadata) -> String {
match (skill.hooks_raw.is_some(), !skill.allowed_tools.is_empty()) {
(true, true) => format!(
"Skill '{}' declares hooks and allowed-tools which grant elevated privileges.",
skill.name
),
(true, false) => format!(
"Skill '{}' declares hooks which may run arbitrary shell commands.",
skill.name
),
(false, true) => format!(
"Skill '{}' declares allowed-tools ({}) which grant elevated tool access.",
skill.name,
skill.allowed_tools.join(", ")
),
(false, false) => {
// Should not reach here (safe-properties would have allowed), but be defensive.
format!("Skill '{}' requires user approval.", skill.name)
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ExecutionContext, LoadedFrom, SkillMetadata, SkillSource};
fn make_skill(name: &str) -> SkillMetadata {
SkillMetadata {
name: name.to_string(),
display_name: None,
description: String::new(),
has_user_specified_description: false,
allowed_tools: vec![],
argument_hint: None,
argument_names: vec![],
when_to_use: None,
version: None,
model: None,
disable_model_invocation: false,
user_invocable: true,
execution_context: ExecutionContext::Inline,
agent: None,
effort: None,
shell: None,
paths: vec![],
hooks_raw: None,
source: SkillSource::User,
loaded_from: LoadedFrom::Skills,
content: String::new(),
content_length: 0,
skill_root: None,
}
}
// P5-1: parse exact match
#[test]
fn p5_1_parse_exact() {
let rule = PermissionRule::parse("commit");
assert_eq!(rule, PermissionRule::Exact("commit".to_string()));
assert!(rule.matches("commit"));
assert!(!rule.matches("commit-all"));
}
// P5-2: parse prefix match
#[test]
fn p5_2_parse_prefix() {
let rule = PermissionRule::parse("db:*");
assert_eq!(rule, PermissionRule::Prefix("db:".to_string()));
assert!(rule.matches("db:migrate"));
assert!(rule.matches("db:seed"));
assert!(!rule.matches("database"));
}
// P5-3: deny rule blocks skill
#[test]
fn p5_3_deny_blocks_skill() {
let checker = SkillPermissionChecker::new(vec!["dangerous".to_string()], vec![], false);
let skill = make_skill("dangerous");
assert_eq!(checker.check(&skill), SkillPermission::Deny);
}
// P5-4: allow rule passes skill
#[test]
fn p5_4_allow_passes_skill() {
let mut skill = make_skill("commit");
// Give it hooks so safe-properties wouldn't fire
skill.hooks_raw = Some(serde_json::json!({}));
let checker = SkillPermissionChecker::new(vec![], vec!["commit".to_string()], false);
assert_eq!(checker.check(&skill), SkillPermission::Allow);
}
// P5-5: deny takes priority over allow
#[test]
fn p5_5_deny_over_allow() {
let mut skill = make_skill("commit");
skill.hooks_raw = Some(serde_json::json!({}));
let checker = SkillPermissionChecker::new(
vec!["commit".to_string()],
vec!["commit".to_string()],
false,
);
assert_eq!(checker.check(&skill), SkillPermission::Deny);
}
// P5-6: no hooks, no allowed_tools → Allow (safe-properties)
#[test]
fn p5_6_safe_properties_allow() {
let checker = SkillPermissionChecker::new(vec![], vec![], false);
let skill = make_skill("read-only");
assert_eq!(checker.check(&skill), SkillPermission::Allow);
}
// P5-7: has hooks → Ask
#[test]
fn p5_7_hooks_require_ask() {
let mut skill = make_skill("hooked");
skill.hooks_raw = Some(serde_json::json!({ "pre": "echo hi" }));
let checker = SkillPermissionChecker::new(vec![], vec![], false);
assert!(matches!(checker.check(&skill), SkillPermission::Ask { .. }));
}
// P5-8: has allowed_tools → Ask
#[test]
fn p5_8_allowed_tools_require_ask() {
let mut skill = make_skill("tooled");
skill.allowed_tools = vec!["Bash".to_string()];
let checker = SkillPermissionChecker::new(vec![], vec![], false);
assert!(matches!(checker.check(&skill), SkillPermission::Ask { .. }));
}
// P5-9: no rule match + has hooks → Ask
#[test]
fn p5_9_no_match_with_hooks_ask() {
let mut skill = make_skill("unknown");
skill.hooks_raw = Some(serde_json::json!({}));
let checker = SkillPermissionChecker::new(
vec!["other".to_string()],
vec!["other".to_string()],
false,
);
assert!(matches!(checker.check(&skill), SkillPermission::Ask { .. }));
}
// P5-10: auto_approve converts Ask → Allow (but deny still blocks)
#[test]
fn p5_10_auto_approve_allows_but_not_deny() {
let mut skill_hooked = make_skill("hooked");
skill_hooked.hooks_raw = Some(serde_json::json!({}));
let mut skill_denied = make_skill("denied");
skill_denied.hooks_raw = Some(serde_json::json!({}));
let checker = SkillPermissionChecker::new(
vec!["denied".to_string()],
vec![],
true, // auto_approve
);
// hooked skill: would be Ask, but auto_approve converts to Allow
assert_eq!(checker.check(&skill_hooked), SkillPermission::Allow);
// denied skill: deny always wins
assert_eq!(checker.check(&skill_denied), SkillPermission::Deny);
}
// P5-13: prefix boundary — "db:*" does not match "database"
#[test]
fn p5_13_prefix_boundary() {
let rule = PermissionRule::parse("db:*");
assert!(!rule.matches("database"));
assert!(!rule.matches("db"));
assert!(rule.matches("db:migrate"));
assert!(rule.matches("db:"));
}
// P5-15: empty deny/allow → all go through safe-properties
#[test]
fn p5_15_empty_rules_safe_properties() {
let checker = SkillPermissionChecker::new(vec![], vec![], false);
// Safe skill (no hooks, no allowed_tools) → Allow
let safe = make_skill("safe");
assert_eq!(checker.check(&safe), SkillPermission::Allow);
// Unsafe skill (has hooks) → Ask
let mut unsafe_skill = make_skill("unsafe");
unsafe_skill.hooks_raw = Some(serde_json::json!({}));
assert!(matches!(
checker.check(&unsafe_skill),
SkillPermission::Ask { .. }
));
}
// Reason string mentions hooks
#[test]
fn ask_reason_mentions_hooks() {
let mut skill = make_skill("hooked");
skill.hooks_raw = Some(serde_json::json!({}));
let checker = SkillPermissionChecker::new(vec![], vec![], false);
if let SkillPermission::Ask { reason } = checker.check(&skill) {
assert!(
reason.contains("hooks"),
"reason should mention hooks: {reason}"
);
} else {
panic!("expected Ask");
}
}
// Reason string mentions allowed-tools
#[test]
fn ask_reason_mentions_allowed_tools() {
let mut skill = make_skill("tooled");
skill.allowed_tools = vec!["Bash".to_string()];
let checker = SkillPermissionChecker::new(vec![], vec![], false);
if let SkillPermission::Ask { reason } = checker.check(&skill) {
assert!(
reason.contains("allowed-tools") || reason.contains("Bash"),
"reason should mention tool: {reason}"
);
} else {
panic!("expected Ask");
}
}
}
@@ -0,0 +1,90 @@
// Supplemental tests for Phase 5 permission system.
// Covers test-plan.md cases not present in the existing impl tests:
// TC-P5-21: prefix deny rule matches all skills in a namespace, but not bare names without colon
// TC-P5-22: PermissionRule::parse("") does not panic — treats empty string as Exact
// TC-P5-23: PermissionRule::parse(":*") does not panic — Prefix with empty prefix
#[cfg(test)]
#[allow(clippy::module_inception)]
mod permissions_supplemental_tests {
use crate::permissions::{PermissionRule, SkillPermission, SkillPermissionChecker};
use crate::types::{ExecutionContext, LoadedFrom, SkillMetadata, SkillSource};
fn make_skill(name: &str) -> SkillMetadata {
SkillMetadata {
name: name.to_string(),
display_name: None,
description: String::new(),
has_user_specified_description: false,
allowed_tools: vec![],
argument_hint: None,
argument_names: vec![],
when_to_use: None,
version: None,
model: None,
disable_model_invocation: false,
user_invocable: true,
execution_context: ExecutionContext::Inline,
agent: None,
effort: None,
shell: None,
paths: vec![],
hooks_raw: None,
source: SkillSource::User,
loaded_from: LoadedFrom::Skills,
content: String::new(),
content_length: 0,
skill_root: None,
}
}
// TC-P5-21: prefix deny rule blocks all skills in a namespace,
// but safe skills whose names lack the colon-prefix are still allowed.
#[test]
fn tc_p5_21_prefix_deny_blocks_namespace_but_not_bare_names() {
let checker = SkillPermissionChecker::new(vec!["admin:*".to_string()], vec![], false);
// "admin:create-user" matches "admin:*" → Deny
let create_user = make_skill("admin:create-user");
assert_eq!(checker.check(&create_user), SkillPermission::Deny);
// "admin:delete-all" matches "admin:*" → Deny
let delete_all = make_skill("admin:delete-all");
assert_eq!(checker.check(&delete_all), SkillPermission::Deny);
// "admins" does NOT match "admin:*" (no colon separator) and has no hooks/tools → Allow
let admins = make_skill("admins");
assert_eq!(checker.check(&admins), SkillPermission::Allow);
// "admin" alone does NOT match "admin:*" → Allow via safe-properties
let admin = make_skill("admin");
assert_eq!(checker.check(&admin), SkillPermission::Allow);
}
// TC-P5-22: parse("") does not panic.
// An empty rule string contains no ":*" suffix, so it becomes Exact("").
#[test]
fn tc_p5_22_parse_empty_string_does_not_panic() {
let rule = PermissionRule::parse("");
// Should produce an Exact rule with empty string
assert_eq!(rule, PermissionRule::Exact("".to_string()));
// Exact("") only matches the empty-string name
assert!(rule.matches(""));
assert!(!rule.matches("anything"));
}
// TC-P5-23: parse(":*") does not panic.
// ":*" ends with "*", so strip_suffix('*') leaves ":", stored as Prefix(":").
// Behaviour: matches any name that starts_with(":") — unusual but must not panic.
#[test]
fn tc_p5_23_parse_colon_star_does_not_panic() {
let rule = PermissionRule::parse(":*");
// Should produce a Prefix rule; the exact stored value is ":"
assert_eq!(rule, PermissionRule::Prefix(":".to_string()));
// A name starting with ":" would match
assert!(rule.matches(":something"));
// An ordinary name without leading ":" does not match
assert!(!rule.matches("something"));
assert!(!rule.matches(""));
}
}
@@ -0,0 +1,574 @@
use unicode_width::UnicodeWidthStr;
use crate::types::{SkillMetadata, SkillSource};
// Skill listing gets 1% of the context window (in characters)
pub const SKILL_BUDGET_CONTEXT_PERCENT: f64 = 0.01;
pub const CHARS_PER_TOKEN: usize = 4;
pub const DEFAULT_CHAR_BUDGET: usize = 8_000; // Fallback: 1% of 200k × 4
pub const MAX_LISTING_DESC_CHARS: usize = 250;
const MIN_DESC_LENGTH: usize = 20;
/// Calculate character budget from context window size.
pub fn get_char_budget(context_window_tokens: Option<usize>) -> usize {
match context_window_tokens {
Some(tokens) => {
((tokens as f64) * (CHARS_PER_TOKEN as f64) * SKILL_BUDGET_CONTEXT_PERCENT) as usize
}
None => DEFAULT_CHAR_BUDGET,
}
}
/// Format a skill's combined description string (description + when_to_use),
/// truncated to MAX_LISTING_DESC_CHARS.
pub fn format_skill_description(skill: &SkillMetadata) -> String {
let desc = match &skill.when_to_use {
Some(wtu) if !wtu.is_empty() => format!("{} - {}", skill.description, wtu),
_ => skill.description.clone(),
};
if UnicodeWidthStr::width(desc.as_str()) > MAX_LISTING_DESC_CHARS {
let mut truncated = String::new();
let mut width = 0usize;
for ch in desc.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if width + cw >= MAX_LISTING_DESC_CHARS {
break;
}
truncated.push(ch);
width += cw;
}
truncated.push('\u{2026}');
truncated
} else {
desc
}
}
/// Format a single skill entry for the listing: `- name: description`.
pub fn format_skill_entry(skill: &SkillMetadata) -> String {
format!("- {}: {}", skill.name, format_skill_description(skill))
}
/// Format all skills within budget, applying three-level degradation.
///
/// Levels:
/// 1. Full mode: all skills with full descriptions
/// 2. Truncated mode: bundled skills full, non-bundled descriptions trimmed
/// 3. Minimal mode: bundled skills full, non-bundled names only
pub fn format_skills_within_budget(
skills: &[SkillMetadata],
context_window_tokens: Option<usize>,
) -> String {
if skills.is_empty() {
return String::new();
}
let budget = get_char_budget(context_window_tokens);
// Build full entries for all skills
let full_entries: Vec<String> = skills.iter().map(format_skill_entry).collect();
// join('\n') produces N-1 newlines for N entries
let full_total: usize = full_entries
.iter()
.map(|e| UnicodeWidthStr::width(e.as_str()))
.sum::<usize>()
+ full_entries.len().saturating_sub(1);
// Level 1: full mode
if full_total <= budget {
return full_entries.join("\n");
}
// Partition into bundled and non-bundled
let mut bundled_indices: Vec<usize> = Vec::new();
let mut rest_indices: Vec<usize> = Vec::new();
for (i, skill) in skills.iter().enumerate() {
if skill.source == SkillSource::Bundled {
bundled_indices.push(i);
} else {
rest_indices.push(i);
}
}
// C-5: if no non-bundled skills, return all bundled full entries
if rest_indices.is_empty() {
return full_entries.join("\n");
}
// Compute space used by bundled skills (full descriptions, always preserved)
// +1 per bundled entry accounts for the trailing newline separator
let bundled_chars: usize = bundled_indices
.iter()
.map(|&i| UnicodeWidthStr::width(full_entries[i].as_str()) + 1)
.sum();
let remaining_budget = budget.saturating_sub(bundled_chars);
// name_overhead = Σ (name.len() + 4) for each non-bundled skill
// where 4 = "- " (2) + ": " (2) prefix/suffix
// plus (rest_count - 1) newline separators between non-bundled entries
let rest_name_overhead: usize = rest_indices
.iter()
.map(|&i| UnicodeWidthStr::width(skills[i].name.as_str()) + 4)
.sum::<usize>()
+ rest_indices.len().saturating_sub(1);
let available_for_descs = remaining_budget.saturating_sub(rest_name_overhead);
let per_desc_budget = available_for_descs / rest_indices.len();
// Level 3: minimal mode — non-bundled show names only
if per_desc_budget < MIN_DESC_LENGTH {
return skills
.iter()
.enumerate()
.map(|(i, skill)| {
if skill.source == SkillSource::Bundled {
full_entries[i].clone()
} else {
format!("- {}", skill.name)
}
})
.collect::<Vec<_>>()
.join("\n");
}
// Level 2: truncated mode — non-bundled descriptions trimmed to per_desc_budget
skills
.iter()
.enumerate()
.map(|(i, skill)| {
if skill.source == SkillSource::Bundled {
return full_entries[i].clone();
}
let desc = format_skill_description(skill);
let trimmed = if UnicodeWidthStr::width(desc.as_str()) > per_desc_budget {
let mut s = String::new();
let mut width = 0usize;
let limit = per_desc_budget.saturating_sub(1);
for ch in desc.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if width + cw >= limit {
break;
}
s.push(ch);
width += cw;
}
s.push('\u{2026}');
s
} else {
desc
};
format!("- {}: {}", skill.name, trimmed)
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ExecutionContext, LoadedFrom, SkillMetadata, SkillSource};
fn make_skill(
name: &str,
description: &str,
when_to_use: Option<&str>,
bundled: bool,
hidden: bool,
) -> SkillMetadata {
SkillMetadata {
name: name.to_string(),
display_name: None,
description: description.to_string(),
has_user_specified_description: false,
allowed_tools: vec![],
argument_hint: None,
argument_names: vec![],
when_to_use: when_to_use.map(|s| s.to_string()),
version: None,
model: None,
disable_model_invocation: hidden,
user_invocable: true,
execution_context: ExecutionContext::Inline,
agent: None,
effort: None,
shell: None,
paths: vec![],
hooks_raw: None,
source: if bundled {
SkillSource::Bundled
} else {
SkillSource::User
},
loaded_from: if bundled {
LoadedFrom::Bundled
} else {
LoadedFrom::Skills
},
content: String::new(),
content_length: 0,
skill_root: None,
}
}
// --- get_char_budget ---
#[test]
fn test_get_char_budget_none_returns_default() {
assert_eq!(get_char_budget(None), DEFAULT_CHAR_BUDGET);
}
#[test]
fn test_get_char_budget_200k_tokens() {
// 200_000 * 4 * 0.01 = 8_000
assert_eq!(get_char_budget(Some(200_000)), 8_000);
}
#[test]
fn test_get_char_budget_small_window() {
// 100 * 4 * 0.01 = 4
assert_eq!(get_char_budget(Some(100)), 4);
}
#[test]
fn test_get_char_budget_zero_tokens() {
assert_eq!(get_char_budget(Some(0)), 0);
}
#[test]
fn test_get_char_budget_large_window() {
// 1_000_000 * 4 * 0.01 = 40_000
assert_eq!(get_char_budget(Some(1_000_000)), 40_000);
}
// --- format_skill_description ---
#[test]
fn test_format_skill_description_no_when_to_use() {
let skill = make_skill("s", "A simple skill", None, false, false);
assert_eq!(format_skill_description(&skill), "A simple skill");
}
#[test]
fn test_format_skill_description_with_when_to_use() {
let skill = make_skill("s", "Does X", Some("Use when Y"), false, false);
assert_eq!(format_skill_description(&skill), "Does X - Use when Y");
}
#[test]
fn test_format_skill_description_truncates_long_description() {
// description is 300 ASCII chars, no when_to_use
let desc = "a".repeat(300);
let skill = make_skill("s", &desc, None, false, false);
let result = format_skill_description(&skill);
// implementation truncates by char count: result chars <= MAX_LISTING_DESC_CHARS
assert!(
result.chars().count() <= MAX_LISTING_DESC_CHARS,
"result should be truncated to MAX_LISTING_DESC_CHARS chars"
);
assert!(
result.ends_with('\u{2026}'),
"truncated result should end with ellipsis"
);
}
#[test]
fn test_format_skill_description_truncates_combined_over_limit() {
// description 200 chars + " - " + when_to_use 100 chars = 303 > 250
let desc = "a".repeat(200);
let wtu = "b".repeat(100);
let skill = make_skill("s", &desc, Some(&wtu), false, false);
let result = format_skill_description(&skill);
assert!(
result.ends_with('\u{2026}'),
"should be truncated with ellipsis"
);
}
#[test]
fn test_format_skill_description_empty_description() {
let skill = make_skill("s", "", None, false, false);
assert_eq!(format_skill_description(&skill), "");
}
#[test]
fn test_format_skill_description_empty_when_to_use_ignored() {
// empty when_to_use string should not add " - "
let skill = make_skill("s", "desc", Some(""), false, false);
assert_eq!(format_skill_description(&skill), "desc");
}
#[test]
fn test_format_skill_description_exactly_at_limit() {
// description exactly 250 chars — should NOT be truncated
let desc = "x".repeat(MAX_LISTING_DESC_CHARS);
let skill = make_skill("s", &desc, None, false, false);
let result = format_skill_description(&skill);
assert_eq!(result, desc);
assert!(!result.ends_with('\u{2026}'));
}
// --- format_skill_entry ---
#[test]
fn test_format_skill_entry_basic() {
let skill = make_skill("my-skill", "Does things", None, false, false);
assert_eq!(format_skill_entry(&skill), "- my-skill: Does things");
}
#[test]
fn test_format_skill_entry_with_when_to_use() {
let skill = make_skill("my-skill", "Does things", Some("When needed"), false, false);
assert_eq!(
format_skill_entry(&skill),
"- my-skill: Does things - When needed"
);
}
#[test]
fn test_format_skill_entry_truncates_long_description() {
let desc = "a".repeat(300);
let skill = make_skill("x", &desc, None, false, false);
let result = format_skill_entry(&skill);
assert!(
result.starts_with("- x: "),
"entry should start with '- x: '"
);
assert!(
result.contains('\u{2026}'),
"long description should be truncated"
);
}
#[test]
fn test_format_skill_entry_empty_name() {
let skill = make_skill("", "desc", None, false, false);
assert_eq!(format_skill_entry(&skill), "- : desc");
}
// --- format_skills_within_budget ---
#[test]
fn test_format_skills_within_budget_empty_returns_empty() {
assert_eq!(format_skills_within_budget(&[], None), "");
assert_eq!(format_skills_within_budget(&[], Some(0)), "");
}
#[test]
fn test_format_skills_within_budget_full_mode() {
// 3 short skills well within 8_000 char default budget
let skills = vec![
make_skill("skill-a", "Desc A", None, false, false),
make_skill("skill-b", "Desc B", None, false, false),
make_skill("skill-c", "Desc C", None, false, false),
];
let result = format_skills_within_budget(&skills, None);
assert!(result.contains("- skill-a: Desc A"));
assert!(result.contains("- skill-b: Desc B"));
assert!(result.contains("- skill-c: Desc C"));
assert!(
!result.contains('\u{2026}'),
"full mode should not truncate"
);
}
#[test]
fn test_format_skills_within_budget_full_mode_line_count() {
let skills = vec![
make_skill("a", "Desc A", None, false, false),
make_skill("b", "Desc B", None, false, false),
make_skill("c", "Desc C", None, false, false),
];
let result = format_skills_within_budget(&skills, None);
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines.len(), 3, "each skill should be on its own line");
}
#[test]
fn test_format_skills_within_budget_truncated_mode() {
// budget = 10_000 * 4 * 0.01 = 400 chars
// 1 bundled skill (short), 5 non-bundled each with 200-char description
// bundled ~60 chars, remaining ~340 / 5 = 68 chars per non-bundled (>= MIN_DESC_LENGTH=20)
let bundled = make_skill("bundled", "Bundled description here", None, true, false);
let non_bundled: Vec<SkillMetadata> = (0..5)
.map(|i| make_skill(&format!("nb-{i}"), &"z".repeat(200), None, false, false))
.collect();
let mut skills = vec![bundled];
skills.extend(non_bundled);
let result = format_skills_within_budget(&skills, Some(10_000));
// bundled skill should be complete (no ellipsis in its description)
assert!(
result.contains("Bundled description here"),
"bundled skill description should be intact"
);
// at least some non-bundled should be truncated
assert!(
result.contains('\u{2026}'),
"non-bundled descriptions should be truncated in truncated mode"
);
}
#[test]
fn test_format_skills_within_budget_minimal_mode() {
// budget = 50 * 4 * 0.01 = 2 chars — far below MIN_DESC_LENGTH=20
// non-bundled should show names only
let bundled = make_skill("bundled", "Bundled full desc", None, true, false);
let nb_skills: Vec<SkillMetadata> = vec![
make_skill("nb-alpha", &"x".repeat(100), None, false, false),
make_skill("nb-beta", &"y".repeat(100), None, false, false),
];
let mut skills = vec![bundled];
skills.extend(nb_skills);
let result = format_skills_within_budget(&skills, Some(50));
// bundled still full
assert!(
result.contains("Bundled full desc"),
"bundled skill should remain full in minimal mode"
);
// non-bundled: names only, no ': '
assert!(
result.contains("- nb-alpha\n") || result.ends_with("- nb-alpha"),
"nb-alpha should appear as name only"
);
assert!(
result.contains("- nb-beta\n") || result.ends_with("- nb-beta"),
"nb-beta should appear as name only"
);
assert!(
!result.contains("- nb-alpha: "),
"non-bundled should not have description in minimal mode"
);
}
#[test]
fn test_format_skills_within_budget_single_skill_full() {
let skill = make_skill("solo", "Solo description", None, false, false);
let result = format_skills_within_budget(&[skill], None);
assert!(result.contains("- solo: Solo description"));
}
#[test]
fn test_format_skills_within_budget_max_desc_limit_respected() {
// Single skill with 300-char description; default budget is large enough for full mode,
// but format_skill_description always caps at MAX_LISTING_DESC_CHARS.
let long_desc = "d".repeat(300);
let skill = make_skill("big", &long_desc, None, false, false);
let result = format_skills_within_budget(&[skill], None);
let prefix = "- big: ";
let desc_part = result.strip_prefix(prefix).unwrap_or(&result);
// implementation truncates at char boundary: MAX_LISTING_DESC_CHARS - 1 chars + ellipsis = 250 chars
assert!(
desc_part.chars().count() <= MAX_LISTING_DESC_CHARS,
"entry description must not exceed MAX_LISTING_DESC_CHARS chars"
);
assert!(desc_part.ends_with('\u{2026}'));
}
#[test]
fn test_format_skills_within_budget_only_bundled_skills() {
// All bundled: even if over budget, all are shown full (no non-bundled to degrade)
let skills: Vec<SkillMetadata> = (0..3)
.map(|i| {
make_skill(
&format!("bundled-{i}"),
&format!("Desc {i}"),
None,
true,
false,
)
})
.collect();
let result = format_skills_within_budget(&skills, Some(1)); // tiny budget
for i in 0..3 {
assert!(
result.contains(&format!("- bundled-{i}: Desc {i}")),
"bundled skill {i} should be intact"
);
}
}
// --- CJK / multi-byte UTF-8 boundary tests ---
#[test]
fn test_format_skill_description_cjk_short_preserved() {
// TC-31: short CJK description should be returned as-is
let skill = make_skill("s", "这是一个技能描述", None, false, false);
let result = format_skill_description(&skill);
assert_eq!(result, "这是一个技能描述");
}
#[test]
fn test_format_skill_description_cjk_long_truncated_no_panic() {
// TC-32: 300 CJK chars must be truncated to <= 250 chars without panicking
let desc = "".repeat(300);
let skill = make_skill("s", &desc, None, false, false);
let result = format_skill_description(&skill);
assert!(
result.chars().count() <= MAX_LISTING_DESC_CHARS,
"CJK description should be truncated to <= {} chars",
MAX_LISTING_DESC_CHARS
);
assert!(
result.ends_with('…'),
"truncated CJK result should end with ellipsis"
);
}
#[test]
fn test_format_skill_description_mixed_cjk_ascii_truncated_no_panic() {
// TC-33: mixed ASCII + CJK over 250 chars must be truncated without panicking
let desc = format!("Skill: {}", "描述".repeat(150));
let skill = make_skill("s", &desc, None, false, false);
let result = format_skill_description(&skill);
assert!(
result.chars().count() <= MAX_LISTING_DESC_CHARS,
"mixed CJK/ASCII description should be truncated to <= {} chars",
MAX_LISTING_DESC_CHARS
);
assert!(
result.ends_with('…'),
"truncated mixed result should end with ellipsis"
);
}
#[test]
fn test_format_skills_within_budget_truncated_mode_cjk_no_panic() {
// TC-34: truncated mode with CJK descriptions must not panic
// budget = 10_000 * 4 * 0.01 = 400 chars; each CJK desc is 200 chars → triggers truncation
let bundled = make_skill("bundled", "Bundled desc", None, true, false);
let non_bundled: Vec<SkillMetadata> = (0..3)
.map(|i| {
make_skill(
&format!("nb-{i}"),
&"中文描述".repeat(50),
None,
false,
false,
)
})
.collect();
let mut skills = vec![bundled];
skills.extend(non_bundled);
// should not panic
let result = format_skills_within_budget(&skills, Some(10_000));
assert!(
result.contains('…') || !result.is_empty(),
"result should be non-empty and handle CJK without panic"
);
assert!(
result.contains("bundled"),
"bundled skill must appear in result"
);
}
}
@@ -0,0 +1,393 @@
use futures::future::join_all;
use regex::Regex;
use std::sync::OnceLock;
use crate::types::LoadedFrom;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Parse and execute shell commands embedded in skill content.
///
/// Block pattern: ```!\n<commands>\n```
/// Inline pattern: !`<command>` (preceded by start-of-line or whitespace)
///
/// All matched commands are executed in parallel.
/// MCP skills are silently skipped (content returned unchanged).
/// Command output replaces the original pattern in content.
pub async fn execute_shell_commands(
content: &str,
loaded_from: LoadedFrom,
cwd: &str,
) -> Result<String, ShellExecutionError> {
if loaded_from == LoadedFrom::Mcp {
return Ok(content.to_owned());
}
let matches = extract_shell_matches(content);
if matches.is_empty() {
return Ok(content.to_owned());
}
// Execute all commands in parallel
let futures: Vec<_> = matches
.iter()
.map(|m| execute_command(&m.command, cwd))
.collect();
let outputs: Vec<Result<String, ShellExecutionError>> = join_all(futures).await;
// Pair matches with outputs; fail-fast on first error
let mut pairs: Vec<(usize, usize, String)> = Vec::with_capacity(matches.len());
for (m, result) in matches.iter().zip(outputs) {
let output = result.map_err(|e| ShellExecutionError::CommandFailed {
pattern: m.full_match.clone(),
output: e.to_string(),
})?;
pairs.push((m.start, m.end, output));
}
// Replace from back to front to preserve byte offsets
pairs.sort_by_key(|p| std::cmp::Reverse(p.0));
let mut result = content.to_owned();
for (start, end, output) in pairs {
result.replace_range(start..end, &output);
}
Ok(result)
}
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
/// Errors that can occur during shell command execution.
#[derive(Debug, thiserror::Error)]
pub enum ShellExecutionError {
#[error("Shell command failed for pattern \"{pattern}\": {output}")]
CommandFailed { pattern: String, output: String },
#[error("Shell execution blocked for MCP skill")]
McpBlocked,
}
// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------
/// A matched shell command with its byte range in the original content.
struct ShellMatch {
/// Complete text to be replaced (full_match bytes in content[start..end])
full_match: String,
/// The command to execute
command: String,
/// Byte offset of `full_match` start in content
start: usize,
/// Byte offset one past the end of `full_match` in content
end: usize,
}
// ---------------------------------------------------------------------------
// Regex helpers
// ---------------------------------------------------------------------------
/// Block regex: ```!\n<body>\n```
fn block_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?s)```!\s*\n([\s\S]*?)\n?```").expect("invalid block regex"))
}
/// Inline regex — two patterns needed because `regex` crate has no lookbehind:
/// 1. Line-start: ^!`...` (multiline mode)
/// 2. Preceded by whitespace: ([ \t])!`...`
fn inline_line_start_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?m)^(!`([^`]+)`)").expect("invalid inline line-start regex"))
}
fn inline_whitespace_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"([ \t])(!`([^`]+)`)").expect("invalid inline whitespace regex"))
}
// ---------------------------------------------------------------------------
// extract_shell_matches
// ---------------------------------------------------------------------------
/// Extract all shell command matches from content, ordered by start position.
fn extract_shell_matches(content: &str) -> Vec<ShellMatch> {
let mut matches: Vec<ShellMatch> = Vec::new();
// Block matches: entire ```!...``` block is replaced
for cap in block_regex().captures_iter(content) {
let full = cap.get(0).unwrap();
let command = cap.get(1).map_or("", |m| m.as_str()).trim().to_owned();
matches.push(ShellMatch {
full_match: full.as_str().to_owned(),
command,
start: full.start(),
end: full.end(),
});
}
// Track byte ranges already covered by block matches to avoid overlap
let block_ranges: Vec<(usize, usize)> = matches.iter().map(|m| (m.start, m.end)).collect();
let overlaps_block =
|s: usize, e: usize| -> bool { block_ranges.iter().any(|(bs, be)| s < *be && e > *bs) };
// Inline line-start: group(1) = full !`cmd`, group(2) = cmd
for cap in inline_line_start_regex().captures_iter(content) {
let full = cap.get(1).unwrap();
let command = cap.get(2).unwrap().as_str().to_owned();
if !overlaps_block(full.start(), full.end()) {
matches.push(ShellMatch {
full_match: full.as_str().to_owned(),
command,
start: full.start(),
end: full.end(),
});
}
}
// Inline whitespace-preceded: group(1) = leading whitespace char,
// group(2) = full !`cmd`, group(3) = cmd
// We replace only the !`cmd` part (group 2), keeping the leading space intact.
for cap in inline_whitespace_regex().captures_iter(content) {
let full_match_group = cap.get(2).unwrap();
let command = cap.get(3).unwrap().as_str().to_owned();
if !overlaps_block(full_match_group.start(), full_match_group.end()) {
matches.push(ShellMatch {
full_match: full_match_group.as_str().to_owned(),
command,
start: full_match_group.start(),
end: full_match_group.end(),
});
}
}
// Sort by start ascending (will be reversed before replacement)
matches.sort_by_key(|m| m.start);
// Deduplicate overlapping matches (keep first by start)
let mut deduped: Vec<ShellMatch> = Vec::new();
let mut last_end: usize = 0;
for m in matches {
if m.start >= last_end {
last_end = m.end;
deduped.push(m);
}
}
deduped
}
// ---------------------------------------------------------------------------
// execute_command
// ---------------------------------------------------------------------------
/// Execute a single shell command and return its combined stdout/stderr output.
async fn execute_command(command: &str, cwd: &str) -> Result<String, ShellExecutionError> {
let output = nomi_config::shell::shell_command_builder(command)
.current_dir(cwd)
.output()
.await
.map_err(|e| ShellExecutionError::CommandFailed {
pattern: command.to_owned(),
output: e.to_string(),
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() && stdout.is_empty() && stderr.is_empty() {
return Err(ShellExecutionError::CommandFailed {
pattern: command.to_owned(),
output: format!("exit code {}", output.status.code().unwrap_or(-1)),
});
}
Ok(format_output(stdout.trim_end(), stderr.trim_end()))
}
/// Format stdout and stderr into a single string.
/// stderr is prefixed with `[stderr]\n` when non-empty.
fn format_output(stdout: &str, stderr: &str) -> String {
match (stdout.is_empty(), stderr.is_empty()) {
(false, false) => format!("{stdout}\n[stderr]\n{stderr}"),
(false, true) => stdout.to_owned(),
(true, false) => format!("[stderr]\n{stderr}"),
(true, true) => String::new(),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
// Note: these are the implementer's tests; supplemental tests below.
use super::*;
// Helper: run execute_shell_commands with LoadedFrom::Skills
async fn run(content: &str) -> Result<String, ShellExecutionError> {
let tmp = std::env::temp_dir();
execute_shell_commands(content, LoadedFrom::Skills, tmp.to_str().unwrap()).await
}
// -----------------------------------------------------------------------
// format_output
// -----------------------------------------------------------------------
#[test]
fn test_format_output_both() {
let s = format_output("out", "err");
assert_eq!(s, "out\n[stderr]\nerr");
}
#[test]
fn test_format_output_stdout_only() {
assert_eq!(format_output("out", ""), "out");
}
#[test]
fn test_format_output_stderr_only() {
assert_eq!(format_output("", "err"), "[stderr]\nerr");
}
#[test]
fn test_format_output_empty() {
assert_eq!(format_output("", ""), "");
}
// -----------------------------------------------------------------------
// extract_shell_matches
// -----------------------------------------------------------------------
#[test]
fn test_extract_block_match() {
let content = "Before\n```!\necho hello\n```\nAfter";
let matches = extract_shell_matches(content);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].command, "echo hello");
assert!(matches[0].full_match.starts_with("```!"));
}
#[test]
fn test_extract_inline_line_start() {
let content = "!`pwd`";
let matches = extract_shell_matches(content);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].command, "pwd");
}
#[test]
fn test_extract_inline_whitespace_preceded() {
let content = "The dir is !`pwd` and user is !`whoami`";
let matches = extract_shell_matches(content);
assert_eq!(matches.len(), 2);
let cmds: Vec<&str> = matches.iter().map(|m| m.command.as_str()).collect();
assert!(cmds.contains(&"pwd"));
assert!(cmds.contains(&"whoami"));
}
#[test]
fn test_extract_no_matches() {
let content = "No shell commands here.";
assert!(extract_shell_matches(content).is_empty());
}
#[test]
fn test_extract_block_and_inline() {
let content = "!`echo inline`\n```!\necho block\n```";
let matches = extract_shell_matches(content);
assert_eq!(matches.len(), 2);
}
// -----------------------------------------------------------------------
// MCP skill blocked
// -----------------------------------------------------------------------
#[tokio::test]
async fn test_mcp_skill_returns_unchanged() {
let content = "!`pwd`";
let tmp = std::env::temp_dir();
let result = execute_shell_commands(content, LoadedFrom::Mcp, tmp.to_str().unwrap()).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), content);
}
// -----------------------------------------------------------------------
// Block execution
// -----------------------------------------------------------------------
#[tokio::test]
async fn test_block_execution() {
let content = "Output:\n```!\necho hello\n```\nDone.";
let result = run(content).await.unwrap();
assert!(result.contains("hello"));
assert!(!result.contains("```!"));
}
#[tokio::test]
async fn test_inline_execution_line_start() {
let content = "!`echo world`";
let result = run(content).await.unwrap();
assert!(result.contains("world"));
}
#[tokio::test]
async fn test_inline_execution_whitespace_preceded() {
let content = "Dir: !`echo /tmp`";
let result = run(content).await.unwrap();
assert!(result.contains("/tmp"));
// Leading space preserved
assert!(result.contains("Dir: "));
}
#[tokio::test]
async fn test_no_shell_commands_unchanged() {
let content = "No commands here.";
let result = run(content).await.unwrap();
assert_eq!(result, content);
}
#[tokio::test]
async fn test_empty_output_replaced_with_empty_string() {
// `cd .` exits 0 with no output on all platforms
let content = "before !`cd .` after";
let result = run(content).await.unwrap();
assert_eq!(result, "before after");
}
#[tokio::test]
async fn test_multiple_inline_parallel() {
let content = "A: !`echo aaa` B: !`echo bbb`";
let result = run(content).await.unwrap();
assert!(result.contains("aaa"));
assert!(result.contains("bbb"));
}
#[tokio::test]
async fn test_stderr_formatted() {
// Write to stderr only — cross-platform redirection
let content = if cfg!(windows) {
"!`echo err 1>&2`"
} else {
"!`echo err >&2`"
};
let result = run(content).await.unwrap();
assert!(result.contains("[stderr]"));
assert!(result.contains("err"));
}
}
// ---------------------------------------------------------------------------
// Supplemental tests (tester role — split to keep file under 800 lines)
// ---------------------------------------------------------------------------
#[cfg(test)]
#[path = "shell_supplemental_tests.rs"]
mod supplemental_tests;
@@ -0,0 +1,445 @@
use super::*;
// Helper: run execute_shell_commands with LoadedFrom::Skills
async fn run(content: &str) -> Result<String, ShellExecutionError> {
let tmp = std::env::temp_dir();
execute_shell_commands(content, LoadedFrom::Skills, tmp.to_str().unwrap()).await
}
// -----------------------------------------------------------------------
// TC-1: Block 语法解析
// -----------------------------------------------------------------------
// TC-1.2: Block 多行命令
#[test]
fn tc_1_2_block_multiline_command() {
let content = "```!\nls -la\npwd\n```";
let matches = extract_shell_matches(content);
assert_eq!(matches.len(), 1);
// command should contain both lines
assert!(matches[0].command.contains("ls -la"));
assert!(matches[0].command.contains("pwd"));
}
// TC-1.3: Block 前后有内容 — full_match 包含完整 block
#[test]
fn tc_1_3_block_with_surrounding_text() {
let content = "before\n```!\ncmd\n```\nafter";
let matches = extract_shell_matches(content);
assert_eq!(matches.len(), 1);
assert!(matches[0].full_match.starts_with("```!"));
assert!(matches[0].full_match.ends_with("```"));
}
// TC-1.4: 多个 Block
#[test]
fn tc_1_4_multiple_blocks() {
let content = "```!\necho first\n```\ntext\n```!\necho second\n```";
let matches = extract_shell_matches(content);
assert_eq!(matches.len(), 2);
}
// TC-1.5: 无 Block 内容 → 匹配 0 条
#[test]
fn tc_1_5_no_block_no_match() {
let content = "no shell commands here";
let matches = extract_shell_matches(content);
assert_eq!(matches.len(), 0);
}
// TC-1.6: 普通代码块不匹配(无 `!`)
#[test]
fn tc_1_6_regular_code_block_not_matched() {
let content = "```rust\nfn main() {}\n```";
let matches = extract_shell_matches(content);
assert_eq!(matches.len(), 0);
}
// TC-1.7: Block 内容为空
#[test]
fn tc_1_7_block_empty_command() {
let content = "```!\n```";
let matches = extract_shell_matches(content);
// empty command block still matched
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].command, "");
}
// -----------------------------------------------------------------------
// TC-2: Inline 语法解析
// -----------------------------------------------------------------------
// TC-2.2: 空格前 Inline — 匹配 1 条
#[test]
fn tc_2_2_inline_space_preceded() {
let content = "dir is !`pwd` end";
let matches = extract_shell_matches(content);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].command, "pwd");
}
// TC-2.3: 多个 Inline
#[test]
fn tc_2_3_multiple_inline_matches() {
let content = "!`cmd1` and !`cmd2`";
let matches = extract_shell_matches(content);
assert_eq!(matches.len(), 2);
let cmds: Vec<&str> = matches.iter().map(|m| m.command.as_str()).collect();
assert!(cmds.contains(&"cmd1"));
assert!(cmds.contains(&"cmd2"));
}
// TC-2.4: 无空格前缀不匹配(D-1 偏离)
// Rust regex 不支持 lookbehind;前缀为非空白字符时不应匹配
#[test]
fn tc_2_4_no_prefix_not_matched() {
// "x!`cmd`" — x 不是空格/行首,不应匹配
let content = "x!`cmd`";
let matches = extract_shell_matches(content);
assert_eq!(
matches.len(),
0,
"inline !`cmd` preceded by non-whitespace 'x' should not match"
);
}
// TC-2.5: 换行前 Inline
#[test]
fn tc_2_5_inline_after_newline() {
let content = "text\n!`ls`\n";
let matches = extract_shell_matches(content);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].command, "ls");
}
// TC-2.6: Inline 命令含空格
#[test]
fn tc_2_6_inline_command_with_spaces() {
let content = "!`echo hello world`";
let matches = extract_shell_matches(content);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].command, "echo hello world");
}
// -----------------------------------------------------------------------
// TC-3: Block + Inline 混合
// -----------------------------------------------------------------------
// TC-3.1: Block + Inline 都存在
#[test]
fn tc_3_1_block_and_inline_both_present() {
let content = "!`echo inline`\n```!\necho block\n```\n";
let matches = extract_shell_matches(content);
assert_eq!(matches.len(), 2);
let cmds: Vec<&str> = matches.iter().map(|m| m.command.as_str()).collect();
assert!(cmds.contains(&"echo inline"));
assert!(cmds.contains(&"echo block"));
}
// TC-3.2: Block 内含 Inline 语法 — Block 优先,内部不被单独匹配(D-2 偏离)
#[test]
fn tc_3_2_block_contains_inline_syntax_deduped() {
// The inline !`ls` is inside a block — should not be extracted separately
let content = "```!\necho first\n!`ls`\n```";
let matches = extract_shell_matches(content);
// Only the block should be matched, not the inner inline
assert_eq!(matches.len(), 1);
assert!(matches[0].full_match.starts_with("```!"));
}
// -----------------------------------------------------------------------
// TC-4: execute_command — 命令执行
// -----------------------------------------------------------------------
// TC-4.1: 成功命令
#[tokio::test]
async fn tc_4_1_successful_command_echo() {
let content = "!`echo hello`";
let result = run(content).await.unwrap();
assert!(result.contains("hello"));
}
// TC-4.2: 命令有 stdout
#[tokio::test]
async fn tc_4_2_stdout_captured() {
let content = "!`echo captured_stdout`";
let result = run(content).await.unwrap();
assert!(result.contains("captured_stdout"));
}
// TC-4.3: 命令有 stderr
#[tokio::test]
async fn tc_4_3_stderr_captured_and_formatted() {
// stderr-only output — cross-platform: write to stderr via redirection
let content = if cfg!(windows) {
"!`echo stderr_msg 1>&2`"
} else {
"!`echo stderr_msg >&2`"
};
let result = run(content).await.unwrap();
assert!(
result.contains("[stderr]"),
"stderr prefix missing: {result}"
);
assert!(result.contains("stderr_msg"));
}
// TC-4.4: 命令失败且无输出 → Err(D-3 偏离:有输出时仍返回 Ok)
#[tokio::test]
async fn tc_4_4_command_fail_no_output_returns_err() {
// `exit 1` exits with code 1 and produces no output (cross-platform)
let content = "!`exit 1`";
let result = run(content).await;
assert!(
result.is_err(),
"command with exit 1 and no output should return Err"
);
}
// TC-4.4b: 命令失败但有输出 → Ok(D-3 偏离验证)
#[tokio::test]
async fn tc_4_4b_command_fail_with_output_returns_ok() {
// exits non-zero but still has stdout
let content = if cfg!(windows) {
"!`echo output & exit 1`"
} else {
"!`echo output; exit 1`"
};
let result = run(content).await;
assert!(
result.is_ok(),
"command with exit 1 but with output should return Ok, got: {:?}",
result.err()
);
assert!(result.unwrap().contains("output"));
}
// TC-4.5: cwd 参数生效
#[tokio::test]
async fn tc_4_5_cwd_used() {
let tmp = std::env::temp_dir();
// Use cross-platform command: `cd` on Windows, `pwd` on Unix
let content = if cfg!(windows) { "!`cd`" } else { "!`pwd`" };
let result = execute_shell_commands(content, LoadedFrom::Skills, tmp.to_str().unwrap())
.await
.unwrap();
// Check that the output contains the temp directory name
let tmp_name = tmp.file_name().unwrap().to_str().unwrap();
assert!(
result.contains(tmp_name),
"pwd should output a path containing '{tmp_name}', got: {result}"
);
}
// TC-4.6: 命令输出为空 → output 为空字符串
#[tokio::test]
async fn tc_4_6_empty_output() {
// `cd .` exits 0 on all platforms with no output
let content = "before !`cd .` after";
let result = run(content).await.unwrap();
assert_eq!(result, "before after");
}
// TC-4.7: 命令不存在 → Err
#[tokio::test]
async fn tc_4_7_nonexistent_command_returns_err() {
let content = "!`not_a_real_command_xyz_12345`";
let result = run(content).await;
// bash writes "command not found" to stderr and exits non-zero with empty stdout.
// Per D-3: if stderr is non-empty, the command returns Ok with [stderr] content.
// On Windows cmd, a nonexistent command returns exit code 1 with empty stdout/stderr → Err.
match &result {
Err(ShellExecutionError::CommandFailed { .. }) => {} // expected on Windows/cmd
Ok(s) => {
// bash returns Ok with [stderr] content since stderr is non-empty
assert!(
s.contains("[stderr]") || s.contains("not found"),
"unexpected Ok result: {s}"
);
}
other => panic!("unexpected result: {:?}", other),
}
}
// -----------------------------------------------------------------------
// TC-5: format_output
// -----------------------------------------------------------------------
// TC-5.5: stdout 末尾换行被 trim
#[test]
fn tc_5_5_stdout_trailing_newline_trimmed() {
// format_output receives pre-trimmed strings (execute_command trims)
let result = format_output("line\n", "");
// format_output itself doesn't trim; execute_command does via trim_end()
// This test verifies format_output handles it cleanly
assert_eq!(result, "line\n");
}
// -----------------------------------------------------------------------
// TC-6: execute_shell_commands
// -----------------------------------------------------------------------
// TC-6.1: MCP skill → 跳过执行,返回原文
#[tokio::test]
async fn tc_6_1_mcp_skill_unchanged() {
let tmp = std::env::temp_dir();
let content = "run: !`pwd` and ```!\nls\n```";
let result = execute_shell_commands(content, LoadedFrom::Mcp, tmp.to_str().unwrap())
.await
.unwrap();
assert_eq!(
result, content,
"MCP skill content should be returned unchanged"
);
}
// TC-6.2: 无 shell 命令 → 原文不变
#[tokio::test]
async fn tc_6_2_no_commands_unchanged() {
let content = "just plain text, no commands";
let result = run(content).await.unwrap();
assert_eq!(result, content);
}
// TC-6.3: Block 命令替换 — full_match 被 output 替换
#[tokio::test]
async fn tc_6_3_block_replaced_with_output() {
let content = "Result:\n```!\necho replaced\n```\nEnd.";
let result = run(content).await.unwrap();
assert!(!result.contains("```!"), "block syntax should be replaced");
assert!(result.contains("replaced"));
assert!(result.contains("Result:"));
assert!(result.contains("End."));
}
// TC-6.4: Inline 命令替换 — 前导空白保留(D-1 偏离)
#[tokio::test]
async fn tc_6_4_inline_replaced_leading_space_preserved() {
let content = "Dir: !`echo /mydir`";
let result = run(content).await.unwrap();
// Leading "Dir: " space must be preserved
assert!(
result.starts_with("Dir: "),
"leading space must be preserved, got: {result}"
);
assert!(result.contains("mydir"));
}
// TC-6.5: 多命令并行执行 — 两者都替换
#[tokio::test]
async fn tc_6_5_multiple_commands_all_replaced() {
let content = "A: !`echo aaa` B: !`echo bbb`";
let result = run(content).await.unwrap();
assert!(result.contains("aaa"), "first command missing: {result}");
assert!(result.contains("bbb"), "second command missing: {result}");
assert!(!result.contains("!`"), "shell syntax should be replaced");
}
// TC-6.7: 从后向前替换 — 前面替换不影响后面位置
#[tokio::test]
async fn tc_6_7_back_to_front_replacement() {
// Two inline commands; the first replacement should not corrupt the second
let content = "X: !`echo first` Y: !`echo second`";
let result = run(content).await.unwrap();
assert!(result.contains("first"));
assert!(result.contains("second"));
// Verify ordering: "X:" before "Y:"
let x_pos = result.find("X:").unwrap();
let y_pos = result.find("Y:").unwrap();
assert!(x_pos < y_pos, "X should come before Y in result: {result}");
}
// TC-6.8: Block 命令 + 周围文本保留
#[tokio::test]
async fn tc_6_8_surrounding_text_preserved() {
let content = "Header\n```!\necho body\n```\nFooter";
let result = run(content).await.unwrap();
assert!(result.contains("Header"));
assert!(result.contains("body"));
assert!(result.contains("Footer"));
assert!(!result.contains("```!"));
}
// -----------------------------------------------------------------------
// TC-7: ShellExecutionError
// -----------------------------------------------------------------------
// TC-7.1: CommandFailed 消息含 pattern
#[test]
fn tc_7_1_command_failed_message_contains_pattern() {
let err = ShellExecutionError::CommandFailed {
pattern: "my-cmd".to_string(),
output: "exit code 1".to_string(),
};
let msg = err.to_string();
assert!(
msg.contains("my-cmd"),
"error message should contain pattern: {msg}"
);
}
// TC-7.2: McpBlocked 消息
#[test]
fn tc_7_2_mcp_blocked_message() {
let err = ShellExecutionError::McpBlocked;
let msg = err.to_string();
assert!(
msg.to_lowercase().contains("mcp") || msg.to_lowercase().contains("blocked"),
"McpBlocked message should mention MCP or blocked: {msg}"
);
}
// TC-7.3: Error 实现 Debug
#[test]
fn tc_7_3_error_debug_format() {
let err = ShellExecutionError::CommandFailed {
pattern: "cmd".to_string(),
output: "output".to_string(),
};
let debug = format!("{:?}", err);
assert!(!debug.is_empty());
}
// -----------------------------------------------------------------------
// TC-15: 边界情况
// -----------------------------------------------------------------------
// TC-15.1: content 为空字符串
#[tokio::test]
async fn tc_15_1_empty_content() {
let result = run("").await.unwrap();
assert_eq!(result, "");
}
// TC-15.3: 命令含特殊字符(引号、管道)
#[tokio::test]
async fn tc_15_3_command_with_special_chars() {
let content = r#"!`echo "hello world"`"#;
let result = run(content).await.unwrap();
assert!(result.contains("hello world"), "got: {result}");
}
// TC-15.4: 命令含换行符(多行 block)
#[tokio::test]
#[cfg(not(windows))] // Windows cmd does not support newline-separated commands in blocks
async fn tc_15_4_multiline_block_executed_as_script() {
let content = "```!\necho line1\necho line2\n```";
let result = run(content).await.unwrap();
assert!(result.contains("line1"));
assert!(result.contains("line2"));
}
// TC-15.6: 同一命令多次出现
#[tokio::test]
async fn tc_15_6_same_command_repeated() {
let content = "!`echo x` and !`echo x`";
let result = run(content).await.unwrap();
// Both occurrences of !`echo x` should be replaced
assert!(
!result.contains("!`"),
"both occurrences should be replaced: {result}"
);
// Should contain "x" — at least once from each replacement
// On Windows cmd, echo may include trailing space; just verify no backtick syntax remains
assert!(result.contains('x'), "expected 'x' in result: {result}");
}
@@ -0,0 +1,619 @@
use regex::Regex;
/// Substitute all argument and environment variables in skill content.
///
/// Substitution order (matches TS `substituteArguments`):
/// 1. Named arguments: `$foo`, `$bar` (mapped from `argument_names[i]` → `parsed_args[i]`)
/// 2. Indexed arguments: `$ARGUMENTS[0]`, `$ARGUMENTS[1]`
/// 3. Shorthand indexed: `$0`, `$1`, `$2`
/// 4. Full arguments: `$ARGUMENTS` → entire args string
/// 5. Skill directory: `${NOMI_SKILL_DIR}` → `skill_root`
/// 6. Session ID: `${NOMI_SESSION_ID}` → `session_id`
/// 7. Fallback: if content is unchanged and args is non-empty, append `\n\nARGUMENTS: {args}`
///
/// When `args` is `None`, the content is returned unchanged (no placeholders replaced).
pub fn substitute_arguments(
content: &str,
args: Option<&str>,
argument_names: &[String],
skill_root: Option<&str>,
session_id: Option<&str>,
) -> String {
// Always apply env-var substitutions regardless of args.
let mut result = content.to_owned();
// 5. ${NOMI_SKILL_DIR}
if let Some(root) = skill_root {
result = result.replace("${NOMI_SKILL_DIR}", root);
}
// 6. ${NOMI_SESSION_ID}
if let Some(sid) = session_id {
result = result.replace("${NOMI_SESSION_ID}", sid);
}
// If no args provided, return after env substitutions only.
let args = match args {
Some(a) => a,
None => return result,
};
let parsed = parse_arguments(args);
let original = result.clone();
// 1. Named argument substitution: $name (but not $name[ or $nameWord).
// The `regex` crate does not support lookaheads, so we use a consuming
// pattern `\$name([^\[\w]|$)` and put the trailing non-word char back.
for (i, name) in argument_names.iter().enumerate() {
if name.is_empty() || name.chars().all(|c| c.is_ascii_digit()) {
// Skip empty or purely numeric names (conflict with $0/$1 shorthand)
continue;
}
let replacement = parsed.get(i).map(|s| s.as_str()).unwrap_or("").to_owned();
// Capture trailing non-word/non-bracket char (group 1) or end-of-string.
let pattern = format!(r"\${}([^\[\w]|$)", regex::escape(name));
if let Ok(re) = Regex::new(&pattern) {
result = re
.replace_all(&result, |caps: &regex::Captures<'_>| {
// Restore the trailing char that was consumed by the pattern.
let trailing = caps.get(1).map(|m| m.as_str()).unwrap_or("");
format!("{replacement}{trailing}")
})
.into_owned();
}
}
// 2. Indexed arguments: $ARGUMENTS[n]
let indexed_re = Regex::new(r"\$ARGUMENTS\[(\d+)\]").expect("static regex");
result = indexed_re
.replace_all(&result, |caps: &regex::Captures<'_>| {
let idx: usize = caps[1].parse().unwrap_or(usize::MAX);
parsed.get(idx).map(|s| s.as_str()).unwrap_or("").to_owned()
})
.into_owned();
// 3. Shorthand indexed: $n not followed by a word character.
// Pattern: \$(\d+)([^\w]|$) — capture trailing non-word char to restore it.
let shorthand_re = Regex::new(r"\$(\d+)([^\w]|$)").expect("static regex");
result = shorthand_re
.replace_all(&result, |caps: &regex::Captures<'_>| {
let idx: usize = caps[1].parse().unwrap_or(usize::MAX);
let trailing = caps.get(2).map(|m| m.as_str()).unwrap_or("");
let value = parsed.get(idx).map(|s| s.as_str()).unwrap_or("");
format!("{value}{trailing}")
})
.into_owned();
// 4. Full argument string: $ARGUMENTS
result = result.replace("$ARGUMENTS", args);
// 7. Fallback: if nothing changed and args is non-empty, append arguments
if result == original && !args.is_empty() {
result.push_str(&format!("\n\nARGUMENTS: {args}"));
}
result
}
/// Parse an argument string into individual arguments.
///
/// Handles double-quoted and single-quoted strings so that
/// `"hello world" foo` parses as `["hello world", "foo"]`.
/// Falls back to whitespace splitting if no quoted strings are present.
pub fn parse_arguments(args: &str) -> Vec<String> {
if args.trim().is_empty() {
return Vec::new();
}
let mut result = Vec::new();
let mut current = String::new();
let mut in_double = false;
let mut in_single = false;
let chars = args.chars();
for ch in chars {
match ch {
'"' if !in_single => {
in_double = !in_double;
}
'\'' if !in_double => {
in_single = !in_single;
}
' ' | '\t' if !in_double && !in_single => {
if !current.is_empty() {
result.push(current.clone());
current.clear();
}
}
_ => {
current.push(ch);
}
}
}
if !current.is_empty() {
result.push(current);
}
result
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// --- parse_arguments ---
#[test]
fn test_parse_empty() {
assert!(parse_arguments("").is_empty());
assert!(parse_arguments(" ").is_empty());
}
#[test]
fn test_parse_simple_words() {
assert_eq!(parse_arguments("foo bar baz"), vec!["foo", "bar", "baz"]);
}
#[test]
fn test_parse_double_quoted() {
assert_eq!(
parse_arguments(r#""hello world" foo"#),
vec!["hello world", "foo"]
);
}
#[test]
fn test_parse_single_quoted() {
assert_eq!(
parse_arguments("'hello world' foo"),
vec!["hello world", "foo"]
);
}
#[test]
fn test_parse_mixed_quotes() {
assert_eq!(
parse_arguments(r#"foo "bar baz" qux"#),
vec!["foo", "bar baz", "qux"]
);
}
// --- substitute_arguments ---
#[test]
fn test_no_args_returns_unchanged() {
let content = "hello $ARGUMENTS world";
let result = substitute_arguments(content, None, &[], None, None);
assert_eq!(result, content);
}
#[test]
fn test_arguments_full_substitution() {
let result = substitute_arguments("run $ARGUMENTS now", Some("foo bar"), &[], None, None);
assert_eq!(result, "run foo bar now");
}
#[test]
fn test_arguments_indexed() {
let result = substitute_arguments(
"first=$ARGUMENTS[0] second=$ARGUMENTS[1]",
Some("alpha beta"),
&[],
None,
None,
);
assert_eq!(result, "first=alpha second=beta");
}
#[test]
fn test_arguments_shorthand() {
let result = substitute_arguments("a=$0 b=$1", Some("x y"), &[], None, None);
assert_eq!(result, "a=x b=y");
}
#[test]
fn test_named_arguments() {
let names = vec!["filename".to_string(), "target".to_string()];
let result = substitute_arguments(
"file=$filename dest=$target",
Some("foo.rs /tmp"),
&names,
None,
None,
);
assert_eq!(result, "file=foo.rs dest=/tmp");
}
#[test]
fn test_named_arg_no_partial_match() {
// $foo should not match inside $foobar
let names = vec!["foo".to_string()];
let result = substitute_arguments("$foobar and $foo", Some("X"), &names, None, None);
// $foobar stays (not a word boundary match), $foo becomes X
assert_eq!(result, "$foobar and X");
}
#[test]
fn test_nomi_skill_dir_substitution() {
let result =
substitute_arguments("dir=${NOMI_SKILL_DIR}", None, &[], Some("/my/skill"), None);
assert_eq!(result, "dir=/my/skill");
}
#[test]
fn test_nomi_session_id_substitution() {
let result =
substitute_arguments("sid=${NOMI_SESSION_ID}", None, &[], None, Some("sess-123"));
assert_eq!(result, "sid=sess-123");
}
#[test]
fn test_fallback_append_when_no_placeholder() {
let result = substitute_arguments("hello world", Some("my-arg"), &[], None, None);
assert_eq!(result, "hello world\n\nARGUMENTS: my-arg");
}
#[test]
fn test_no_fallback_when_args_empty() {
// Empty string — no fallback appended
let result = substitute_arguments("hello world", Some(""), &[], None, None);
assert_eq!(result, "hello world");
}
#[test]
fn test_arguments_out_of_bounds_replaced_with_empty() {
let result = substitute_arguments("$ARGUMENTS[5]", Some("a"), &[], None, None);
assert_eq!(result, "");
}
#[test]
fn test_substitution_order_indexed_before_full() {
// $ARGUMENTS[0] must be replaced before $ARGUMENTS to avoid partial corruption
let result = substitute_arguments(
"$ARGUMENTS[0] and $ARGUMENTS",
Some("hello world"),
&[],
None,
None,
);
assert_eq!(result, "hello and hello world");
}
}
// ---------------------------------------------------------------------------
// Supplemental tests (tester role — covers test-plan.md cases not in impl tests)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod supplemental_tests {
use super::*;
// -----------------------------------------------------------------------
// TC-1.x: parse_arguments additional cases
// -----------------------------------------------------------------------
#[test]
fn tc_1_1_basic_space_split() {
assert_eq!(parse_arguments("foo bar baz"), vec!["foo", "bar", "baz"]);
}
#[test]
fn tc_1_3_multiple_quoted_groups() {
assert_eq!(
parse_arguments(r#""arg one" "arg two" plain"#),
vec!["arg one", "arg two", "plain"]
);
}
#[test]
fn tc_1_6_single_unquoted_arg() {
assert_eq!(parse_arguments("single"), vec!["single"]);
}
#[test]
fn tc_1_7_quoted_path_with_spaces() {
assert_eq!(
parse_arguments(r#""path/to/file with spaces.txt" --flag"#),
vec!["path/to/file with spaces.txt", "--flag"]
);
}
#[test]
fn tc_1_8_unclosed_quote_no_panic() {
// Must not panic; result is implementation-defined but non-empty
let result = parse_arguments(r#""unclosed arg"#);
assert!(!result.is_empty() || result.is_empty()); // just verifies no panic
}
#[test]
fn tc_1_9_single_quotes_also_group() {
// Implementation supports single quotes too (extends plan)
assert_eq!(
parse_arguments("'hello world' foo"),
vec!["hello world", "foo"]
);
}
// -----------------------------------------------------------------------
// TC-2.x: $ARGUMENTS full substitution
// -----------------------------------------------------------------------
#[test]
fn tc_2_1_arguments_full_replacement() {
let r = substitute_arguments("Run: $ARGUMENTS", Some("foo bar"), &[], None, None);
assert_eq!(r, "Run: foo bar");
}
#[test]
fn tc_2_2_arguments_none_becomes_empty() {
// When args = None, $ARGUMENTS is NOT replaced (returns unchanged per spec)
let r = substitute_arguments("Run: $ARGUMENTS", None, &[], None, None);
assert_eq!(r, "Run: $ARGUMENTS");
}
#[test]
fn tc_2_3_arguments_multiple_occurrences() {
let r = substitute_arguments("$ARGUMENTS and $ARGUMENTS", Some("x"), &[], None, None);
assert_eq!(r, "x and x");
}
// -----------------------------------------------------------------------
// TC-3.x: $ARGUMENTS[n] indexed substitution
// -----------------------------------------------------------------------
#[test]
fn tc_3_1_arguments_index_0() {
let r = substitute_arguments("First: $ARGUMENTS[0]", Some("alpha beta"), &[], None, None);
assert_eq!(r, "First: alpha");
}
#[test]
fn tc_3_2_arguments_index_1() {
let r = substitute_arguments("Second: $ARGUMENTS[1]", Some("alpha beta"), &[], None, None);
assert_eq!(r, "Second: beta");
}
#[test]
fn tc_3_3_arguments_index_out_of_bounds_empty() {
let r = substitute_arguments("Third: $ARGUMENTS[2]", Some("only_one"), &[], None, None);
assert_eq!(r, "Third: ");
}
#[test]
fn tc_3_4_arguments_index_with_quoted_arg() {
let r = substitute_arguments(
"$ARGUMENTS[0]",
Some(r#""hello world" foo"#),
&[],
None,
None,
);
assert_eq!(r, "hello world");
}
// -----------------------------------------------------------------------
// TC-4.x: $n shorthand indexed substitution
// -----------------------------------------------------------------------
#[test]
fn tc_4_1_shorthand_0() {
let r = substitute_arguments("Hello $0", Some("world"), &[], None, None);
assert_eq!(r, "Hello world");
}
#[test]
fn tc_4_2_shorthand_0_and_1() {
let r = substitute_arguments("$0 and $1", Some("foo bar"), &[], None, None);
assert_eq!(r, "foo and bar");
}
#[test]
fn tc_4_3_shorthand_out_of_bounds() {
let r = substitute_arguments("$2", Some("only_two args"), &[], None, None);
// "only_two" = $0, "args" = $1, $2 is out of bounds → empty
assert_eq!(r, "");
}
#[test]
fn tc_4_4_shorthand_no_args() {
let r = substitute_arguments("Run $0", None, &[], None, None);
// args = None → no substitution, content returned unchanged
assert_eq!(r, "Run $0");
}
// -----------------------------------------------------------------------
// TC-5.x: $name named argument substitution
// -----------------------------------------------------------------------
#[test]
fn tc_5_1_single_named_arg() {
// $query maps to argument index 0; args "rust programming" parses to ["rust", "programming"].
// $query is replaced with the first parsed argument "rust".
// "programming" is the second argument but has no placeholder in content.
let names = vec!["query".to_string()];
let r = substitute_arguments(
"Search for $query",
Some("rust programming"),
&names,
None,
None,
);
assert_eq!(r, "Search for rust");
}
#[test]
fn tc_5_2_multiple_named_args() {
let names = vec!["src".to_string(), "dst".to_string()];
let r = substitute_arguments(
"From $src to $dst",
Some("source.txt dest.txt"),
&names,
None,
None,
);
assert_eq!(r, "From source.txt to dest.txt");
}
#[test]
fn tc_5_4_named_arg_index_out_of_range() {
// $second maps to index 1 but only one arg provided
let names = vec!["first".to_string(), "second".to_string()];
let r = substitute_arguments("File: $second", Some("only_one"), &names, None, None);
assert_eq!(r, "File: ");
}
// -----------------------------------------------------------------------
// TC-6.x: ${NOMI_SKILL_DIR} substitution
// -----------------------------------------------------------------------
#[test]
fn tc_6_1_skill_dir_replaced() {
let r = substitute_arguments(
"cd ${NOMI_SKILL_DIR}",
None,
&[],
Some("/home/user/.nomi/skills/my-skill"),
None,
);
assert_eq!(r, "cd /home/user/.nomi/skills/my-skill");
}
#[test]
fn tc_6_2_skill_dir_none_not_replaced() {
// skill_root = None → ${NOMI_SKILL_DIR} stays unreplaced
let r = substitute_arguments("cd ${NOMI_SKILL_DIR}", None, &[], None, None);
assert_eq!(r, "cd ${NOMI_SKILL_DIR}");
}
#[test]
fn tc_6_3_skill_dir_multiple_occurrences() {
let r = substitute_arguments(
"${NOMI_SKILL_DIR}/a and ${NOMI_SKILL_DIR}/b",
None,
&[],
Some("/skills/foo"),
None,
);
assert_eq!(r, "/skills/foo/a and /skills/foo/b");
}
// -----------------------------------------------------------------------
// TC-7.x: ${NOMI_SESSION_ID} substitution
// -----------------------------------------------------------------------
#[test]
fn tc_7_1_session_id_replaced() {
let r = substitute_arguments(
"Session: ${NOMI_SESSION_ID}",
None,
&[],
None,
Some("abc-123"),
);
assert_eq!(r, "Session: abc-123");
}
#[test]
fn tc_7_2_session_id_none_not_replaced() {
let r = substitute_arguments("Session: ${NOMI_SESSION_ID}", None, &[], None, None);
assert_eq!(r, "Session: ${NOMI_SESSION_ID}");
}
// -----------------------------------------------------------------------
// TC-8.x: fallback append when no placeholder
// -----------------------------------------------------------------------
#[test]
fn tc_8_1_no_placeholder_appends_arguments() {
let r = substitute_arguments("Do the task.", Some("my argument"), &[], None, None);
assert_eq!(r, "Do the task.\n\nARGUMENTS: my argument");
}
#[test]
fn tc_8_2_no_placeholder_no_args_no_append() {
let r = substitute_arguments("Do the task.", None, &[], None, None);
assert_eq!(r, "Do the task.");
}
#[test]
fn tc_8_3_with_placeholder_no_append() {
let r = substitute_arguments("Run $ARGUMENTS", Some("x"), &[], None, None);
assert_eq!(r, "Run x");
assert!(!r.contains("ARGUMENTS:"));
}
#[test]
fn tc_8_4_empty_args_string_no_append() {
// args = Some("") is empty → no fallback appended
let r = substitute_arguments("Do it.", Some(""), &[], None, None);
assert_eq!(r, "Do it.");
}
// -----------------------------------------------------------------------
// TC-9.x: comprehensive / combined scenarios
// -----------------------------------------------------------------------
#[test]
fn tc_9_1_multiple_placeholder_types() {
let r = substitute_arguments(
"cd ${NOMI_SKILL_DIR} && run $ARGUMENTS[0] with $ARGUMENTS",
Some("alpha beta"),
&[],
Some("/skills/foo"),
None,
);
assert_eq!(r, "cd /skills/foo && run alpha with alpha beta");
}
#[test]
fn tc_9_2_empty_content_with_args_appends() {
let r = substitute_arguments("", Some("foo"), &[], None, None);
assert_eq!(r, "\n\nARGUMENTS: foo");
}
#[test]
fn tc_9_3_empty_content_no_args() {
let r = substitute_arguments("", None, &[], None, None);
assert_eq!(r, "");
}
#[test]
fn tc_9_4_indexed_before_full_no_corruption() {
// $ARGUMENTS[0] must be recognized before $ARGUMENTS replacement
let r = substitute_arguments(
"$ARGUMENTS[0] / $ARGUMENTS",
Some("alpha beta"),
&[],
None,
None,
);
assert_eq!(r, "alpha / alpha beta");
}
// -----------------------------------------------------------------------
// TC-15.x: edge cases
// -----------------------------------------------------------------------
#[test]
fn tc_15_2_skill_dir_and_arguments_same_line() {
let r = substitute_arguments(
"${NOMI_SKILL_DIR}: $ARGUMENTS",
Some("test"),
&[],
Some("/root"),
None,
);
assert_eq!(r, "/root: test");
}
#[test]
fn tc_15_3_large_args_no_panic() {
let big_arg = "x".repeat(10_000);
let r = substitute_arguments("$ARGUMENTS", Some(&big_arg), &[], None, None);
assert_eq!(r, big_arg);
}
}
@@ -0,0 +1,138 @@
use serde::{Deserialize, Serialize};
// Re-export EffortLevel from nomi-types (single source of truth)
pub use nomi_types::skill_types::EffortLevel;
/// Raw fields from skill frontmatter (YAML deserialization target).
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct FrontmatterData {
pub name: Option<String>,
pub description: Option<String>,
#[serde(rename = "allowed-tools")]
pub allowed_tools: Option<StringOrVec>,
#[serde(rename = "argument-hint")]
pub argument_hint: Option<String>,
pub arguments: Option<StringOrVec>,
#[serde(rename = "when-to-use")]
pub when_to_use: Option<String>,
pub version: Option<String>,
pub model: Option<String>,
pub effort: Option<StringOrNumber>,
/// "inline" | "fork"
pub context: Option<String>,
pub agent: Option<String>,
pub paths: Option<StringOrVec>,
/// "bash" only — PowerShell not supported
pub shell: Option<String>,
#[serde(rename = "user-invocable")]
pub user_invocable: Option<BoolOrString>,
#[serde(rename = "hide-from-slash-command-tool")]
pub hide_from_model_invocation: Option<BoolOrString>,
/// Raw hooks YAML — converted to serde_json::Value in SkillMetadata (Phase 11 will parse fully)
pub hooks: Option<serde_yaml::Value>,
#[serde(rename = "type")]
pub skill_type: Option<String>,
pub skills: Option<String>,
// No serde(flatten) + HashMap — known serde_yaml bug with that combination
}
/// String or list of strings (used for allowed-tools, paths, arguments, etc.)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StringOrVec {
Single(String),
Multiple(Vec<String>),
}
/// String or integer (used for effort field)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StringOrNumber {
Str(String),
Num(i64),
}
/// Boolean or "true"/"false" string (used for user-invocable, hide-from-slash-command-tool)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum BoolOrString {
Bool(bool),
Str(String),
}
/// Parsed frontmatter plus body content.
#[derive(Debug, Clone)]
pub struct ParsedMarkdown {
pub frontmatter: FrontmatterData,
pub content: String,
}
/// Skill execution context.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecutionContext {
Inline,
Fork,
}
/// Where the skill file originates.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillSource {
/// `<config_dir>/nomi/skills/`
User,
/// .nomi/skills/ (project-level)
Project,
/// .nomi/.managed/skills/
Managed,
/// Built-in bundled skills
Bundled,
/// Loaded via MCP protocol
Mcp,
/// .nomi/commands/ (legacy compatibility)
Legacy,
}
/// How the skill was discovered during loading.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoadedFrom {
Skills,
CommandsDeprecated,
Managed,
Bundled,
Mcp,
}
/// Normalized skill metadata, derived from FrontmatterData.
#[derive(Debug, Clone)]
pub struct SkillMetadata {
pub name: String,
pub display_name: Option<String>,
pub description: String,
pub has_user_specified_description: bool,
pub allowed_tools: Vec<String>,
pub argument_hint: Option<String>,
pub argument_names: Vec<String>,
pub when_to_use: Option<String>,
pub version: Option<String>,
/// None means "don't override"; "inherit" in frontmatter is normalized to None
pub model: Option<String>,
pub disable_model_invocation: bool,
pub user_invocable: bool,
pub execution_context: ExecutionContext,
pub agent: Option<String>,
pub effort: Option<EffortLevel>,
/// "bash" only
pub shell: Option<String>,
/// Glob patterns after brace expansion
pub paths: Vec<String>,
/// Hooks converted from serde_yaml::Value — full parse deferred to Phase 11
pub hooks_raw: Option<serde_json::Value>,
pub source: SkillSource,
pub loaded_from: LoadedFrom,
/// Body content after frontmatter
pub content: String,
/// Character count of body (approximate token estimate: ~4 chars/token for English, not exact)
pub content_length: usize,
/// Directory containing the skill file
pub skill_root: Option<String>,
}
@@ -0,0 +1,584 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, recommended_watcher};
use tokio::sync::watch;
use tokio::task::JoinHandle;
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/// Watches skill directories for filesystem changes and broadcasts a version
/// counter via a `watch` channel whenever a relevant change is detected.
///
/// Changes are debounced: multiple events within a 300 ms window are coalesced
/// into a single notification. The version counter is a monotonically
/// increasing `u64`; consumers compare the received value against the previous
/// one to decide whether a reload is needed.
///
/// Hidden files (names starting with `.`) are silently ignored so that editor
/// swap/temp files do not trigger spurious reloads.
///
/// # Usage
///
/// ```ignore
/// let dirs = vec![user_skills_dir().unwrap()];
/// let (mut watcher, rx) = SkillWatcher::new()?;
/// watcher.start(dirs)?;
///
/// tokio::spawn(async move {
/// while rx.changed().await.is_ok() {
/// let version = *rx.borrow();
/// println!("skills changed, version={version}");
/// // reload skills here …
/// }
/// });
/// ```
pub struct SkillWatcher {
/// The underlying notify watcher. Wrapped in `Option` so that `stop()`
/// can drop it (which terminates the OS-level monitoring thread).
watcher: Option<RecommendedWatcher>,
/// Sender side of the signal channel shared with the notify callback.
/// Sending a `()` signals the debounce task that an event occurred.
signal_tx: watch::Sender<()>,
/// Sender side of the public version channel. The debounce task calls
/// `version_tx.send(n)` after the debounce window expires.
version_tx: watch::Sender<u64>,
/// Monotonically increasing version counter.
version: Arc<AtomicU64>,
/// Handle to the debounce tokio task so that `stop()` can abort it.
debounce_task: Option<JoinHandle<()>>,
/// Directories currently being watched.
watched_dirs: Vec<PathBuf>,
}
impl SkillWatcher {
/// Create a new `SkillWatcher`.
///
/// Returns `(watcher, change_receiver)`. Pass directories to
/// [`start`](Self::start) to begin watching them.
pub fn new() -> notify::Result<(Self, watch::Receiver<u64>)> {
let (signal_tx, _signal_rx) = watch::channel(());
let (version_tx, version_rx) = watch::channel(0u64);
let version = Arc::new(AtomicU64::new(0));
// Clone signal_tx for use inside the notify callback (runs on an OS
// thread — `watch::Sender::send` is sync and safe to call there).
let cb_signal_tx = signal_tx.clone();
let inner_watcher = recommended_watcher(move |res: notify::Result<Event>| {
if let Ok(event) = res {
if should_ignore(&event) {
return;
}
// Signal the debounce task. Errors mean the receiver was
// dropped (watcher is shutting down) — ignore silently.
let _ = cb_signal_tx.send(());
}
})?;
let watcher = Self {
watcher: Some(inner_watcher),
signal_tx,
version_tx,
version,
debounce_task: None,
watched_dirs: Vec::new(),
};
Ok((watcher, version_rx))
}
/// Begin watching the directories supplied to [`new`](Self::new) and spawn
/// the debounce task.
///
/// Can only be called once per `SkillWatcher` instance. Calling `start`
/// after `stop` is not supported.
pub fn start(&mut self, dirs: Vec<PathBuf>) -> notify::Result<()> {
for dir in dirs {
self.watch_directory(&dir)?;
}
let mut signal_rx = self.signal_tx.subscribe();
let version = Arc::clone(&self.version);
let version_tx = self.version_tx.clone();
let handle = tokio::spawn(async move {
loop {
// Wait for the next signal from the notify callback.
if signal_rx.changed().await.is_err() {
// Sender dropped — watcher stopped.
break;
}
// Debounce: wait 300 ms, consuming any additional signals that
// arrive during the window.
tokio::time::sleep(Duration::from_millis(300)).await;
// Drain any signals queued during the sleep.
while signal_rx.has_changed().unwrap_or(false) {
let _ = signal_rx.changed().await;
}
// Increment version and broadcast.
let new_version = version.fetch_add(1, Ordering::Relaxed) + 1;
// Errors mean all receivers were dropped; ignore.
let _ = version_tx.send(new_version);
}
});
self.debounce_task = Some(handle);
Ok(())
}
/// Dynamically add a directory to the watch list.
///
/// Skips directories that do not exist, logging a message. Safe to call
/// after [`start`](Self::start).
pub fn watch_directory(&mut self, dir: &Path) -> notify::Result<()> {
if !dir.is_dir() {
tracing::debug!(target: "nomi_skills", path = %dir.display(), "skipped non-existent watcher directory");
return Ok(());
}
if self.watched_dirs.contains(&dir.to_path_buf()) {
return Ok(());
}
if let Some(ref mut w) = self.watcher {
w.watch(dir, RecursiveMode::Recursive)?;
self.watched_dirs.push(dir.to_path_buf());
tracing::debug!(target: "nomi_skills", path = %dir.display(), "watching skill directory");
}
Ok(())
}
/// Stop watching all directories and clean up resources.
///
/// Drops the underlying notify watcher (which stops the OS monitoring
/// thread) and aborts the debounce tokio task.
pub fn stop(&mut self) {
// Drop the notify watcher — this implicitly unwatches all paths and
// shuts down the OS monitoring thread.
self.watcher = None;
// Abort the debounce task.
if let Some(handle) = self.debounce_task.take() {
handle.abort();
}
self.watched_dirs.clear();
}
/// Return the list of directories currently being watched.
pub fn watched_dirs(&self) -> &[PathBuf] {
&self.watched_dirs
}
}
impl Drop for SkillWatcher {
fn drop(&mut self) {
self.stop();
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Returns `true` for events that should not trigger a reload.
///
/// Filtered events:
/// - `Access` events (read-only, no content change)
/// - `Modify(Metadata(_))` events (timestamp/permission/xattr changes only —
/// macOS FSEvents emits these on the parent directory when a hidden file is
/// written, which would otherwise bypass the hidden-file name filter)
/// - `Create(Folder)` events — macOS FSEvents emits a `Create(Folder)` event
/// on the watched directory itself when the watcher is first registered.
/// This is a spurious watcher-init event, not a real skill-relevant change.
/// On Linux (inotify) this event is not emitted for existing directories.
/// - Events on hidden files/directories (names starting with `.`)
fn should_ignore(event: &Event) -> bool {
// Filter access-only and pure metadata events.
if matches!(
event.kind,
EventKind::Access(_) | EventKind::Modify(notify::event::ModifyKind::Metadata(_))
) {
return true;
}
// Filter directory-creation events. macOS FSEvents fires Create(Folder)
// on the watched directory itself upon watcher registration, and also when
// a hidden file is written (the parent directory appears "created" again).
// Directory creation is never a skill-relevant change — skills are files.
if matches!(
event.kind,
EventKind::Create(notify::event::CreateKind::Folder)
) {
return true;
}
// Filter hidden files (editor swap/temp files, .DS_Store, etc.).
// Only check the final path component (file name), not intermediate
// directory components — otherwise paths like `.nomi/skills/SKILL.md`
// would be incorrectly filtered because `.nomi` starts with `.`.
event.paths.iter().all(|p| {
p.file_name()
.map(|n| n.to_string_lossy().starts_with('.'))
.unwrap_or(false)
})
}
// ---------------------------------------------------------------------------
// White-box unit tests for internal helpers and struct branches
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use notify::{
EventKind,
event::{AccessKind, CreateKind, ModifyKind, RemoveKind, RenameMode},
};
// Helper: build a minimal Event with the given kind and paths.
fn make_event(kind: EventKind, paths: Vec<PathBuf>) -> Event {
Event {
kind,
paths,
attrs: Default::default(),
}
}
// -----------------------------------------------------------------------
// TC-WB-01 [白盒] should_ignore: Access(Read) event → true
// -----------------------------------------------------------------------
#[test]
fn wb01_should_ignore_access_read() {
let ev = make_event(
EventKind::Access(AccessKind::Read),
vec![PathBuf::from("/some/SKILL.md")],
);
assert!(should_ignore(&ev), "Access(Read) should be ignored");
}
#[test]
fn wb01b_should_ignore_access_any() {
let ev = make_event(
EventKind::Access(AccessKind::Any),
vec![PathBuf::from("/some/SKILL.md")],
);
assert!(should_ignore(&ev), "Access(Any) should be ignored");
}
// -----------------------------------------------------------------------
// TC-WB-02 [白盒] should_ignore: Create event with visible filename → false
// -----------------------------------------------------------------------
#[test]
fn wb02_should_not_ignore_create_visible_file() {
let ev = make_event(
EventKind::Create(CreateKind::File),
vec![PathBuf::from("/skills/SKILL.md")],
);
assert!(
!should_ignore(&ev),
"Create on visible file should NOT be ignored"
);
}
// -----------------------------------------------------------------------
// TC-WB-03 [白盒] should_ignore: Modify(Any) event with visible filename → false
// -----------------------------------------------------------------------
#[test]
fn wb03_should_not_ignore_modify_visible_file() {
let ev = make_event(
EventKind::Modify(ModifyKind::Any),
vec![PathBuf::from("/home/user/skills/SKILL.md")],
);
assert!(
!should_ignore(&ev),
"Modify(Any) on visible file should NOT be ignored"
);
}
// -----------------------------------------------------------------------
// TC-WB-03b [白盒] should_ignore: Modify(Metadata(_)) → true (Bug-2 fix)
//
// macOS FSEvents emits Modify(Metadata(Extended)) on the parent directory
// when a hidden file is written. This event must be filtered to prevent
// spurious reloads when editor temp files are saved.
// -----------------------------------------------------------------------
#[test]
fn wb03b_should_ignore_modify_metadata() {
use notify::event::MetadataKind;
let ev = make_event(
EventKind::Modify(ModifyKind::Metadata(MetadataKind::Extended)),
vec![PathBuf::from("/skills")],
);
assert!(
should_ignore(&ev),
"Modify(Metadata(Extended)) should be ignored (macOS parent-dir metadata event)"
);
}
#[test]
fn wb03c_should_ignore_modify_metadata_any() {
use notify::event::MetadataKind;
let ev = make_event(
EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)),
vec![PathBuf::from("/skills/SKILL.md")],
);
assert!(
should_ignore(&ev),
"Modify(Metadata(Any)) should be ignored"
);
}
// -----------------------------------------------------------------------
// TC-WB-04 [白盒] should_ignore: Remove event with visible filename → false
// -----------------------------------------------------------------------
#[test]
fn wb04_should_not_ignore_remove_visible_file() {
let ev = make_event(
EventKind::Remove(RemoveKind::File),
vec![PathBuf::from("/skills/SKILL.md")],
);
assert!(
!should_ignore(&ev),
"Remove on visible file should NOT be ignored"
);
}
// -----------------------------------------------------------------------
// TC-WB-05 [白盒] should_ignore: hidden filename (.swp) → true
// -----------------------------------------------------------------------
#[test]
fn wb05_should_ignore_hidden_filename() {
let ev = make_event(
EventKind::Create(CreateKind::File),
vec![PathBuf::from("/skills/.swp")],
);
assert!(should_ignore(&ev), ".swp hidden file should be ignored");
}
#[test]
fn wb05b_should_ignore_ds_store() {
let ev = make_event(
EventKind::Create(CreateKind::File),
vec![PathBuf::from("/skills/.DS_Store")],
);
assert!(should_ignore(&ev), ".DS_Store should be ignored");
}
// -----------------------------------------------------------------------
// TC-WB-06 [白盒] should_ignore: empty paths list → true (vacuous truth)
//
// Iterator::all() on an empty iterator returns true. This documents the
// current behaviour where a zero-path event is treated as "all hidden".
// -----------------------------------------------------------------------
#[test]
fn wb06_should_ignore_empty_paths_vacuous_true() {
let ev = make_event(EventKind::Create(CreateKind::Any), vec![]);
assert!(
should_ignore(&ev),
"empty paths: all() vacuous truth → treated as ignored"
);
}
// -----------------------------------------------------------------------
// TC-WB-07 [白盒] should_ignore: mixed paths (one visible, one hidden) → false
//
// all() requires every path to be hidden; one visible file name breaks it.
// -----------------------------------------------------------------------
#[test]
fn wb07_should_not_ignore_mixed_paths() {
let ev = make_event(
EventKind::Modify(ModifyKind::Any),
vec![
PathBuf::from("/skills/SKILL.md"), // visible filename
PathBuf::from("/skills/.swp"), // hidden filename
],
);
assert!(
!should_ignore(&ev),
"mixed paths (one visible filename) should NOT be ignored"
);
}
// -----------------------------------------------------------------------
// TC-WB-08 [白盒] should_ignore: hidden intermediate dir, visible filename → false
//
// After the fix (file_name() only), a path like
// `/private/var/folders/.tmpABC/SKILL.md` should NOT be ignored because
// the file name `SKILL.md` does not start with `.`.
// -----------------------------------------------------------------------
#[test]
fn wb08_hidden_intermediate_dir_visible_filename_not_ignored() {
let ev = make_event(
EventKind::Create(CreateKind::File),
vec![PathBuf::from("/private/var/folders/.tmpABC123/SKILL.md")],
);
assert!(
!should_ignore(&ev),
"visible filename under hidden dir should NOT be ignored (file_name check only)"
);
}
// -----------------------------------------------------------------------
// TC-WB-09 [白盒] should_ignore: Rename event with visible filename → false
// -----------------------------------------------------------------------
#[test]
fn wb09_should_not_ignore_rename_visible() {
let ev = make_event(
EventKind::Modify(notify::event::ModifyKind::Name(RenameMode::Any)),
vec![PathBuf::from("/skills/SKILL.md")],
);
assert!(
!should_ignore(&ev),
"Rename (ModifyKind::Name) on visible file should NOT be ignored"
);
}
// -----------------------------------------------------------------------
// TC-WB-10 [白盒] should_ignore: path with no file_name component → false
//
// file_name() returns None for root ("/") or paths ending in "..".
// unwrap_or(false) means "don't ignore" — a safe default.
// -----------------------------------------------------------------------
#[test]
fn wb10_should_not_ignore_path_without_filename() {
let ev = make_event(
EventKind::Create(CreateKind::Any),
// Path "/" has no file_name() — unwrap_or(false) → not hidden
vec![PathBuf::from("/")],
);
assert!(
!should_ignore(&ev),
"path with no file_name (root) should NOT be ignored (unwrap_or(false))"
);
}
// -----------------------------------------------------------------------
// TC-WB-11 [白盒] watch_directory: duplicate call does not increase count
// -----------------------------------------------------------------------
#[tokio::test]
async fn wb11_watch_directory_duplicate_is_noop() {
let dir = tempfile::TempDir::new().unwrap();
let (mut watcher, _rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir.path().to_path_buf()]).unwrap();
let count_before = watcher.watched_dirs().len();
watcher.watch_directory(dir.path()).unwrap(); // second call, same dir
let count_after = watcher.watched_dirs().len();
assert_eq!(
count_before, count_after,
"duplicate watch_directory should not increase watched_dirs count"
);
}
// -----------------------------------------------------------------------
// TC-WB-12 [白盒] watch_directory after stop(): watcher is None → Ok (no panic)
// -----------------------------------------------------------------------
#[test]
fn wb12_watch_directory_after_stop_returns_ok() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let dir = tempfile::TempDir::new().unwrap();
let (mut watcher, _rx) = SkillWatcher::new().unwrap();
watcher.start(vec![]).unwrap();
watcher.stop(); // watcher.watcher is now None
// dir exists but watcher is None → skip silently → Ok
let result = watcher.watch_directory(dir.path());
assert!(
result.is_ok(),
"watch_directory after stop() should return Ok"
);
});
}
// -----------------------------------------------------------------------
// TC-WB-13 [白盒] stop() before start() does not panic (debounce_task is None)
// -----------------------------------------------------------------------
#[test]
fn wb13_stop_before_start_does_not_panic() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let (mut watcher, _rx) = SkillWatcher::new().unwrap();
watcher.stop(); // debounce_task is None
});
}
// -----------------------------------------------------------------------
// TC-WB-14 [白盒] watched_dirs() reflects directories added via start()
// -----------------------------------------------------------------------
#[tokio::test]
async fn wb14_watched_dirs_reflects_start_dirs() {
let dir_a = tempfile::TempDir::new().unwrap();
let dir_b = tempfile::TempDir::new().unwrap();
let (mut watcher, _rx) = SkillWatcher::new().unwrap();
watcher
.start(vec![dir_a.path().to_path_buf(), dir_b.path().to_path_buf()])
.unwrap();
let dirs = watcher.watched_dirs();
assert_eq!(dirs.len(), 2, "should have 2 watched dirs");
assert!(dirs.contains(&dir_a.path().to_path_buf()));
assert!(dirs.contains(&dir_b.path().to_path_buf()));
}
// -----------------------------------------------------------------------
// TC-WB-15 [白盒] watched_dirs() is empty after stop()
// -----------------------------------------------------------------------
#[tokio::test]
async fn wb15_watched_dirs_cleared_after_stop() {
let dir = tempfile::TempDir::new().unwrap();
let (mut watcher, _rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir.path().to_path_buf()]).unwrap();
assert_eq!(watcher.watched_dirs().len(), 1);
watcher.stop();
assert_eq!(
watcher.watched_dirs().len(),
0,
"should be empty after stop()"
);
}
// -----------------------------------------------------------------------
// TC-WB-16 [白盒] Drop impl calls stop() — no double-free / no panic
// -----------------------------------------------------------------------
#[tokio::test]
async fn wb16_drop_calls_stop_no_panic() {
let dir = tempfile::TempDir::new().unwrap();
{
let (mut watcher, _rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir.path().to_path_buf()]).unwrap();
// watcher dropped here — Drop::drop() calls stop()
}
// If we reach here without panic, the test passes.
}
}
@@ -0,0 +1,602 @@
/// Black-box tests for `SkillWatcher` based on the Phase 13 test plan.
///
/// All tests are async (`#[tokio::test]`) because the watcher relies on
/// tokio tasks for debouncing.
///
/// ## macOS path note
///
/// `tempfile::TempDir` creates directories under `/var/folders/.../T/.tmpXXXX`.
/// On macOS, FSEvents resolves symlinks, so the reported path becomes
/// `/private/var/folders/.../T/.tmpXXXX`. The `.tmpXXXX` directory name
/// starts with `.`, which causes `should_ignore` to filter ALL events from
/// such directories (it checks every path component, not just the filename).
///
/// To work around this, tests that rely on receiving notifications create
/// directories with visible (non-dot-prefixed) names under `/tmp/`.
/// Tests that verify silence (TC-14, TC-15) still use `TempDir` because
/// those directories only receive hidden-file events which should be filtered.
///
/// Debounce window is 300 ms. Tests that expect a notification wait 600 ms
/// (300 ms window + 300 ms platform margin). Tests that expect *no*
/// notification wait 800 ms to be safe.
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tempfile::TempDir;
use tokio::time::timeout;
use crate::discovery::RuntimeDiscovery;
use crate::watcher::SkillWatcher;
/// Create a uniquely named, non-hidden test directory under `/tmp/`.
///
/// Returns a `PathBuf` and a guard that removes the directory on drop.
/// Using `/tmp/` directly (rather than `TempDir`) avoids the macOS
/// `.tmpXXXX` hidden-directory naming that triggers `should_ignore`.
fn make_visible_test_dir(name: &str) -> (PathBuf, TempDirGuard) {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
// Use /private/tmp to match FSEvents resolved path on macOS
let base = if cfg!(target_os = "macos") {
PathBuf::from("/private/tmp")
} else {
std::env::temp_dir()
};
let dir = base.join(format!("nomi_watcher_test_{name}_{id}"));
fs::create_dir_all(&dir).expect("failed to create test dir");
let guard = TempDirGuard(dir.clone());
(dir, guard)
}
/// RAII guard that removes the test directory on drop.
struct TempDirGuard(PathBuf);
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
const DEBOUNCE_EXPECT_MS: u64 = 600; // wait when expecting a notification
const DEBOUNCE_NO_EXPECT_MS: u64 = 800; // wait when expecting silence
// Time to wait after start() before triggering events, giving notify time to
// register with the OS kernel (FSEvents/inotify initialisation latency).
const WATCHER_INIT_MS: u64 = 150;
// ---------------------------------------------------------------------------
// Diagnostic: verify notify events are received at all
// ---------------------------------------------------------------------------
/// Slow diagnostic test: uses a 1-second wait to rule out timing issues.
/// Run individually: cargo test watcher_tests::diag -- --nocapture --include-ignored
#[tokio::test]
#[ignore]
async fn diag_basic_event_received() {
let dir = TempDir::new().unwrap();
eprintln!("[diag] test dir: {}", dir.path().display());
let (mut watcher, mut rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir.path().to_path_buf()]).unwrap();
eprintln!("[diag] waiting 500ms for notify init...");
tokio::time::sleep(Duration::from_millis(500)).await;
let before = *rx.borrow_and_update();
eprintln!("[diag] version before: {before}");
let file = dir.path().join("test.md");
eprintln!("[diag] writing file: {}", file.display());
fs::write(&file, "hello").unwrap();
eprintln!("[diag] file written, waiting up to 1s...");
let result = timeout(Duration::from_millis(1000), rx.changed()).await;
eprintln!(
"[diag] timeout result (true=got event): {:?}",
result.is_ok()
);
let after = *rx.borrow();
eprintln!("[diag] version after: {after}");
assert!(result.is_ok(), "[diag] no event received within 1s");
}
/// Diagnostic: test with multi-thread runtime to rule out single-thread scheduling
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn diag_multi_thread_event_received() {
let dir = TempDir::new().unwrap();
eprintln!("[diag-mt] test dir: {}", dir.path().display());
let (mut watcher, mut rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir.path().to_path_buf()]).unwrap();
eprintln!("[diag-mt] waiting 500ms for notify init...");
tokio::time::sleep(Duration::from_millis(500)).await;
let before = *rx.borrow_and_update();
eprintln!("[diag-mt] version before: {before}");
let file = dir.path().join("test.md");
eprintln!("[diag-mt] writing file: {}", file.display());
fs::write(&file, "hello").unwrap();
eprintln!("[diag-mt] file written, waiting up to 2s...");
let result = timeout(Duration::from_millis(2000), rx.changed()).await;
eprintln!(
"[diag-mt] timeout result (true=got event): {:?}",
result.is_ok()
);
let after = *rx.borrow();
eprintln!("[diag-mt] version after: {after}");
assert!(result.is_ok(), "[diag-mt] no event received within 2s");
}
// ---------------------------------------------------------------------------
// TC-01: new() accepts empty directory list
// ---------------------------------------------------------------------------
/// [TC-01 黑盒] `new()` with no dirs returns Ok; initial version is 0.
#[tokio::test]
async fn tc01_new_empty_dirs_returns_ok() {
let (mut watcher, rx) = SkillWatcher::new().expect("new() should succeed");
watcher.start(vec![]).expect("start() should succeed");
let initial = *rx.borrow();
assert_eq!(initial, 0, "initial version should be 0");
}
// ---------------------------------------------------------------------------
// TC-02: new() accepts multiple existing directories
// ---------------------------------------------------------------------------
/// [TC-02 黑盒] `new()` with two real directories returns Ok.
#[tokio::test]
async fn tc02_new_with_existing_dirs_returns_ok() {
let dir_a = TempDir::new().unwrap();
let dir_b = TempDir::new().unwrap();
let (mut watcher, _rx) = SkillWatcher::new().expect("new() should succeed with existing dirs");
watcher
.start(vec![dir_a.path().to_path_buf(), dir_b.path().to_path_buf()])
.expect("start() should succeed");
}
// ---------------------------------------------------------------------------
// TC-03: new() skips non-existent directories (no panic, no Err)
// ---------------------------------------------------------------------------
/// [TC-03 黑盒] Non-existent directory is skipped silently; `new()` succeeds.
#[tokio::test]
async fn tc03_nonexistent_dir_skipped() {
let non_existent = std::path::PathBuf::from("/nonexistent/path/abc_phase13_test");
let (mut watcher, _rx) = SkillWatcher::new().expect("new() should succeed");
watcher
.start(vec![non_existent])
.expect("start() should not error for non-existent dirs");
}
// ---------------------------------------------------------------------------
// TC-04: mix of existing and non-existing directories
// ---------------------------------------------------------------------------
/// [TC-04 黑盒] Mix of existing and non-existing dirs — both handled without error.
#[tokio::test]
async fn tc04_mixed_dirs() {
let existing = TempDir::new().unwrap();
let non_existent = std::path::PathBuf::from("/nonexistent/xyz_phase13_test");
let (mut watcher, _rx) = SkillWatcher::new().expect("new() should succeed");
watcher
.start(vec![existing.path().to_path_buf(), non_existent])
.expect("start() should succeed for mixed dirs");
}
// ---------------------------------------------------------------------------
// TC-05: file creation triggers notification after debounce
// ---------------------------------------------------------------------------
/// [TC-05 黑盒] Creating a file in a watched directory triggers a version bump.
#[tokio::test]
async fn tc05_file_create_triggers_notification() {
let (dir, _guard) = make_visible_test_dir("tc05");
let (mut watcher, mut rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir.clone()]).unwrap();
// Wait for notify to register the watch with the OS.
tokio::time::sleep(Duration::from_millis(WATCHER_INIT_MS)).await;
let initial = *rx.borrow_and_update();
// Create a file to trigger an event.
fs::write(dir.join("SKILL.md"), "# test skill").unwrap();
let result = timeout(Duration::from_millis(DEBOUNCE_EXPECT_MS), rx.changed()).await;
assert!(
result.is_ok(),
"should receive notification within {}ms after file creation",
DEBOUNCE_EXPECT_MS
);
let new_version = *rx.borrow();
assert!(
new_version > initial,
"version should increment after file creation (was {initial}, now {new_version})"
);
}
// ---------------------------------------------------------------------------
// TC-06: file modification triggers notification
// ---------------------------------------------------------------------------
/// [TC-06 黑盒] Modifying an existing file triggers a version bump.
#[tokio::test]
async fn tc06_file_modify_triggers_notification() {
let (dir, _guard) = make_visible_test_dir("tc06");
let skill_file = dir.join("SKILL.md");
fs::write(&skill_file, "# initial").unwrap();
let (mut watcher, mut rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir.clone()]).unwrap();
// Wait for notify to initialise, then drain any creation event.
tokio::time::sleep(Duration::from_millis(WATCHER_INIT_MS + 400)).await;
let version_before = *rx.borrow_and_update();
// Modify the file.
fs::write(&skill_file, "# modified").unwrap();
let result = timeout(Duration::from_millis(DEBOUNCE_EXPECT_MS), rx.changed()).await;
assert!(
result.is_ok(),
"should receive notification within {}ms after file modification",
DEBOUNCE_EXPECT_MS
);
let new_version = *rx.borrow();
assert!(
new_version > version_before,
"version should increment after modification (was {version_before}, now {new_version})"
);
}
// ---------------------------------------------------------------------------
// TC-07: file deletion triggers notification
// ---------------------------------------------------------------------------
/// [TC-07 黑盒] Deleting a file in a watched directory triggers a version bump.
#[tokio::test]
async fn tc07_file_delete_triggers_notification() {
let (dir, _guard) = make_visible_test_dir("tc07");
let skill_file = dir.join("SKILL.md");
fs::write(&skill_file, "# to be deleted").unwrap();
let (mut watcher, mut rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir.clone()]).unwrap();
// Wait for notify init + drain creation event.
tokio::time::sleep(Duration::from_millis(WATCHER_INIT_MS + 400)).await;
let version_before = *rx.borrow_and_update();
// Delete the file.
fs::remove_file(&skill_file).unwrap();
let result = timeout(Duration::from_millis(DEBOUNCE_EXPECT_MS), rx.changed()).await;
assert!(
result.is_ok(),
"should receive notification within {}ms after file deletion",
DEBOUNCE_EXPECT_MS
);
let new_version = *rx.borrow();
assert!(
new_version > version_before,
"version should increment after deletion (was {version_before}, now {new_version})"
);
}
// ---------------------------------------------------------------------------
// TC-08: file rename triggers notification
// ---------------------------------------------------------------------------
/// [TC-08 黑盒] Renaming a file in a watched directory triggers a version bump.
#[tokio::test]
async fn tc08_file_rename_triggers_notification() {
let (dir, _guard) = make_visible_test_dir("tc08");
let old_file = dir.join("old.md");
fs::write(&old_file, "# old").unwrap();
let (mut watcher, mut rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir.clone()]).unwrap();
// Wait for notify init + drain creation event.
tokio::time::sleep(Duration::from_millis(WATCHER_INIT_MS + 400)).await;
let version_before = *rx.borrow_and_update();
// Rename the file.
fs::rename(&old_file, dir.join("SKILL.md")).unwrap();
let result = timeout(Duration::from_millis(DEBOUNCE_EXPECT_MS), rx.changed()).await;
assert!(
result.is_ok(),
"should receive notification within {}ms after file rename",
DEBOUNCE_EXPECT_MS
);
let new_version = *rx.borrow();
assert!(
new_version > version_before,
"version should increment after rename (was {version_before}, now {new_version})"
);
}
// ---------------------------------------------------------------------------
// TC-09: multiple events within 300ms are coalesced into one notification
// ---------------------------------------------------------------------------
/// [TC-09 黑盒] Five file writes within 100 ms result in only one version increment.
#[tokio::test]
async fn tc09_debounce_coalesces_multiple_events() {
let (dir, _guard) = make_visible_test_dir("tc09");
let (mut watcher, mut rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir.clone()]).unwrap();
// Wait for notify to initialise.
tokio::time::sleep(Duration::from_millis(WATCHER_INIT_MS)).await;
let initial = *rx.borrow_and_update();
// Write 5 files rapidly (within ~50 ms total).
for i in 0..5u32 {
fs::write(dir.join(format!("skill_{i}.md")), format!("# skill {i}")).unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
}
// Wait for the debounce window to expire plus margin.
tokio::time::sleep(Duration::from_millis(DEBOUNCE_EXPECT_MS)).await;
let final_version = *rx.borrow();
let increments = final_version - initial;
assert!(
increments >= 1,
"version should have incremented at least once (initial={initial}, final={final_version})"
);
// The key assertion: 5 rapid events should be coalesced into at most 2 notifications.
// Ideally 1, but allow a small margin for platform timing jitter.
assert!(
increments <= 2,
"debounce should coalesce rapid events into <=2 increments, got {increments} (initial={initial}, final={final_version})"
);
}
// ---------------------------------------------------------------------------
// TC-10: watch_directory() adds a new directory dynamically
// ---------------------------------------------------------------------------
/// [TC-10 黑盒] `watch_directory()` called after `start()` enables monitoring new dir.
#[tokio::test]
async fn tc10_watch_directory_dynamic_add() {
let (dir_a, _guard_a) = make_visible_test_dir("tc10a");
let (dir_b, _guard_b) = make_visible_test_dir("tc10b");
let (mut watcher, mut rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir_a.clone()]).unwrap();
// Dynamically add dir_b.
watcher
.watch_directory(&dir_b)
.expect("watch_directory() should succeed for existing dir");
// Wait for notify to register the new directory.
tokio::time::sleep(Duration::from_millis(WATCHER_INIT_MS)).await;
let version_before = *rx.borrow_and_update();
// Create file in newly added dir.
fs::write(dir_b.join("SKILL.md"), "# dynamic").unwrap();
let result = timeout(Duration::from_millis(DEBOUNCE_EXPECT_MS), rx.changed()).await;
assert!(
result.is_ok(),
"should receive notification from dynamically added directory"
);
let new_version = *rx.borrow();
assert!(
new_version > version_before,
"version should increment after event in dynamically added dir"
);
}
// ---------------------------------------------------------------------------
// TC-11: watch_directory() with non-existent dir does not panic
// ---------------------------------------------------------------------------
/// [TC-11 黑盒] `watch_directory()` on a non-existent dir does not panic or crash.
#[tokio::test]
async fn tc11_watch_directory_nonexistent_no_panic() {
let (mut watcher, _rx) = SkillWatcher::new().unwrap();
watcher.start(vec![]).unwrap();
let result = watcher.watch_directory(&std::path::PathBuf::from("/nonexistent/dynamic_test"));
// Should either return Ok (skip silently) or Err — but MUST NOT panic.
// Per AC-2 spirit, we expect Ok (silently skipped).
assert!(
result.is_ok(),
"watch_directory() should not error for non-existent dir per AC-2 skip semantics"
);
}
// ---------------------------------------------------------------------------
// TC-12: stop() prevents subsequent notifications
// ---------------------------------------------------------------------------
/// [TC-12 黑盒] After `stop()`, file changes in formerly watched dir do not trigger notifications.
#[tokio::test]
async fn tc12_stop_prevents_notifications() {
let (dir, _guard) = make_visible_test_dir("tc12");
let (mut watcher, mut rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir.clone()]).unwrap();
// Wait for notify init, then drain any initial events.
tokio::time::sleep(Duration::from_millis(WATCHER_INIT_MS)).await;
let version_before_stop = *rx.borrow_and_update();
watcher.stop();
// Give a brief moment for the OS to process the unwatch.
tokio::time::sleep(Duration::from_millis(50)).await;
// Create a file — should NOT trigger any notification.
fs::write(dir.join("after_stop.md"), "# after stop").unwrap();
tokio::time::sleep(Duration::from_millis(DEBOUNCE_NO_EXPECT_MS)).await;
let version_after = *rx.borrow();
assert_eq!(
version_after, version_before_stop,
"version should not change after stop() (was {version_before_stop}, got {version_after})"
);
}
// ---------------------------------------------------------------------------
// TC-13: stop() is idempotent (safe to call multiple times)
// ---------------------------------------------------------------------------
/// [TC-13 黑盒] Calling `stop()` twice does not panic.
#[tokio::test]
async fn tc13_stop_idempotent() {
let (mut watcher, _rx) = SkillWatcher::new().unwrap();
watcher.start(vec![]).unwrap();
watcher.stop();
watcher.stop(); // second call must not panic
}
// ---------------------------------------------------------------------------
// TC-14: hidden files (dot-prefixed) do not trigger notifications
// ---------------------------------------------------------------------------
/// [TC-14 黑盒] Creating a hidden file (`.swp`) does not trigger a version bump.
///
/// Uses a visible (non-dot-prefixed) parent directory so that normal files
/// *would* trigger a notification — confirming that only the hidden file is filtered.
///
/// `Modify(Metadata(_))` events are now filtered by `should_ignore`, so the
/// parent-directory metadata event emitted by macOS FSEvents is also suppressed.
#[tokio::test]
async fn tc14_hidden_file_not_triggers_notification() {
let (dir, _guard) = make_visible_test_dir("tc14");
let (mut watcher, mut rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir.clone()]).unwrap();
tokio::time::sleep(Duration::from_millis(WATCHER_INIT_MS)).await;
let version_before = *rx.borrow_and_update();
// Create a hidden file (editor swap file).
fs::write(dir.join(".swp"), "editor temp").unwrap();
tokio::time::sleep(Duration::from_millis(DEBOUNCE_NO_EXPECT_MS)).await;
let version_after = *rx.borrow();
assert_eq!(
version_after, version_before,
"hidden file creation should not increment version (was {version_before}, got {version_after})"
);
}
// ---------------------------------------------------------------------------
// TC-15: hidden dot-prefixed file does not trigger notification
// ---------------------------------------------------------------------------
/// [TC-15 黑盒] Creating `.hidden_skill.md` does not trigger a version bump.
///
/// Uses a visible (non-dot-prefixed) parent directory so that normal files
/// *would* trigger a notification — confirming that only the dot-prefixed file is filtered.
///
/// `Modify(Metadata(_))` filtering now suppresses macOS parent-dir metadata events.
#[tokio::test]
async fn tc15_dot_prefixed_file_not_triggers_notification() {
let (dir, _guard) = make_visible_test_dir("tc15");
let (mut watcher, mut rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir.clone()]).unwrap();
tokio::time::sleep(Duration::from_millis(WATCHER_INIT_MS)).await;
let version_before = *rx.borrow_and_update();
fs::write(dir.join(".hidden_skill.md"), "# hidden").unwrap();
tokio::time::sleep(Duration::from_millis(DEBOUNCE_NO_EXPECT_MS)).await;
let version_after = *rx.borrow();
assert_eq!(
version_after, version_before,
".hidden_skill.md should not increment version (was {version_before}, got {version_after})"
);
}
// ---------------------------------------------------------------------------
// TC-16: RuntimeDiscovery::clear_checked_dirs() exists and clears state
// ---------------------------------------------------------------------------
/// [TC-16 黑盒] `clear_checked_dirs()` method exists and empties the checked dirs cache.
#[test]
fn tc16_runtime_discovery_clear_checked_dirs() {
let mut discovery = RuntimeDiscovery::new();
// Verify the method is callable and clears state.
// We can't inspect private fields directly, so we verify behaviour via
// discover_dirs_for_paths returning results after clearing.
//
// The key assertion: calling clear_checked_dirs() does not panic.
discovery.clear_checked_dirs();
// Calling it a second time is also safe.
discovery.clear_checked_dirs();
}
// ---------------------------------------------------------------------------
// TC-17 & TC-18 are verified by CI: cargo clippy and cargo test
// (These are build-level assertions, not unit tests.)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// TC-20: version number is strictly monotonically increasing
// ---------------------------------------------------------------------------
/// [TC-20 黑盒] Version numbers strictly increase across multiple independent events.
#[tokio::test]
async fn tc20_version_monotonically_increasing() {
let (dir, _guard) = make_visible_test_dir("tc20");
let (mut watcher, mut rx) = SkillWatcher::new().unwrap();
watcher.start(vec![dir.clone()]).unwrap();
// Wait for notify to initialise.
tokio::time::sleep(Duration::from_millis(WATCHER_INIT_MS)).await;
let mut prev_version = *rx.borrow_and_update();
for round in 0..3u32 {
// Each round: create a unique file, then wait for the notification.
fs::write(
dir.join(format!("round_{round}.md")),
format!("# round {round}"),
)
.unwrap();
let result = timeout(Duration::from_millis(DEBOUNCE_EXPECT_MS), rx.changed()).await;
assert!(
result.is_ok(),
"round {round}: should receive notification within {DEBOUNCE_EXPECT_MS}ms"
);
let new_version = *rx.borrow_and_update();
assert!(
new_version > prev_version,
"round {round}: version should be strictly increasing (prev={prev_version}, new={new_version})"
);
prev_version = new_version;
}
}