阶段1拆解: stream 系外移 stream_relay.rs,mod.rs 3048→2607,loop_body 502→83 技能收尾: regenerate/edit 回溯补技能,新增 read_skill 工具,项目级技能扫描 DRY: emit 双写 46 处收敛 event_bus 正典,KV 引号归一收口,LOCK-SLOW 33 处单行化 警示骨架 4→1 与压缩兜底 2→1,噪音目录档位收敛,truncate 同名陷阱消除 工具元数据落 ToolMeta 八维,7 张 match 表改查 registry,parity 测试锁等价 命名: is_noise_entry/teardown_round/probe_missed_terminal_event 等 6 项纠偏
524 lines
21 KiB
Rust
524 lines
21 KiB
Rust
//! 本机 Claude 技能扫描(skills / commands / plugins 三类)
|
||
//!
|
||
//! 核心设计6(/ skill 修复):
|
||
//! - `strip_frontmatter` + `read_skill_content_stripped`:注入正文剥首个 `---...---` 块
|
||
//! (原 `read_skill_content` 保留兼容,返全文)
|
||
//! - `OnceLock<Vec>` → `RwLock<Option<Vec>>` + 快路径(读锁命中 clone)/ 慢路径(重扫)
|
||
//! + `invalidate_skills` 写锁置 None,支持进程内 `ai_reload_skills` 热重载
|
||
//! - `scan_skills` 返 `ScanResult{skills, conflicts}`:同名收集所有 path 入 conflicts,
|
||
//! 保留首份(优先级 project>skills>commands>plugins,项目级经 `ai_list_skills(project_path)`
|
||
//! 设 `PROJECT_SKILLS_ROOT` 后生效);`SkillInfo.duplicates` 仅冲突时填。
|
||
|
||
use std::collections::HashSet;
|
||
use std::fs;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::{RwLock, RwLockReadGuard};
|
||
|
||
use serde::Serialize;
|
||
|
||
use crate::commands::ai::tool_registry::{is_noise_entry, NoiseDirTier};
|
||
|
||
/// 技能元信息(前端 `/` 联想 + 后端注入用)
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct SkillInfo {
|
||
pub name: String,
|
||
pub description: String,
|
||
pub argument_hint: Option<String>,
|
||
/// skill | command | plugin
|
||
pub source: String,
|
||
/// SKILL.md 绝对路径(注入时读全文)
|
||
pub path: String,
|
||
/// 同名冲突时的其它来源路径(仅冲突时填 Some;首份为 None)。
|
||
///
|
||
/// 注入始终取首份(优先级 skills>commands>plugins),前端据非空 duplicates 提示用户
|
||
/// "存在同名技能 N 份,已用 {source} 来源"。无冲突为 None,向后兼容旧前端(字段缺省)。
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub duplicates: Option<Vec<String>>,
|
||
}
|
||
|
||
/// 扫描结果:去重后的 skills 列表 + 同名冲突明细(前端提示用)。
|
||
///
|
||
/// `conflicts`: `(name, 所有同名的 path 列表)` —— 注入取 skills[0](即 ScanResult.skills 中
|
||
/// 首份,优先级 skills>commands>plugins),冲突列表仅做提示,不影响注入。
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct ScanResult {
|
||
pub skills: Vec<SkillInfo>,
|
||
pub conflicts: Vec<(String, Vec<String>)>,
|
||
}
|
||
|
||
/// ~/.claude 目录(跨平台:USERPROFILE / HOME)
|
||
fn claude_home() -> Option<PathBuf> {
|
||
std::env::var_os("USERPROFILE")
|
||
.or_else(|| std::env::var_os("HOME"))
|
||
.map(PathBuf::from)
|
||
.map(|h| h.join(".claude"))
|
||
}
|
||
|
||
/// 项目级技能根目录(进程内,前端经 `ai_list_skills(project_path)` 设置)。
|
||
///
|
||
/// Some(root) 时 `scan_skills` 额外扫 `<root>/.claude/skills/*/SKILL.md`(source="project"),
|
||
/// 与 home 级三类一起参与去重/冲突;项目级优先级最高(同名覆盖 home,对齐 Claude Code
|
||
/// 项目技能语义)。None = 仅 home 级(与旧行为一致)。
|
||
/// 更新入口 `set_project_skills_root`(变更才触发重扫),`ai_list_skills` IPC 调用。
|
||
static PROJECT_SKILLS_ROOT: RwLock<Option<PathBuf>> = RwLock::new(None);
|
||
|
||
/// 更新项目级技能根目录。返回是否发生变更(调用方据 true 调 `invalidate_skills` 触发重扫)。
|
||
///
|
||
/// `None`/空串 = 不改动当前值(`ai_list_skills` 不传参沿用已设 root,remote_bridge 零参
|
||
/// 调用不受影响);重复传同值幂等(不触发重扫)。
|
||
pub(crate) fn set_project_skills_root(path: Option<String>) -> bool {
|
||
let new_root = path
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())
|
||
.map(PathBuf::from);
|
||
// 锁中毒:写不进就当"无变更"返回(下次 IPC 重试自愈,不 panic)。
|
||
let mut g = match RwLock::write(&PROJECT_SKILLS_ROOT) {
|
||
Ok(g) => g,
|
||
Err(_) => {
|
||
tracing::error!("PROJECT_SKILLS_ROOT 写锁中毒,set_project_skills_root 跳过");
|
||
return false;
|
||
}
|
||
};
|
||
if *g == new_root {
|
||
return false;
|
||
}
|
||
*g = new_root;
|
||
true
|
||
}
|
||
|
||
/// 读当前项目根快照(spawn_blocking 的 scan_skills 内同步读,clone 出避免持锁跨扫盘)。
|
||
fn project_skills_root_snapshot() -> Option<PathBuf> {
|
||
// 锁中毒降级 None(仅丢项目级技能,home 级不受影响)。
|
||
RwLock::read(&PROJECT_SKILLS_ROOT)
|
||
.ok()
|
||
.and_then(|g| g.clone())
|
||
}
|
||
|
||
/// 扫描项目级技能目录 `<root>/.claude/skills/*/SKILL.md`(单层,与 home skills 同构)。
|
||
fn scan_project_skills(root: &Path, out: &mut Vec<SkillInfo>) {
|
||
if let Ok(entries) = fs::read_dir(root.join(".claude").join("skills")) {
|
||
for entry in entries.flatten() {
|
||
if let Some(info) = parse_skill_file(&entry.path().join("SKILL.md"), "project") {
|
||
out.push(info);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 剥离 markdown 首个 `---...---` frontmatter 块,返回正文。
|
||
///
|
||
/// 状态机:用行索引推进;首个 `---`(trim 后)进入 in_fm,遇到下个 `---` 退出,余为正文。
|
||
/// 无 frontmatter(首行非 ---)直接返原文;frontmatter 未闭合(只有起始 --- 无收尾)
|
||
/// 按容错返空串(避免把整篇当 frontmatter 误剥,此分支极罕见 —— SKILL.md 都闭合)。
|
||
///
|
||
/// 注入用:原 `read_skill_content` 返全文(含 frontmatter)会让 LLM 看到 YAML 头噪声,
|
||
/// 改用 `read_skill_content_stripped` 后注入正文干净。
|
||
pub(crate) fn strip_frontmatter(md: &str) -> String {
|
||
let mut lines = md.lines().enumerate().peekable();
|
||
// 空串 / 首行非 --- → 无 frontmatter,返原文
|
||
let first = match lines.next() {
|
||
Some((_, l)) => l,
|
||
None => return String::new(),
|
||
};
|
||
if first.trim() != "---" {
|
||
// 无 frontmatter:返原文(首行 + 余下,用 \n 拼回)
|
||
let mut out = first.to_string();
|
||
for (_, l) in lines {
|
||
out.push('\n');
|
||
out.push_str(l);
|
||
}
|
||
return out;
|
||
}
|
||
// in_fm:跳过直到下个 ---
|
||
let mut body_start: Option<usize> = None;
|
||
for (i, l) in lines {
|
||
if l.trim() == "---" {
|
||
body_start = Some(i + 1);
|
||
break;
|
||
}
|
||
}
|
||
match body_start {
|
||
Some(start) => {
|
||
// 正文 = 原文第 start 行(0-based)起所有行
|
||
md.lines().skip(start).collect::<Vec<_>>().join("\n")
|
||
}
|
||
None => String::new(), // frontmatter 未闭合
|
||
}
|
||
}
|
||
|
||
/// 剥离 YAML 标量值两侧的引号(`"..."` / `'...'`),简易 frontmatter 解析用
|
||
fn unquote(s: &str) -> &str {
|
||
s.strip_prefix('"')
|
||
.and_then(|x| x.strip_suffix('"'))
|
||
.or_else(|| s.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')))
|
||
.unwrap_or(s)
|
||
}
|
||
|
||
/// 解析 markdown frontmatter 的 name / description / argument-hint / user_invocable
|
||
/// (简易,按行匹配,容错缩进与 CRLF;仅扫描 frontmatter 区段)
|
||
fn parse_frontmatter(md: &str) -> Option<(String, String, Option<String>, bool)> {
|
||
let mut lines = md.lines();
|
||
if lines.next()?.trim() != "---" {
|
||
return None;
|
||
}
|
||
let mut name = None;
|
||
let mut desc = None;
|
||
let mut hint = None;
|
||
let mut invocable = true;
|
||
for line in lines {
|
||
if line.trim() == "---" {
|
||
break;
|
||
}
|
||
let l = line.trim_start();
|
||
if let Some(v) = l.strip_prefix("name:") {
|
||
name = Some(unquote(v.trim()).to_string());
|
||
} else if let Some(v) = l.strip_prefix("description:") {
|
||
desc = Some(unquote(v.trim()).to_string());
|
||
} else if let Some(v) = l.strip_prefix("argument-hint:") {
|
||
hint = Some(unquote(v.trim()).to_string());
|
||
} else if let Some(v) = l.strip_prefix("user_invocable:") {
|
||
invocable = v.trim() != "false";
|
||
}
|
||
}
|
||
name.map(|n| (n, desc.unwrap_or_default(), hint, invocable))
|
||
}
|
||
|
||
/// 解析单个 SKILL.md / command md 为 SkillInfo(排除 user_invocable: false)
|
||
fn parse_skill_file(path: &Path, source: &str) -> Option<SkillInfo> {
|
||
let md = fs::read_to_string(path).ok()?;
|
||
let (name, description, argument_hint, invocable) = parse_frontmatter(&md).unwrap_or_else(|| {
|
||
// 无 frontmatter(部分 commands):用文件名兜底,默认可调用
|
||
let stem = path
|
||
.file_stem()
|
||
.map(|s| s.to_string_lossy().to_string())
|
||
.unwrap_or_default();
|
||
(stem, String::new(), None, true)
|
||
});
|
||
if !invocable {
|
||
return None;
|
||
}
|
||
Some(SkillInfo {
|
||
name,
|
||
description,
|
||
argument_hint,
|
||
source: source.to_string(),
|
||
path: path.to_string_lossy().to_string(),
|
||
duplicates: None,
|
||
})
|
||
}
|
||
|
||
/// 递归收集目录下所有 SKILL.md(用于 plugins/marketplaces 多层嵌套)
|
||
fn collect_skill_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||
if let Ok(entries) = fs::read_dir(dir) {
|
||
for entry in entries.flatten() {
|
||
let p = entry.path();
|
||
if p.is_dir() {
|
||
// 跳过依赖/VCS 目录,避免递归爆炸(SkillScan 最小集档,名单统一走 is_noise_entry)
|
||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||
if is_noise_entry(name, NoiseDirTier::SkillScan) {
|
||
continue;
|
||
}
|
||
collect_skill_files(&p, out);
|
||
} else if p.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") {
|
||
out.push(p);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 扫描全部来源,按 name 去重(project 优先 > skills > commands > plugins)。
|
||
///
|
||
/// 项目级:`PROJECT_SKILLS_ROOT` 已设时额外扫 `<root>/.claude/skills/*/SKILL.md`
|
||
/// (source="project"),排在首位(项目级同名覆盖 home 级,对齐 Claude Code 项目技能语义)。
|
||
///
|
||
/// 返 `ScanResult`:`skills` 为去重后首份(优先级保留),`conflicts` 收集所有同名 path
|
||
/// (仅当同名 > 1 时入列,前端提示用)。
|
||
fn scan_skills() -> ScanResult {
|
||
let home = match claude_home() {
|
||
Some(h) => h,
|
||
None => {
|
||
return ScanResult {
|
||
skills: Vec::new(),
|
||
conflicts: Vec::new(),
|
||
}
|
||
}
|
||
};
|
||
|
||
// 各来源按优先级顺序收集(project > skills > commands > plugins)
|
||
let mut all: Vec<Vec<SkillInfo>> = Vec::with_capacity(4);
|
||
|
||
// 0. <project>/.claude/skills/*/SKILL.md(项目级,PROJECT_SKILLS_ROOT 已设才扫)
|
||
let mut batch_project = Vec::new();
|
||
if let Some(root) = project_skills_root_snapshot() {
|
||
scan_project_skills(&root, &mut batch_project);
|
||
}
|
||
all.push(batch_project);
|
||
|
||
// 1. ~/.claude/skills/*/SKILL.md
|
||
let mut batch_skill = Vec::new();
|
||
if let Ok(entries) = fs::read_dir(home.join("skills")) {
|
||
for entry in entries.flatten() {
|
||
if let Some(info) = parse_skill_file(&entry.path().join("SKILL.md"), "skill") {
|
||
batch_skill.push(info);
|
||
}
|
||
}
|
||
}
|
||
all.push(batch_skill);
|
||
|
||
// 2. ~/.claude/commands/*.md
|
||
let mut batch_cmd = Vec::new();
|
||
if let Ok(entries) = fs::read_dir(home.join("commands")) {
|
||
for entry in entries.flatten() {
|
||
let p = entry.path();
|
||
if p.extension().and_then(|e| e.to_str()) == Some("md") {
|
||
if let Some(info) = parse_skill_file(&p, "command") {
|
||
batch_cmd.push(info);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
all.push(batch_cmd);
|
||
|
||
// 3. ~/.claude/plugins/marketplaces/**/skills/*/SKILL.md(递归;cache 不在此路径下)
|
||
let mut files = Vec::new();
|
||
collect_skill_files(&home.join("plugins").join("marketplaces"), &mut files);
|
||
let mut batch_plugin = Vec::new();
|
||
for f in files {
|
||
if let Some(info) = parse_skill_file(&f, "plugin") {
|
||
batch_plugin.push(info);
|
||
}
|
||
}
|
||
all.push(batch_plugin);
|
||
|
||
// 按 name 去重(首份优先级保留)+ 收集冲突
|
||
// name -> (首份 index in skills, 所有 path)
|
||
let mut seen: HashSet<String> = HashSet::new();
|
||
let mut skills: Vec<SkillInfo> = Vec::new();
|
||
// name -> Vec<path>(按来源顺序,用于冲突判定 + duplicates 回填)
|
||
let mut name_paths: std::collections::HashMap<String, Vec<String>> =
|
||
std::collections::HashMap::new();
|
||
|
||
for batch in &all {
|
||
for info in batch {
|
||
name_paths
|
||
.entry(info.name.clone())
|
||
.or_default()
|
||
.push(info.path.clone());
|
||
if seen.insert(info.name.clone()) {
|
||
skills.push(info.clone());
|
||
}
|
||
}
|
||
}
|
||
|
||
// 回填 duplicates + 构造 conflicts
|
||
let mut conflicts: Vec<(String, Vec<String>)> = Vec::new();
|
||
for skill in skills.iter_mut() {
|
||
if let Some(paths) = name_paths.get(&skill.name) {
|
||
if paths.len() > 1 {
|
||
// 首份 path 是 skill.path 本身,duplicates 填其余(按收集顺序)
|
||
let dups: Vec<String> = paths
|
||
.iter()
|
||
.filter(|p| **p != skill.path)
|
||
.cloned()
|
||
.collect();
|
||
if !dups.is_empty() {
|
||
skill.duplicates = Some(dups);
|
||
}
|
||
conflicts.push((skill.name.clone(), paths.clone()));
|
||
}
|
||
}
|
||
}
|
||
|
||
ScanResult { skills, conflicts }
|
||
}
|
||
|
||
/// 进程内技能缓存(RwLock + Option 懒初始化)。
|
||
///
|
||
/// 设计(核心设计6):
|
||
/// - 快路径:`skills_cached()` 读锁命中 → clone 返回(扫盘零开销)
|
||
/// - 慢路径:读锁 None → 释放后 `scan_skills()` 填充(双检锁,避免持写锁扫盘阻塞读)
|
||
/// - `invalidate_skills()` 写锁置 None,下次 `skills_cached()` 触发重扫
|
||
/// - `ai_reload_skills` IPC:invalidate + 重扫,进程内热重载(不重启生效)
|
||
static SKILLS: RwLock<Option<Vec<SkillInfo>>> = RwLock::new(None);
|
||
|
||
/// 读锁快路径 guard(供 `read_skill_content_stripped` 持引用迭代读缓存)。
|
||
///
|
||
/// 生命周期 `'static`:SKILLS 是 static 项,对其借用可标注 `'static`(Rust 对 static 的保证),
|
||
/// 使函数能返回 guard 跨作用域传递。guard Drop 时释放读锁。
|
||
type SkillsGuard = RwLockReadGuard<'static, Option<Vec<SkillInfo>>>;
|
||
|
||
/// 取读锁快照(懒初始化:None 时先释放锁扫盘填回,再读锁取引用)。
|
||
///
|
||
/// 返 `RwLockReadGuard<Option<Vec<SkillInfo>>>`,调用方解 `*guard` 得 `&Vec<SkillInfo>`。
|
||
/// 懒初始化走双检锁:先读锁查 Some(快),None 时释放 → 扫盘 → 写锁填回 → 读锁重取。
|
||
///
|
||
/// `scan_skills` 同步递归 `fs::read_dir` + `read_to_string`(plugins/marketplaces
|
||
/// 多层嵌套,Windows 文件多时同步阻塞 tokio runtime)。本函数改 async,慢路径扫盘包
|
||
/// `spawn_blocking` 隔离(对齐 commands/project.rs detect_stack 模式)。快路径(读锁命中)仍同步无 fs。
|
||
///
|
||
/// 锁中毒(加固):读写锁 expect 中毒会 panic,技能加载热路径 panic 不可接受。
|
||
/// 中毒 → tracing::error! 记录 + 返 None(调用方 `skills_cached` 得空 Vec / `read_skill_content_stripped`
|
||
/// 返 None),不再 panic。中毒通常因持锁 panicking 线程(早期改 *g 时 unwrap)残留,缓存本身可重建,
|
||
/// 返空后下次 `invalidate_skills` 或进程重启自愈。
|
||
async fn skills_lock_async() -> Option<SkillsGuard> {
|
||
// 快路径:读锁命中(无 fs,纯内存)
|
||
{
|
||
// 锁中毒不 panic:PoisonError 携 guard 仍可恢复数据,但缓存一致性不保 → 记录后返 None 降级空。
|
||
let g = match RwLock::read(&SKILLS) {
|
||
Ok(g) => g,
|
||
Err(_) => {
|
||
tracing::error!("SKILLS 读锁中毒,返空技能列表");
|
||
return None;
|
||
}
|
||
};
|
||
if g.is_some() {
|
||
return Some(g);
|
||
}
|
||
}
|
||
// 慢路径:扫盘(spawn_blocking 隔离同步 fs 递归,防阻塞 tokio runtime)
|
||
let scanned = tokio::task::spawn_blocking(scan_skills)
|
||
.await
|
||
.map(|res| res.skills)
|
||
.unwrap_or_default();
|
||
{
|
||
let mut g = match RwLock::write(&SKILLS) {
|
||
Ok(g) => g,
|
||
Err(_) => {
|
||
tracing::error!("SKILLS 写锁中毒,返空技能列表");
|
||
return None;
|
||
}
|
||
};
|
||
// 另一线程可能已填,二次检查(双检锁)
|
||
if g.is_none() {
|
||
*g = Some(scanned);
|
||
}
|
||
}
|
||
// 再取读锁返回(此时必 Some)
|
||
let g = match RwLock::read(&SKILLS) {
|
||
Ok(g) => g,
|
||
Err(_) => {
|
||
tracing::error!("SKILLS 读锁(慢路径后)中毒,返空技能列表");
|
||
return None;
|
||
}
|
||
};
|
||
debug_assert!(g.is_some(), "skills_lock 慢路径后必 Some");
|
||
Some(g)
|
||
}
|
||
|
||
/// 技能扫描结果缓存(进程内;命中即 clone,不重复扫盘)。
|
||
///
|
||
/// 替代原 `OnceLock::get_or_init` 路径:返 owned `Vec<SkillInfo>`(clone),
|
||
/// 因 RwLock 不能返 `&'static`。调用方(config.rs:30 / read_skill_content_stripped)已同步适配。
|
||
///
|
||
/// 改 async,慢路径(首次/重扫)走 `skills_lock_async` → spawn_blocking
|
||
/// 隔离同步 fs 防阻塞 tokio runtime(Tauri 单线程 runtime)。快路径(读锁命中)无 fs。
|
||
pub(crate) async fn skills_cached() -> Vec<SkillInfo> {
|
||
let g = skills_lock_async().await;
|
||
// 锁中毒 → None → unwrap_or_default() 得空 Vec(中毒降级)。
|
||
g.and_then(|g| g.clone()).unwrap_or_default()
|
||
}
|
||
|
||
/// 置缓存为 None,下次 `skills_cached()` 触发重扫。
|
||
///
|
||
/// `ai_reload_skills` IPC 调用:写锁置 None → 紧接 `skills_cached()` 重扫,
|
||
/// 实现"改技能不重启即生效"。
|
||
pub(crate) fn invalidate_skills() {
|
||
// 锁中毒:本就是想置 None 清缓存重建,但中毒时持锁线程已 panic,此处无法恢复一致状态。
|
||
// 记录错误并提前返回(下次加载由 skills_lock_async 中毒降级返空,进程重启自愈)。
|
||
let mut g = match RwLock::write(&SKILLS) {
|
||
Ok(g) => g,
|
||
Err(_) => {
|
||
tracing::error!("SKILLS 写锁中毒(invalidate_skills),跳过置 None");
|
||
return;
|
||
}
|
||
};
|
||
*g = None;
|
||
}
|
||
|
||
/// 按 name 读技能正文(剥首个 `---...---` frontmatter 块)。
|
||
///
|
||
/// 核心设计6:注入用正文,避免 YAML 头噪声污染 system prompt。
|
||
/// 缓存未命中返 None;文件读失败返 None。
|
||
///
|
||
/// 改 async,缓存懒初始化(可能触发扫盘)走 spawn_blocking 防阻塞 runtime。
|
||
///
|
||
/// 注:guard(std::sync::RwLockReadGuard 非 Send)必须在 spawn_blocking 的 .await 前 drop,
|
||
/// 否则非 Send 跨 await 点致 future 不 Send(MentionResolver 要求 Send)。guard 用 { } 限作用域。
|
||
pub(crate) async fn read_skill_content_stripped(name: String) -> Option<String> {
|
||
// 用 skills_cached(扫盘填充 + clone)替代 skills_lock_async(只读):防御 SKILLS 为 None
|
||
// (首次/invalidate 后)时 ? 早返 None 致技能不注入。skills_cached 触发 get_or_init 扫盘确保填充。
|
||
let path: Option<String> = {
|
||
let skills = skills_cached().await;
|
||
skills.iter().find(|s| s.name == name).map(|s| s.path.clone())
|
||
};
|
||
let path = match path {
|
||
Some(p) => p,
|
||
None => {
|
||
tracing::warn!(skill = %name, "[read_skill_content_stripped] 缓存中未找到该技能(name 不匹配/未扫到/user_invocable:false)");
|
||
return None;
|
||
}
|
||
};
|
||
// 单文件读(spawn_blocking 防 Windows fs 慢),细化诊断各失败步便于定位真断点。
|
||
// path move 进 spawn_blocking,先 clone 留诊断用(tracing 借用 move 后值会 E0382)。
|
||
let path_for_log = path.clone();
|
||
let md = match tokio::task::spawn_blocking(move || fs::read_to_string(&path)).await {
|
||
Ok(Ok(content)) => content,
|
||
Ok(Err(e)) => {
|
||
tracing::warn!(skill = %name, path = %path_for_log, error = %e, "[read_skill_content_stripped] 读 SKILL.md 失败(路径/权限/编码)");
|
||
return None;
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!(skill = %name, error = %e, "[read_skill_content_stripped] spawn_blocking join 失败");
|
||
return None;
|
||
}
|
||
};
|
||
Some(strip_frontmatter(&md))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn strip_frontmatter_normal() {
|
||
let md = "---\nname: foo\ndescription: bar\n---\n# Body\ncontent";
|
||
assert_eq!(strip_frontmatter(md), "# Body\ncontent");
|
||
}
|
||
|
||
#[test]
|
||
fn strip_frontmatter_no_fm() {
|
||
let md = "# Title\nbody line";
|
||
assert_eq!(strip_frontmatter(md), "# Title\nbody line");
|
||
}
|
||
|
||
#[test]
|
||
fn strip_frontmatter_empty() {
|
||
assert_eq!(strip_frontmatter(""), "");
|
||
}
|
||
|
||
#[test]
|
||
fn strip_frontmatter_only_fm() {
|
||
// 只有 frontmatter 无正文
|
||
let md = "---\nname: foo\n---\n";
|
||
assert_eq!(strip_frontmatter(md), "");
|
||
}
|
||
|
||
#[test]
|
||
fn strip_frontmatter_crlf() {
|
||
let md = "---\r\nname: foo\r\n---\r\nbody\r\n";
|
||
let out = strip_frontmatter(md);
|
||
assert!(out.contains("body"), "CRLF 正文应保留: {:?}", out);
|
||
}
|
||
|
||
#[test]
|
||
fn scan_result_dedup_and_conflicts() {
|
||
// 仅测去重逻辑(不依赖 ~/.claude 存在);通过 parse_skill_file 间接覆盖
|
||
// scan_skills 本身依赖文件系统,此处不集成测
|
||
let _ = ScanResult {
|
||
skills: vec![],
|
||
conflicts: vec![],
|
||
};
|
||
}
|
||
}
|