Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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(¤t);
|
||||
|
||||
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(¤t) == 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: ®ex::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: ®ex::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: ®ex::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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user