tirea_extension_skills/
registry.rs

1use crate::materialize::{load_asset_material, load_reference_material, run_script_material};
2use crate::skill_md::{parse_allowed_tools, parse_skill_md, SkillFrontmatter};
3use crate::{
4    ScriptResult, Skill, SkillError, SkillMaterializeError, SkillMeta, SkillResource,
5    SkillResourceKind, SkillWarning,
6};
7use async_trait::async_trait;
8use std::collections::HashMap;
9use std::fs;
10use std::io::{BufRead, BufReader};
11use std::path::{Path, PathBuf};
12use std::sync::mpsc;
13use std::sync::{Arc, Mutex, RwLock, Weak};
14use std::thread::JoinHandle;
15use std::time::Duration;
16use unicode_normalization::UnicodeNormalization;
17
18/// A filesystem-backed skill.
19///
20/// Each `FsSkill` owns its root directory and SKILL.md path. Resource loading
21/// and script execution are performed relative to `root_dir`.
22///
23/// Use [`FsSkill::discover`] to scan a directory for skills, or
24/// [`FsSkill::discover_roots`] for multiple directories.
25#[derive(Debug, Clone)]
26pub struct FsSkill {
27    meta: SkillMeta,
28    root_dir: PathBuf,
29    skill_md_path: PathBuf,
30}
31
32/// Result of a discovery scan: found skills and any warnings.
33#[derive(Debug, Clone)]
34pub struct DiscoveryResult {
35    pub skills: Vec<FsSkill>,
36    pub warnings: Vec<SkillWarning>,
37}
38
39pub trait SkillRegistry: Send + Sync {
40    fn len(&self) -> usize;
41
42    fn is_empty(&self) -> bool {
43        self.len() == 0
44    }
45
46    fn get(&self, id: &str) -> Option<Arc<dyn Skill>>;
47
48    fn ids(&self) -> Vec<String>;
49
50    fn snapshot(&self) -> HashMap<String, Arc<dyn Skill>>;
51
52    fn start_periodic_refresh(&self, _interval: Duration) -> Result<(), SkillRegistryManagerError> {
53        Ok(())
54    }
55
56    fn stop_periodic_refresh(&self) -> bool {
57        false
58    }
59
60    fn periodic_refresh_running(&self) -> bool {
61        false
62    }
63}
64
65#[derive(Debug, thiserror::Error)]
66pub enum SkillRegistryError {
67    #[error("skill id must be non-empty")]
68    EmptySkillId,
69
70    #[error("duplicate skill id: {0}")]
71    DuplicateSkillId(String),
72}
73
74#[derive(Clone, Default)]
75pub struct InMemorySkillRegistry {
76    skills: HashMap<String, Arc<dyn Skill>>,
77}
78
79impl std::fmt::Debug for InMemorySkillRegistry {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.debug_struct("InMemorySkillRegistry")
82            .field("len", &self.skills.len())
83            .finish()
84    }
85}
86
87impl InMemorySkillRegistry {
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    pub fn is_empty(&self) -> bool {
93        self.skills.is_empty()
94    }
95
96    pub fn from_skills(skills: Vec<Arc<dyn Skill>>) -> Self {
97        let mut registry = Self::new();
98        registry.extend_upsert(skills);
99        registry
100    }
101
102    pub fn register(&mut self, skill: Arc<dyn Skill>) -> Result<(), SkillRegistryError> {
103        let id = skill.meta().id.trim().to_string();
104        if id.is_empty() {
105            return Err(SkillRegistryError::EmptySkillId);
106        }
107        if self.skills.contains_key(&id) {
108            return Err(SkillRegistryError::DuplicateSkillId(id));
109        }
110        self.skills.insert(id, skill);
111        Ok(())
112    }
113
114    pub fn extend_upsert(&mut self, skills: Vec<Arc<dyn Skill>>) {
115        for skill in skills {
116            let id = skill.meta().id.trim().to_string();
117            if id.is_empty() {
118                continue;
119            }
120            self.skills.insert(id, skill);
121        }
122    }
123
124    pub fn extend_registry(&mut self, other: &dyn SkillRegistry) -> Result<(), SkillRegistryError> {
125        for (_, skill) in other.snapshot() {
126            self.register(skill)?;
127        }
128        Ok(())
129    }
130}
131
132impl SkillRegistry for InMemorySkillRegistry {
133    fn len(&self) -> usize {
134        self.skills.len()
135    }
136
137    fn get(&self, id: &str) -> Option<Arc<dyn Skill>> {
138        self.skills.get(id).cloned()
139    }
140
141    fn ids(&self) -> Vec<String> {
142        let mut ids: Vec<String> = self.skills.keys().cloned().collect();
143        ids.sort();
144        ids
145    }
146
147    fn snapshot(&self) -> HashMap<String, Arc<dyn Skill>> {
148        self.skills.clone()
149    }
150}
151
152#[derive(Clone, Default)]
153pub struct CompositeSkillRegistry {
154    registries: Vec<Arc<dyn SkillRegistry>>,
155    cached_snapshot: Arc<RwLock<HashMap<String, Arc<dyn Skill>>>>,
156}
157
158impl std::fmt::Debug for CompositeSkillRegistry {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        let snapshot = read_lock(&self.cached_snapshot);
161        f.debug_struct("CompositeSkillRegistry")
162            .field("registries", &self.registries.len())
163            .field("len", &snapshot.len())
164            .finish()
165    }
166}
167
168impl CompositeSkillRegistry {
169    pub fn try_new(
170        regs: impl IntoIterator<Item = Arc<dyn SkillRegistry>>,
171    ) -> Result<Self, SkillRegistryError> {
172        let registries: Vec<Arc<dyn SkillRegistry>> = regs.into_iter().collect();
173        let merged = Self::merge_snapshots(&registries)?;
174        Ok(Self {
175            registries,
176            cached_snapshot: Arc::new(RwLock::new(merged)),
177        })
178    }
179
180    fn merge_snapshots(
181        registries: &[Arc<dyn SkillRegistry>],
182    ) -> Result<HashMap<String, Arc<dyn Skill>>, SkillRegistryError> {
183        let mut merged = InMemorySkillRegistry::new();
184        for reg in registries {
185            merged.extend_registry(reg.as_ref())?;
186        }
187        Ok(merged.snapshot())
188    }
189
190    fn refresh_snapshot(&self) -> Result<HashMap<String, Arc<dyn Skill>>, SkillRegistryError> {
191        Self::merge_snapshots(&self.registries)
192    }
193
194    fn read_cached_snapshot(&self) -> HashMap<String, Arc<dyn Skill>> {
195        read_lock(&self.cached_snapshot).clone()
196    }
197
198    fn write_cached_snapshot(&self, snapshot: HashMap<String, Arc<dyn Skill>>) {
199        *write_lock(&self.cached_snapshot) = snapshot;
200    }
201}
202
203impl SkillRegistry for CompositeSkillRegistry {
204    fn len(&self) -> usize {
205        self.snapshot().len()
206    }
207
208    fn get(&self, id: &str) -> Option<Arc<dyn Skill>> {
209        self.snapshot().get(id).cloned()
210    }
211
212    fn ids(&self) -> Vec<String> {
213        let mut ids: Vec<String> = self.snapshot().keys().cloned().collect();
214        ids.sort();
215        ids
216    }
217
218    fn snapshot(&self) -> HashMap<String, Arc<dyn Skill>> {
219        match self.refresh_snapshot() {
220            Ok(snapshot) => {
221                self.write_cached_snapshot(snapshot.clone());
222                snapshot
223            }
224            Err(_) => self.read_cached_snapshot(),
225        }
226    }
227
228    fn start_periodic_refresh(&self, interval: Duration) -> Result<(), SkillRegistryManagerError> {
229        let mut started: Vec<Arc<dyn SkillRegistry>> = Vec::new();
230        for registry in &self.registries {
231            match registry.start_periodic_refresh(interval) {
232                Ok(()) => started.push(registry.clone()),
233                Err(SkillRegistryManagerError::PeriodicRefreshAlreadyRunning) => {}
234                Err(err) => {
235                    for reg in started {
236                        let _ = reg.stop_periodic_refresh();
237                    }
238                    return Err(err);
239                }
240            }
241        }
242        Ok(())
243    }
244
245    fn stop_periodic_refresh(&self) -> bool {
246        let mut stopped = false;
247        for registry in &self.registries {
248            stopped |= registry.stop_periodic_refresh();
249        }
250        stopped
251    }
252
253    fn periodic_refresh_running(&self) -> bool {
254        self.registries
255            .iter()
256            .any(|registry| registry.periodic_refresh_running())
257    }
258}
259
260#[derive(Debug, thiserror::Error)]
261pub enum SkillRegistryManagerError {
262    #[error(transparent)]
263    Skill(#[from] SkillError),
264
265    #[error(transparent)]
266    Registry(#[from] SkillRegistryError),
267
268    #[error("skill roots list must be non-empty")]
269    EmptyRoots,
270
271    #[error("periodic refresh interval must be > 0")]
272    InvalidRefreshInterval,
273
274    #[error("periodic refresh loop is already running")]
275    PeriodicRefreshAlreadyRunning,
276
277    #[error("periodic refresh join failed: {0}")]
278    Join(String),
279}
280
281#[derive(Clone, Default)]
282struct SkillRegistrySnapshot {
283    version: u64,
284    skills: HashMap<String, Arc<dyn Skill>>,
285    warnings: Vec<SkillWarning>,
286}
287
288struct PeriodicRefreshRuntime {
289    stop_tx: Option<mpsc::Sender<()>>,
290    join: JoinHandle<()>,
291}
292
293struct SkillRegistryState {
294    roots: Vec<PathBuf>,
295    snapshot: RwLock<SkillRegistrySnapshot>,
296    periodic_refresh: Mutex<Option<PeriodicRefreshRuntime>>,
297}
298
299fn read_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
300    match lock.read() {
301        Ok(guard) => guard,
302        Err(poisoned) => poisoned.into_inner(),
303    }
304}
305
306fn write_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
307    match lock.write() {
308        Ok(guard) => guard,
309        Err(poisoned) => poisoned.into_inner(),
310    }
311}
312
313fn mutex_lock<T>(lock: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
314    match lock.lock() {
315        Ok(guard) => guard,
316        Err(poisoned) => poisoned.into_inner(),
317    }
318}
319
320fn is_periodic_refresh_running(state: &SkillRegistryState) -> bool {
321    let mut runtime = mutex_lock(&state.periodic_refresh);
322    if runtime
323        .as_ref()
324        .is_some_and(|running| running.join.is_finished())
325    {
326        if let Some(finished) = runtime.take() {
327            let _ = finished.join.join();
328        }
329        return false;
330    }
331    runtime.is_some()
332}
333
334type DiscoverSnapshot = (HashMap<String, Arc<dyn Skill>>, Vec<SkillWarning>);
335
336fn discover_snapshot_from_roots(
337    roots: &[PathBuf],
338) -> Result<DiscoverSnapshot, SkillRegistryManagerError> {
339    let discovered = FsSkill::discover_roots(roots.to_vec())?;
340    let mut map: HashMap<String, Arc<dyn Skill>> = HashMap::new();
341    for skill in discovered.skills {
342        let arc = Arc::new(skill) as Arc<dyn Skill>;
343        let id = arc.meta().id.trim().to_string();
344        if id.is_empty() {
345            return Err(SkillRegistryError::EmptySkillId.into());
346        }
347        if map.insert(id.clone(), arc).is_some() {
348            return Err(SkillRegistryError::DuplicateSkillId(id).into());
349        }
350    }
351    Ok((map, discovered.warnings))
352}
353
354async fn refresh_state(state: &SkillRegistryState) -> Result<u64, SkillRegistryManagerError> {
355    let roots = state.roots.clone();
356    let handle = tokio::task::spawn_blocking(move || discover_snapshot_from_roots(&roots));
357    let (skills, warnings) = handle
358        .await
359        .map_err(|e| SkillRegistryManagerError::Join(e.to_string()))??;
360    Ok(apply_snapshot(state, skills, warnings))
361}
362
363fn apply_snapshot(
364    state: &SkillRegistryState,
365    skills: HashMap<String, Arc<dyn Skill>>,
366    warnings: Vec<SkillWarning>,
367) -> u64 {
368    let mut snapshot = write_lock(&state.snapshot);
369    let version = snapshot.version.saturating_add(1);
370    *snapshot = SkillRegistrySnapshot {
371        version,
372        skills,
373        warnings,
374    };
375    version
376}
377
378fn refresh_state_blocking(state: &SkillRegistryState) -> Result<u64, SkillRegistryManagerError> {
379    let (skills, warnings) = discover_snapshot_from_roots(&state.roots)?;
380    Ok(apply_snapshot(state, skills, warnings))
381}
382
383fn periodic_refresh_loop(
384    state: Weak<SkillRegistryState>,
385    interval: Duration,
386    stop_rx: mpsc::Receiver<()>,
387) {
388    loop {
389        match stop_rx.recv_timeout(interval) {
390            Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
391            Err(mpsc::RecvTimeoutError::Timeout) => {
392                let Some(state) = state.upgrade() else {
393                    break;
394                };
395                let _ = refresh_state_blocking(state.as_ref());
396            }
397        }
398    }
399}
400
401#[derive(Clone)]
402pub struct FsSkillRegistryManager {
403    state: Arc<SkillRegistryState>,
404}
405
406impl std::fmt::Debug for FsSkillRegistryManager {
407    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408        let snapshot = read_lock(&self.state.snapshot);
409        let periodic_running = is_periodic_refresh_running(self.state.as_ref());
410        f.debug_struct("FsSkillRegistryManager")
411            .field("roots", &self.state.roots.len())
412            .field("skills", &snapshot.skills.len())
413            .field("warnings", &snapshot.warnings.len())
414            .field("version", &snapshot.version)
415            .field("periodic_refresh_running", &periodic_running)
416            .finish()
417    }
418}
419
420impl FsSkillRegistryManager {
421    pub fn discover_roots(roots: Vec<PathBuf>) -> Result<Self, SkillRegistryManagerError> {
422        if roots.is_empty() {
423            return Err(SkillRegistryManagerError::EmptyRoots);
424        }
425        let (skills, warnings) = discover_snapshot_from_roots(&roots)?;
426        let snapshot = SkillRegistrySnapshot {
427            version: 1,
428            skills,
429            warnings,
430        };
431        Ok(Self {
432            state: Arc::new(SkillRegistryState {
433                roots,
434                snapshot: RwLock::new(snapshot),
435                periodic_refresh: Mutex::new(None),
436            }),
437        })
438    }
439
440    pub async fn refresh(&self) -> Result<u64, SkillRegistryManagerError> {
441        refresh_state(self.state.as_ref()).await
442    }
443
444    pub fn start_periodic_refresh(
445        &self,
446        interval: Duration,
447    ) -> Result<(), SkillRegistryManagerError> {
448        if interval.is_zero() {
449            return Err(SkillRegistryManagerError::InvalidRefreshInterval);
450        }
451        let mut runtime = mutex_lock(&self.state.periodic_refresh);
452        if runtime
453            .as_ref()
454            .is_some_and(|running| !running.join.is_finished())
455        {
456            return Err(SkillRegistryManagerError::PeriodicRefreshAlreadyRunning);
457        }
458        if let Some(finished) = runtime.take() {
459            let _ = finished.join.join();
460        }
461        let (stop_tx, stop_rx) = mpsc::channel();
462        let weak_state = Arc::downgrade(&self.state);
463        let join = std::thread::Builder::new()
464            .name("tirea-skills-registry-refresh".to_string())
465            .spawn(move || periodic_refresh_loop(weak_state, interval, stop_rx))
466            .map_err(|e| SkillRegistryManagerError::Join(e.to_string()))?;
467        *runtime = Some(PeriodicRefreshRuntime {
468            stop_tx: Some(stop_tx),
469            join,
470        });
471        Ok(())
472    }
473
474    pub fn stop_periodic_refresh(&self) -> bool {
475        let runtime = {
476            let mut guard = mutex_lock(&self.state.periodic_refresh);
477            guard.take()
478        };
479        let Some(mut runtime) = runtime else {
480            return false;
481        };
482        if let Some(stop_tx) = runtime.stop_tx.take() {
483            let _ = stop_tx.send(());
484        }
485        let _ = runtime.join.join();
486        true
487    }
488
489    pub fn periodic_refresh_running(&self) -> bool {
490        is_periodic_refresh_running(self.state.as_ref())
491    }
492
493    pub fn version(&self) -> u64 {
494        read_lock(&self.state.snapshot).version
495    }
496
497    pub fn warnings(&self) -> Vec<SkillWarning> {
498        read_lock(&self.state.snapshot).warnings.clone()
499    }
500}
501
502impl SkillRegistry for FsSkillRegistryManager {
503    fn len(&self) -> usize {
504        read_lock(&self.state.snapshot).skills.len()
505    }
506
507    fn get(&self, id: &str) -> Option<Arc<dyn Skill>> {
508        read_lock(&self.state.snapshot).skills.get(id).cloned()
509    }
510
511    fn ids(&self) -> Vec<String> {
512        let snapshot = read_lock(&self.state.snapshot);
513        let mut ids: Vec<String> = snapshot.skills.keys().cloned().collect();
514        ids.sort();
515        ids
516    }
517
518    fn snapshot(&self) -> HashMap<String, Arc<dyn Skill>> {
519        read_lock(&self.state.snapshot).skills.clone()
520    }
521
522    fn start_periodic_refresh(&self, interval: Duration) -> Result<(), SkillRegistryManagerError> {
523        FsSkillRegistryManager::start_periodic_refresh(self, interval)
524    }
525
526    fn stop_periodic_refresh(&self) -> bool {
527        FsSkillRegistryManager::stop_periodic_refresh(self)
528    }
529
530    fn periodic_refresh_running(&self) -> bool {
531        FsSkillRegistryManager::periodic_refresh_running(self)
532    }
533}
534
535impl FsSkill {
536    /// Discover all valid skills under a single root directory.
537    pub fn discover(root: impl Into<PathBuf>) -> Result<DiscoveryResult, SkillError> {
538        Self::discover_roots(vec![root.into()])
539    }
540
541    /// Discover skills under multiple root directories.
542    ///
543    /// Returns an error if duplicate skill IDs are found across roots.
544    pub fn discover_roots(roots: Vec<PathBuf>) -> Result<DiscoveryResult, SkillError> {
545        let mut skills: Vec<FsSkill> = Vec::new();
546        let mut warnings: Vec<SkillWarning> = Vec::new();
547        let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
548
549        for root in roots {
550            let (found, w) = discover_under_root(&root)?;
551            warnings.extend(w);
552
553            for skill in found {
554                if !seen_ids.insert(skill.meta.id.clone()) {
555                    return Err(SkillError::DuplicateSkillId(skill.meta.id));
556                }
557                skills.push(skill);
558            }
559        }
560
561        skills.sort_by(|a, b| a.meta.id.cmp(&b.meta.id));
562        warnings.sort_by(|a, b| a.path.cmp(&b.path));
563
564        Ok(DiscoveryResult { skills, warnings })
565    }
566
567    /// Collect discovered skills into a vec of trait objects.
568    pub fn into_arc_skills(skills: Vec<FsSkill>) -> Vec<Arc<dyn Skill>> {
569        skills
570            .into_iter()
571            .map(|s| Arc::new(s) as Arc<dyn Skill>)
572            .collect()
573    }
574}
575
576#[async_trait]
577impl Skill for FsSkill {
578    fn meta(&self) -> &SkillMeta {
579        &self.meta
580    }
581
582    async fn read_instructions(&self) -> Result<String, SkillError> {
583        fs::read_to_string(&self.skill_md_path).map_err(|e| {
584            SkillError::Io(format!(
585                "failed to read SKILL.md for skill '{}': {e}",
586                self.meta.id
587            ))
588        })
589    }
590
591    async fn load_resource(
592        &self,
593        kind: SkillResourceKind,
594        path: &str,
595    ) -> Result<SkillResource, SkillError> {
596        let root = self.root_dir.clone();
597        let skill_id = self.meta.id.clone();
598        let path = path.to_string();
599
600        let materialized: Result<SkillResource, SkillMaterializeError> =
601            tokio::task::spawn_blocking(move || match kind {
602                SkillResourceKind::Reference => {
603                    load_reference_material(&skill_id, &root, &path).map(SkillResource::Reference)
604                }
605                SkillResourceKind::Asset => {
606                    load_asset_material(&skill_id, &root, &path).map(SkillResource::Asset)
607                }
608            })
609            .await
610            .map_err(|e| SkillError::Io(e.to_string()))?;
611
612        materialized.map_err(SkillError::from)
613    }
614
615    async fn run_script(&self, script: &str, args: &[String]) -> Result<ScriptResult, SkillError> {
616        let result: Result<ScriptResult, SkillMaterializeError> =
617            run_script_material(&self.meta.id, &self.root_dir, script, args).await;
618        result.map_err(SkillError::from)
619    }
620}
621
622fn discover_under_root(root: &Path) -> Result<(Vec<FsSkill>, Vec<SkillWarning>), SkillError> {
623    let mut skills: Vec<FsSkill> = Vec::new();
624    let mut warnings: Vec<SkillWarning> = Vec::new();
625
626    let root = fs::canonicalize(root)
627        .map_err(|e| SkillError::Io(format!("failed to access skills root: {e}")))?;
628
629    let entries = fs::read_dir(&root)
630        .map_err(|e| SkillError::Io(format!("failed to read skills root: {e}")))?;
631
632    for entry in entries.flatten() {
633        let path = entry.path();
634        let ft = match entry.file_type() {
635            Ok(ft) => ft,
636            Err(_) => continue,
637        };
638        if !ft.is_dir() {
639            continue;
640        }
641        let dir_name_raw = match path.file_name().and_then(|s| s.to_str()) {
642            Some(s) => s.to_string(),
643            None => continue,
644        };
645        if dir_name_raw.starts_with('.') {
646            continue;
647        }
648
649        let dir_name = normalize_name(&dir_name_raw);
650        if let Err(reason) = validate_dir_name(&dir_name) {
651            warnings.push(SkillWarning {
652                path: path.clone(),
653                reason,
654            });
655            continue;
656        }
657
658        let skill_md = path.join("SKILL.md");
659        if !skill_md.is_file() {
660            warnings.push(SkillWarning {
661                path: path.clone(),
662                reason: "missing SKILL.md".to_string(),
663            });
664            continue;
665        }
666
667        match build_fs_skill(&dir_name, &skill_md) {
668            Ok(skill) => skills.push(skill),
669            Err(reason) => warnings.push(SkillWarning {
670                path: skill_md,
671                reason,
672            }),
673        }
674    }
675
676    Ok((skills, warnings))
677}
678
679fn build_fs_skill(dir_name: &str, skill_md: &Path) -> Result<FsSkill, String> {
680    let root_dir = skill_md
681        .parent()
682        .ok_or_else(|| "invalid skill path".to_string())?
683        .to_path_buf();
684
685    let SkillFrontmatter {
686        name,
687        description,
688        allowed_tools,
689        ..
690    } = read_frontmatter_from_skill_md_path(skill_md)?;
691
692    // Spec: name must match the parent directory name.
693    if name != dir_name {
694        return Err(format!(
695            "frontmatter name '{name}' does not match directory '{dir_name}'"
696        ));
697    }
698
699    let allowed_tools = allowed_tools
700        .as_deref()
701        .map(parse_allowed_tools)
702        .transpose()
703        .map_err(|e| e.to_string())?
704        .unwrap_or_default()
705        .into_iter()
706        .map(|t| t.raw)
707        .collect::<Vec<_>>();
708
709    Ok(FsSkill {
710        meta: SkillMeta {
711            id: name.clone(),
712            name,
713            description,
714            allowed_tools,
715        },
716        root_dir,
717        skill_md_path: skill_md.to_path_buf(),
718    })
719}
720
721fn read_frontmatter_from_skill_md_path(skill_md: &Path) -> Result<SkillFrontmatter, String> {
722    let file = fs::File::open(skill_md).map_err(|e| e.to_string())?;
723    let mut reader = BufReader::new(file);
724
725    let mut first = String::new();
726    let n = reader.read_line(&mut first).map_err(|e| e.to_string())?;
727    if n == 0 || trim_line_ending(&first) != "---" {
728        return Err("missing YAML frontmatter (expected leading '---' fence)".to_string());
729    }
730
731    let mut fm = String::new();
732    loop {
733        let mut line = String::new();
734        let read = reader.read_line(&mut line).map_err(|e| e.to_string())?;
735        if read == 0 {
736            return Err("unterminated YAML frontmatter (missing closing '---' fence)".to_string());
737        }
738        if trim_line_ending(&line) == "---" {
739            break;
740        }
741        fm.push_str(&line);
742    }
743
744    // Reuse the same parser/validator to keep behavior consistent.
745    let synthetic = format!("---\n{}---\n", fm);
746    parse_skill_md(&synthetic)
747        .map(|doc| doc.frontmatter)
748        .map_err(|e| e.to_string())
749}
750
751fn trim_line_ending(line: &str) -> &str {
752    line.trim_end_matches(['\n', '\r'])
753}
754
755fn normalize_name(s: &str) -> String {
756    s.trim().nfkc().collect::<String>()
757}
758
759fn validate_dir_name(dir_name: &str) -> Result<(), String> {
760    if dir_name.is_empty() {
761        return Err("directory name must be non-empty".to_string());
762    }
763    if dir_name.chars().count() > 64 {
764        return Err("directory name must be <= 64 characters".to_string());
765    }
766    if dir_name != dir_name.to_lowercase() {
767        return Err("directory name must be lowercase".to_string());
768    }
769    if dir_name.starts_with('-') {
770        return Err("directory name must not start with '-'".to_string());
771    }
772    if dir_name.ends_with('-') {
773        return Err("directory name must not end with '-'".to_string());
774    }
775    if dir_name.contains("--") {
776        return Err("directory name must not contain consecutive '-'".to_string());
777    }
778    if !dir_name.chars().all(|c| c.is_alphanumeric() || c == '-') {
779        return Err(
780            "directory name contains invalid characters (only letters, digits, and '-' are allowed)"
781                .to_string(),
782        );
783    }
784    Ok(())
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790    use async_trait::async_trait;
791    use std::collections::HashMap;
792    use std::sync::{Arc, RwLock};
793    use tempfile::TempDir;
794
795    #[test]
796    fn discover_skills_and_parses_frontmatter() {
797        let td = TempDir::new().unwrap();
798        let skills_root = td.path().join("skills");
799        fs::create_dir_all(skills_root.join("docx-processing")).unwrap();
800        fs::write(
801            skills_root.join("docx-processing").join("SKILL.md"),
802            r#"---
803name: docx-processing
804description: Docs
805allowed-tools: read_file
806---
807Body
808"#,
809        )
810        .unwrap();
811
812        let result = FsSkill::discover(&skills_root).unwrap();
813        assert_eq!(result.skills.len(), 1);
814        assert_eq!(result.skills[0].meta.id, "docx-processing");
815        assert_eq!(result.skills[0].meta.name, "docx-processing");
816        assert_eq!(
817            result.skills[0].meta.allowed_tools,
818            vec!["read_file".to_string()]
819        );
820    }
821
822    #[test]
823    fn discover_skips_invalid_skills_and_reports_warnings() {
824        let td = TempDir::new().unwrap();
825        let skills_root = td.path().join("skills");
826        fs::create_dir_all(skills_root.join("good-skill")).unwrap();
827        fs::create_dir_all(skills_root.join("BadSkill")).unwrap();
828        fs::create_dir_all(skills_root.join("missing-skill-md")).unwrap();
829        fs::write(
830            skills_root.join("good-skill").join("SKILL.md"),
831            "---\nname: good-skill\ndescription: ok\n---\nBody\n",
832        )
833        .unwrap();
834        fs::write(
835            skills_root.join("BadSkill").join("SKILL.md"),
836            "---\nname: badskill\ndescription: x\n---\nBody\n",
837        )
838        .unwrap();
839
840        let result = FsSkill::discover(&skills_root).unwrap();
841        assert_eq!(result.skills.len(), 1);
842        assert_eq!(result.skills[0].meta.id, "good-skill");
843        assert!(!result.warnings.is_empty());
844        assert!(result
845            .warnings
846            .iter()
847            .any(|w| w.reason.contains("directory name")));
848        assert!(result
849            .warnings
850            .iter()
851            .any(|w| w.reason.contains("missing SKILL.md")));
852    }
853
854    #[test]
855    fn discover_skips_name_mismatch() {
856        let td = TempDir::new().unwrap();
857        let skills_root = td.path().join("skills");
858        fs::create_dir_all(skills_root.join("good-skill")).unwrap();
859        fs::write(
860            skills_root.join("good-skill").join("SKILL.md"),
861            "---\nname: other-skill\ndescription: ok\n---\nBody\n",
862        )
863        .unwrap();
864
865        let result = FsSkill::discover(&skills_root).unwrap();
866        assert!(result.skills.is_empty());
867        assert!(result
868            .warnings
869            .iter()
870            .any(|w| w.reason.contains("does not match directory")));
871    }
872
873    #[test]
874    fn discover_does_not_recurse_into_nested_dirs() {
875        let td = TempDir::new().unwrap();
876        let skills_root = td.path().join("skills");
877        fs::create_dir_all(skills_root.join("good-skill").join("nested")).unwrap();
878        fs::write(
879            skills_root.join("good-skill").join("SKILL.md"),
880            "---\nname: good-skill\ndescription: ok\n---\nBody\n",
881        )
882        .unwrap();
883        fs::write(
884            skills_root
885                .join("good-skill")
886                .join("nested")
887                .join("SKILL.md"),
888            "---\nname: nested-skill\ndescription: ok\n---\nBody\n",
889        )
890        .unwrap();
891
892        let result = FsSkill::discover(&skills_root).unwrap();
893        assert_eq!(result.skills.len(), 1);
894        assert_eq!(result.skills[0].meta.id, "good-skill");
895    }
896
897    #[test]
898    fn discover_skips_hidden_dirs_and_root_files() {
899        let td = TempDir::new().unwrap();
900        let skills_root = td.path().join("skills");
901        fs::create_dir_all(skills_root.join(".hidden")).unwrap();
902        fs::write(
903            skills_root.join(".hidden").join("SKILL.md"),
904            "---\nname: hidden\ndescription: ok\n---\nBody\n",
905        )
906        .unwrap();
907        fs::write(
908            skills_root.join("not-a-skill"),
909            "---\nname: not-a-skill\ndescription: ok\n---\nBody\n",
910        )
911        .unwrap();
912
913        let result = FsSkill::discover(&skills_root).unwrap();
914        assert!(result.skills.is_empty());
915    }
916
917    #[test]
918    fn discover_allows_i18n_directory_names() {
919        let td = TempDir::new().unwrap();
920        let skills_root = td.path().join("skills");
921        fs::create_dir_all(skills_root.join("技能")).unwrap();
922        fs::write(
923            skills_root.join("技能").join("SKILL.md"),
924            "---\nname: 技能\ndescription: ok\n---\nBody\n",
925        )
926        .unwrap();
927
928        let result = FsSkill::discover(&skills_root).unwrap();
929        assert_eq!(result.skills.len(), 1);
930        assert_eq!(result.skills[0].meta.id, "技能");
931    }
932
933    #[tokio::test]
934    async fn read_instructions_reads_from_disk_lazily() {
935        let td = TempDir::new().unwrap();
936        let skills_root = td.path().join("skills");
937        fs::create_dir_all(skills_root.join("s1")).unwrap();
938        let skill_md = skills_root.join("s1").join("SKILL.md");
939        fs::write(&skill_md, "---\nname: s1\ndescription: ok\n---\nBody\n").unwrap();
940
941        let result = FsSkill::discover(&skills_root).unwrap();
942        let skill = &result.skills[0];
943        fs::remove_file(&skill_md).unwrap();
944
945        let err = skill.read_instructions().await.unwrap_err();
946        assert!(matches!(err, SkillError::Io(_)));
947    }
948
949    #[test]
950    fn discover_does_not_parse_markdown_body() {
951        let td = TempDir::new().unwrap();
952        let skills_root = td.path().join("skills");
953        fs::create_dir_all(skills_root.join("s1")).unwrap();
954        let skill_md = skills_root.join("s1").join("SKILL.md");
955        let mut bytes = b"---\nname: s1\ndescription: ok\n---\n".to_vec();
956        bytes.extend_from_slice(&[0xff, 0xfe, 0xfd]); // invalid UTF-8 body
957        fs::write(&skill_md, bytes).unwrap();
958
959        let result = FsSkill::discover(&skills_root).unwrap();
960        assert_eq!(result.skills.len(), 1);
961        assert_eq!(result.skills[0].meta.id, "s1");
962    }
963
964    #[test]
965    fn discover_errors_for_missing_root() {
966        let td = TempDir::new().unwrap();
967        let missing = td.path().join("missing");
968        let err = FsSkill::discover(&missing).unwrap_err();
969        assert!(matches!(err, SkillError::Io(_)));
970    }
971
972    #[test]
973    fn discover_rejects_duplicate_ids_across_roots() {
974        let td = TempDir::new().unwrap();
975        let root1 = td.path().join("skills1");
976        let root2 = td.path().join("skills2");
977        fs::create_dir_all(root1.join("s1")).unwrap();
978        fs::create_dir_all(root2.join("s1")).unwrap();
979        fs::write(
980            root1.join("s1").join("SKILL.md"),
981            "---\nname: s1\ndescription: ok\n---\nBody\n",
982        )
983        .unwrap();
984        fs::write(
985            root2.join("s1").join("SKILL.md"),
986            "---\nname: s1\ndescription: ok\n---\nBody\n",
987        )
988        .unwrap();
989
990        let err = FsSkill::discover_roots(vec![root1, root2]).unwrap_err();
991        assert!(matches!(err, SkillError::DuplicateSkillId(ref id) if id == "s1"));
992    }
993
994    #[test]
995    fn normalize_relative_name_nfkc() {
996        let s = "a\u{FF0D}b";
997        let norm = normalize_name(s);
998        assert!(!norm.is_empty());
999    }
1000
1001    #[test]
1002    fn validate_dir_name_rejects_parent_dir_like_segments() {
1003        assert!(validate_dir_name("a/b").is_err());
1004        assert!(validate_dir_name("..").is_err());
1005    }
1006
1007    #[tokio::test]
1008    async fn registry_manager_refresh_discovers_new_skill_without_rebuild() {
1009        let td = TempDir::new().unwrap();
1010        let root = td.path().join("skills");
1011        fs::create_dir_all(root.join("s1")).unwrap();
1012        fs::write(
1013            root.join("s1").join("SKILL.md"),
1014            "---\nname: s1\ndescription: ok\n---\nBody\n",
1015        )
1016        .unwrap();
1017
1018        let manager = FsSkillRegistryManager::discover_roots(vec![root.clone()]).unwrap();
1019        assert_eq!(manager.version(), 1);
1020        assert_eq!(manager.ids(), vec!["s1".to_string()]);
1021
1022        fs::create_dir_all(root.join("s2")).unwrap();
1023        fs::write(
1024            root.join("s2").join("SKILL.md"),
1025            "---\nname: s2\ndescription: ok\n---\nBody\n",
1026        )
1027        .unwrap();
1028
1029        let version = manager.refresh().await.unwrap();
1030        assert_eq!(version, 2);
1031        assert_eq!(manager.ids(), vec!["s1".to_string(), "s2".to_string()]);
1032    }
1033
1034    #[tokio::test]
1035    async fn registry_manager_failed_refresh_keeps_last_good_snapshot() {
1036        let td = TempDir::new().unwrap();
1037        let root = td.path().join("skills");
1038        fs::create_dir_all(root.join("s1")).unwrap();
1039        fs::write(
1040            root.join("s1").join("SKILL.md"),
1041            "---\nname: s1\ndescription: ok\n---\nBody\n",
1042        )
1043        .unwrap();
1044
1045        let manager = FsSkillRegistryManager::discover_roots(vec![root.clone()]).unwrap();
1046        let initial_ids = manager.ids();
1047        fs::remove_dir_all(&root).unwrap();
1048
1049        let err = manager.refresh().await.err().unwrap();
1050        assert!(matches!(err, SkillRegistryManagerError::Skill(_)));
1051        assert_eq!(manager.version(), 1);
1052        assert_eq!(manager.ids(), initial_ids);
1053    }
1054
1055    #[derive(Debug)]
1056    struct MockSkill {
1057        meta: SkillMeta,
1058    }
1059
1060    impl MockSkill {
1061        fn new(id: &str) -> Self {
1062            Self {
1063                meta: SkillMeta {
1064                    id: id.to_string(),
1065                    name: id.to_string(),
1066                    description: format!("{id} desc"),
1067                    allowed_tools: Vec::new(),
1068                },
1069            }
1070        }
1071    }
1072
1073    #[async_trait]
1074    impl Skill for MockSkill {
1075        fn meta(&self) -> &SkillMeta {
1076            &self.meta
1077        }
1078
1079        async fn read_instructions(&self) -> Result<String, SkillError> {
1080            Ok(format!(
1081                "---\nname: {}\ndescription: ok\n---\nBody\n",
1082                self.meta.id
1083            ))
1084        }
1085
1086        async fn load_resource(
1087            &self,
1088            _kind: SkillResourceKind,
1089            _path: &str,
1090        ) -> Result<SkillResource, SkillError> {
1091            Err(SkillError::Unsupported("not used".to_string()))
1092        }
1093
1094        async fn run_script(
1095            &self,
1096            _script: &str,
1097            _args: &[String],
1098        ) -> Result<ScriptResult, SkillError> {
1099            Err(SkillError::Unsupported("not used".to_string()))
1100        }
1101    }
1102
1103    #[derive(Default)]
1104    struct MutableSkillRegistry {
1105        skills: RwLock<HashMap<String, Arc<dyn Skill>>>,
1106    }
1107
1108    impl MutableSkillRegistry {
1109        fn replace_ids(&self, ids: &[&str]) {
1110            let mut map: HashMap<String, Arc<dyn Skill>> = HashMap::new();
1111            for id in ids {
1112                map.insert(
1113                    (*id).to_string(),
1114                    Arc::new(MockSkill::new(id)) as Arc<dyn Skill>,
1115                );
1116            }
1117            match self.skills.write() {
1118                Ok(mut guard) => *guard = map,
1119                Err(poisoned) => *poisoned.into_inner() = map,
1120            }
1121        }
1122    }
1123
1124    impl SkillRegistry for MutableSkillRegistry {
1125        fn len(&self) -> usize {
1126            self.snapshot().len()
1127        }
1128
1129        fn get(&self, id: &str) -> Option<Arc<dyn Skill>> {
1130            self.snapshot().get(id).cloned()
1131        }
1132
1133        fn ids(&self) -> Vec<String> {
1134            let mut ids: Vec<String> = self.snapshot().keys().cloned().collect();
1135            ids.sort();
1136            ids
1137        }
1138
1139        fn snapshot(&self) -> HashMap<String, Arc<dyn Skill>> {
1140            match self.skills.read() {
1141                Ok(guard) => guard.clone(),
1142                Err(poisoned) => poisoned.into_inner().clone(),
1143            }
1144        }
1145    }
1146
1147    #[test]
1148    fn composite_skill_registry_reads_live_updates_from_source_registries() {
1149        let dynamic = Arc::new(MutableSkillRegistry::default());
1150        dynamic.replace_ids(&["s1"]);
1151
1152        let mut static_registry = InMemorySkillRegistry::new();
1153        static_registry
1154            .register(Arc::new(MockSkill::new("s_static")) as Arc<dyn Skill>)
1155            .expect("register static skill");
1156
1157        let composite = CompositeSkillRegistry::try_new(vec![
1158            dynamic.clone() as Arc<dyn SkillRegistry>,
1159            Arc::new(static_registry) as Arc<dyn SkillRegistry>,
1160        ])
1161        .expect("compose registries");
1162
1163        assert!(composite.ids().contains(&"s1".to_string()));
1164        assert!(composite.ids().contains(&"s_static".to_string()));
1165
1166        dynamic.replace_ids(&["s1", "s2"]);
1167        let ids = composite.ids();
1168        assert!(ids.contains(&"s1".to_string()));
1169        assert!(ids.contains(&"s2".to_string()));
1170        assert!(ids.contains(&"s_static".to_string()));
1171    }
1172
1173    #[test]
1174    fn composite_skill_registry_keeps_last_good_snapshot_on_runtime_conflict() {
1175        let reg_a = Arc::new(MutableSkillRegistry::default());
1176        reg_a.replace_ids(&["s1"]);
1177        let reg_b = Arc::new(MutableSkillRegistry::default());
1178        reg_b.replace_ids(&["s2"]);
1179
1180        let composite = CompositeSkillRegistry::try_new(vec![
1181            reg_a.clone() as Arc<dyn SkillRegistry>,
1182            reg_b.clone() as Arc<dyn SkillRegistry>,
1183        ])
1184        .expect("compose registries");
1185
1186        let initial_ids = composite.ids();
1187        assert_eq!(initial_ids, vec!["s1".to_string(), "s2".to_string()]);
1188
1189        reg_b.replace_ids(&["s1"]);
1190        assert_eq!(composite.ids(), initial_ids);
1191        assert!(composite.get("s2").is_some());
1192    }
1193}