Skip to main content

navi_core/skills/
store.rs

1//! Filesystem-backed skill store with optional **pools** (folders).
2//!
3//! Layout:
4//! ```text
5//! {data_dir}/skills/
6//!   <skill-id>/SKILL.md              # root-level skill
7//!   <pool-id>/
8//!     POOL.md                        # pool metadata
9//!     <skill-id>/SKILL.md            # skill inside pool
10//! {project}/.navi/skills/            # same shape for project scope
11//! ```
12
13use super::{
14    ParsedSkillFile, SkillManifest, SkillSource, SkillWriteRequest, SkillWriteResult,
15    SkillWriteScope, parse_skill_md, resolve_skill_id, slugify_skill_id,
16};
17use anyhow::{Context, Result};
18use std::fs;
19use std::path::{Path, PathBuf};
20
21/// Local skill store rooted at `data_dir` (user) and optionally `project_dir` (project).
22pub struct SkillStore {
23    data_dir: PathBuf,
24    project_dir: Option<PathBuf>,
25}
26
27/// A skill pool = folder of related skills (shown as one catalog entry until opened).
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct SkillPool {
30    pub id: String,
31    pub name: String,
32    pub description: Option<String>,
33    pub scope: SkillWriteScope,
34    pub path: PathBuf,
35    pub skill_count: usize,
36}
37
38impl SkillStore {
39    /// Opens the filesystem skill store under `<data_dir>/skills/`.
40    pub fn open(data_dir: &Path) -> Result<Self> {
41        let root = data_dir.join("skills");
42        fs::create_dir_all(&root)
43            .with_context(|| format!("failed to create skills dir {}", root.display()))?;
44        let store = Self {
45            data_dir: data_dir.to_path_buf(),
46            project_dir: None,
47        };
48        if let Err(err) = store.migrate_from_sqlite_if_present() {
49            tracing::warn!(error = %err, "legacy skills.sqlite migration skipped");
50        }
51        Ok(store)
52    }
53
54    pub fn open_with_project(data_dir: &Path, project_dir: &Path) -> Result<Self> {
55        let mut store = Self::open(data_dir)?;
56        store.project_dir = Some(project_dir.to_path_buf());
57        Ok(store)
58    }
59
60    #[cfg(test)]
61    pub fn open_memory() -> Result<Self> {
62        let dir = tempfile::tempdir().context("tempdir for skill store")?;
63        let path = dir.keep();
64        Self::open(&path)
65    }
66
67    pub fn path(&self) -> PathBuf {
68        self.data_dir.join("skills")
69    }
70
71    fn user_root(&self) -> PathBuf {
72        self.data_dir.join("skills")
73    }
74
75    fn project_root(&self) -> Option<PathBuf> {
76        self.project_dir
77            .as_ref()
78            .map(|p| p.join(".navi").join("skills"))
79    }
80
81    fn skill_file_path(
82        &self,
83        id: &str,
84        pool: Option<&str>,
85        scope: SkillWriteScope,
86    ) -> Result<PathBuf> {
87        let root = match scope {
88            SkillWriteScope::User => self.user_root(),
89            SkillWriteScope::Project => self.project_root().ok_or_else(|| {
90                anyhow::anyhow!("project-scoped skill requires an active project")
91            })?,
92        };
93        Ok(match pool.filter(|p| !p.is_empty()) {
94            Some(pool) => root.join(pool).join(id).join("SKILL.md"),
95            None => root.join(id).join("SKILL.md"),
96        })
97    }
98
99    /// Lists **all** skills (root + every pool member). Used by load_skill / tests.
100    pub fn list_all(&self) -> Result<Vec<SkillManifest>> {
101        let mut out = list_skills_recursive(&self.user_root(), SkillWriteScope::User, None)?;
102        if let Some(root) = self.project_root() {
103            out.extend(list_skills_recursive(
104                &root,
105                SkillWriteScope::Project,
106                None,
107            )?);
108        }
109        out.sort_by(|a, b| a.pool.cmp(&b.pool).then_with(|| a.id.cmp(&b.id)));
110        out.dedup_by(|a, b| a.id == b.id && a.pool == b.pool);
111        Ok(out)
112    }
113
114    /// Root-level skills only (not inside a pool). For prompt catalog.
115    pub fn list_root_skills(&self) -> Result<Vec<SkillManifest>> {
116        let mut out = list_root_skills_in(&self.user_root(), SkillWriteScope::User)?;
117        if let Some(root) = self.project_root() {
118            out.extend(list_root_skills_in(&root, SkillWriteScope::Project)?);
119        }
120        out.sort_by(|a, b| a.id.cmp(&b.id));
121        out.dedup_by(|a, b| a.id == b.id);
122        Ok(out)
123    }
124
125    /// All pools (folders) under user + project roots.
126    pub fn list_pools(&self) -> Result<Vec<SkillPool>> {
127        let mut out = list_pools_in(&self.user_root(), SkillWriteScope::User)?;
128        if let Some(root) = self.project_root() {
129            out.extend(list_pools_in(&root, SkillWriteScope::Project)?);
130        }
131        out.sort_by(|a, b| a.id.cmp(&b.id));
132        out.dedup_by(|a, b| a.id == b.id);
133        Ok(out)
134    }
135
136    /// Skills inside one pool (metadata + instructions for load).
137    pub fn list_pool_skills(&self, pool_id: &str) -> Result<Vec<SkillManifest>> {
138        let pool = slugify_skill_id(pool_id);
139        let mut out = Vec::new();
140        let user_pool = self.user_root().join(&pool);
141        if user_pool.is_dir() {
142            out.extend(list_skills_in_pool_dir(
143                &user_pool,
144                &pool,
145                SkillWriteScope::User,
146            )?);
147        }
148        if let Some(root) = self.project_root() {
149            let p = root.join(&pool);
150            if p.is_dir() {
151                out.extend(list_skills_in_pool_dir(
152                    &p,
153                    &pool,
154                    SkillWriteScope::Project,
155                )?);
156            }
157        }
158        out.sort_by(|a, b| a.id.cmp(&b.id));
159        out.dedup_by(|a, b| a.id == b.id);
160        Ok(out)
161    }
162
163    pub fn list_for_discovery(&self, _project_key: Option<&str>) -> Result<Vec<SkillManifest>> {
164        self.list_all()
165    }
166
167    pub fn get(&self, id: &str) -> Result<Option<SkillManifest>> {
168        self.get_in_pool(id, None)
169    }
170
171    /// Resolve skill by id, optionally restricted to a pool. Also tries `pool/id` form.
172    pub fn get_in_pool(&self, id: &str, pool: Option<&str>) -> Result<Option<SkillManifest>> {
173        let (pool_hint, skill_id) = split_pool_skill_ref(id, pool);
174        let skill_id = slugify_skill_id(&skill_id);
175        let pool_hint = pool_hint.map(|p| slugify_skill_id(&p));
176
177        // Prefer project over user.
178        for scope in [SkillWriteScope::Project, SkillWriteScope::User] {
179            if matches!(scope, SkillWriteScope::Project) && self.project_dir.is_none() {
180                continue;
181            }
182            if let Some(ref p) = pool_hint {
183                let path = self.skill_file_path(&skill_id, Some(p), scope)?;
184                if path.is_file() {
185                    return Ok(Some(load_skill_md(&path, scope, Some(p))?));
186                }
187            } else {
188                // Root first, then search all pools.
189                let root_path = self.skill_file_path(&skill_id, None, scope)?;
190                if root_path.is_file() {
191                    return Ok(Some(load_skill_md(&root_path, scope, None)?));
192                }
193                let root = match scope {
194                    SkillWriteScope::User => self.user_root(),
195                    SkillWriteScope::Project => self.project_root().unwrap(),
196                };
197                if let Some(found) = find_skill_in_pools(&root, &skill_id, scope)? {
198                    return Ok(Some(found));
199                }
200            }
201        }
202        Ok(None)
203    }
204
205    pub fn upsert(
206        &self,
207        request: &SkillWriteRequest,
208        _project_key: Option<&str>,
209    ) -> Result<SkillWriteResult> {
210        let id = resolve_skill_id(request)?;
211        let name = request.name.trim();
212        if name.is_empty() {
213            return Err(anyhow::anyhow!("skill name is required"));
214        }
215        let instructions = request.instructions.trim();
216        if instructions.is_empty() {
217            return Err(anyhow::anyhow!("skill instructions cannot be empty"));
218        }
219        if matches!(request.scope, SkillWriteScope::Project) && self.project_dir.is_none() {
220            return Err(anyhow::anyhow!(
221                "project-scoped skill requires an active project"
222            ));
223        }
224
225        let pool = request
226            .pool
227            .as_deref()
228            .map(str::trim)
229            .filter(|s| !s.is_empty())
230            .map(slugify_skill_id);
231
232        if let Some(ref p) = pool {
233            ensure_pool_meta(
234                match request.scope {
235                    SkillWriteScope::User => self.user_root().join(p),
236                    SkillWriteScope::Project => self
237                        .project_root()
238                        .ok_or_else(|| {
239                            anyhow::anyhow!("project-scoped skill requires an active project")
240                        })?
241                        .join(p),
242                },
243                p,
244            )?;
245        }
246
247        let path = self.skill_file_path(&id, pool.as_deref(), request.scope)?;
248        let created = !path.is_file();
249        if let Some(parent) = path.parent() {
250            fs::create_dir_all(parent)
251                .with_context(|| format!("failed to create skill dir {}", parent.display()))?;
252        }
253
254        let body = render_skill_md(request, &id);
255        fs::write(&path, body)
256            .with_context(|| format!("failed to write skill file {}", path.display()))?;
257
258        let skill = load_skill_md(&path, request.scope, pool.as_deref())?;
259        Ok(SkillWriteResult {
260            skill,
261            path,
262            created,
263        })
264    }
265
266    pub fn delete(&self, id: &str) -> Result<bool> {
267        let (pool_hint, skill_id) = split_pool_skill_ref(id, None);
268        let skill_id = slugify_skill_id(&skill_id);
269        let mut deleted = false;
270
271        if let Some(skill) = self.get_in_pool(&skill_id, pool_hint.as_deref())? {
272            if let Some(parent) = skill.path.parent() {
273                if parent.is_dir() {
274                    fs::remove_dir_all(parent).with_context(|| {
275                        format!("failed to delete skill dir {}", parent.display())
276                    })?;
277                    deleted = true;
278                }
279            }
280        }
281        Ok(deleted)
282    }
283
284    fn migrate_from_sqlite_if_present(&self) -> Result<()> {
285        let sqlite_path = self.data_dir.join("skills.sqlite");
286        if !sqlite_path.is_file() {
287            return Ok(());
288        }
289        let conn = rusqlite::Connection::open(&sqlite_path)
290            .with_context(|| format!("open legacy {}", sqlite_path.display()))?;
291        let mut stmt = match conn.prepare(
292            "SELECT id, name, description, version, author, tags, requires, allow_tools, deny_tools,
293                    instructions, scope,
294                    COALESCE(harness, 0)
295             FROM skills",
296        ) {
297            Ok(s) => s,
298            Err(_) => conn.prepare(
299                "SELECT id, name, description, version, author, tags, requires, allow_tools, deny_tools,
300                        instructions, scope, 0
301                 FROM skills",
302            )?,
303        };
304        let rows = stmt.query_map([], |row| {
305            Ok((
306                row.get::<_, String>(0)?,
307                row.get::<_, String>(1)?,
308                row.get::<_, Option<String>>(2)?,
309                row.get::<_, Option<String>>(3)?,
310                row.get::<_, Option<String>>(4)?,
311                row.get::<_, String>(5).unwrap_or_else(|_| "[]".into()),
312                row.get::<_, String>(6).unwrap_or_else(|_| "[]".into()),
313                row.get::<_, String>(7).unwrap_or_else(|_| "[]".into()),
314                row.get::<_, String>(8).unwrap_or_else(|_| "[]".into()),
315                row.get::<_, String>(9)?,
316                row.get::<_, String>(10).unwrap_or_else(|_| "user".into()),
317                row.get::<_, i32>(11).unwrap_or(0),
318            ))
319        })?;
320
321        for row in rows {
322            let (
323                id,
324                name,
325                description,
326                version,
327                author,
328                tags_raw,
329                requires_raw,
330                allow_raw,
331                deny_raw,
332                instructions,
333                _scope_raw,
334                harness,
335            ) = row?;
336            let path = self.skill_file_path(&id, None, SkillWriteScope::User)?;
337            if path.is_file() {
338                continue;
339            }
340            let request = SkillWriteRequest {
341                id: id.clone(),
342                name,
343                description,
344                version,
345                author,
346                tags: serde_json::from_str(&tags_raw).unwrap_or_default(),
347                requires: serde_json::from_str(&requires_raw).unwrap_or_default(),
348                allow_tools: serde_json::from_str(&allow_raw).unwrap_or_default(),
349                deny_tools: serde_json::from_str(&deny_raw).unwrap_or_default(),
350                harness: harness != 0,
351                pool: None,
352                instructions,
353                scope: SkillWriteScope::User,
354            };
355            let body = render_skill_md(&request, &id);
356            if let Some(parent) = path.parent() {
357                fs::create_dir_all(parent)?;
358            }
359            fs::write(&path, body)?;
360            tracing::info!(id = %id, path = %path.display(), "migrated skill from skills.sqlite");
361        }
362        Ok(())
363    }
364}
365
366fn split_pool_skill_ref(id: &str, pool: Option<&str>) -> (Option<String>, String) {
367    if let Some(p) = pool.filter(|s| !s.trim().is_empty()) {
368        return (Some(p.to_string()), id.to_string());
369    }
370    if let Some((p, s)) = id.split_once('/') {
371        if !p.is_empty() && !s.is_empty() {
372            return (Some(p.to_string()), s.to_string());
373        }
374    }
375    (None, id.to_string())
376}
377
378fn ensure_pool_meta(pool_dir: PathBuf, pool_id: &str) -> Result<()> {
379    fs::create_dir_all(&pool_dir)
380        .with_context(|| format!("failed to create pool dir {}", pool_dir.display()))?;
381    let meta = pool_dir.join("POOL.md");
382    if !meta.is_file() {
383        let body = format!(
384            "---\nname: {}\nid: {}\ndescription: Skill pool\n---\n\n# {}\n\nSkill pool folder.\n",
385            pool_id, pool_id, pool_id
386        );
387        fs::write(&meta, body)?;
388    }
389    Ok(())
390}
391
392fn list_root_skills_in(root: &Path, scope: SkillWriteScope) -> Result<Vec<SkillManifest>> {
393    if !root.is_dir() {
394        return Ok(Vec::new());
395    }
396    let mut out = Vec::new();
397    for entry in fs::read_dir(root)
398        .with_context(|| format!("failed to read skills dir {}", root.display()))?
399    {
400        let entry = entry?;
401        let path = entry.path();
402        if !path.is_dir() {
403            if path.extension().and_then(|e| e.to_str()) == Some("md")
404                && !path
405                    .file_name()
406                    .and_then(|n| n.to_str())
407                    .unwrap_or("")
408                    .eq_ignore_ascii_case("POOL.md")
409            {
410                if let Ok(skill) = load_skill_md(&path, scope, None) {
411                    out.push(skill);
412                }
413            }
414            continue;
415        }
416        // Pool directory: has POOL.md or child skill dirs without SKILL.md at this level
417        if path.join("POOL.md").is_file() {
418            continue;
419        }
420        let skill_md = path.join("SKILL.md");
421        if skill_md.is_file() {
422            if let Ok(skill) = load_skill_md(&skill_md, scope, None) {
423                out.push(skill);
424            }
425        }
426    }
427    Ok(out)
428}
429
430fn list_pools_in(root: &Path, scope: SkillWriteScope) -> Result<Vec<SkillPool>> {
431    if !root.is_dir() {
432        return Ok(Vec::new());
433    }
434    let mut out = Vec::new();
435    for entry in fs::read_dir(root)
436        .with_context(|| format!("failed to read skills dir {}", root.display()))?
437    {
438        let entry = entry?;
439        let path = entry.path();
440        if !path.is_dir() {
441            continue;
442        }
443        // Pool if: POOL.md exists OR (no SKILL.md and has subdirs with SKILL.md)
444        let is_pool = path.join("POOL.md").is_file()
445            || (!path.join("SKILL.md").is_file() && dir_has_nested_skills(&path));
446        if !is_pool {
447            continue;
448        }
449        let id = path
450            .file_name()
451            .and_then(|n| n.to_str())
452            .unwrap_or("pool")
453            .to_string();
454        let (name, description) = read_pool_meta(&path, &id);
455        let skills = list_skills_in_pool_dir(&path, &id, scope)?;
456        out.push(SkillPool {
457            id: slugify_skill_id(&id),
458            name,
459            description,
460            scope,
461            path: path.clone(),
462            skill_count: skills.len(),
463        });
464    }
465    Ok(out)
466}
467
468fn dir_has_nested_skills(path: &Path) -> bool {
469    let Ok(entries) = fs::read_dir(path) else {
470        return false;
471    };
472    for e in entries.flatten() {
473        let p = e.path();
474        if p.is_dir() && p.join("SKILL.md").is_file() {
475            return true;
476        }
477    }
478    false
479}
480
481fn read_pool_meta(path: &Path, fallback_id: &str) -> (String, Option<String>) {
482    let meta = path.join("POOL.md");
483    if meta.is_file() {
484        if let Ok(raw) = fs::read_to_string(&meta) {
485            let parsed = parse_skill_md(&raw, fallback_id);
486            let name = if parsed.name.trim().is_empty() {
487                fallback_id.to_string()
488            } else {
489                parsed.name
490            };
491            return (name, parsed.description);
492        }
493    }
494    (
495        fallback_id.to_string(),
496        Some(format!("Skill pool `{fallback_id}`")),
497    )
498}
499
500fn list_skills_in_pool_dir(
501    pool_dir: &Path,
502    pool_id: &str,
503    scope: SkillWriteScope,
504) -> Result<Vec<SkillManifest>> {
505    let mut out = Vec::new();
506    if !pool_dir.is_dir() {
507        return Ok(out);
508    }
509    for entry in fs::read_dir(pool_dir)
510        .with_context(|| format!("failed to read pool dir {}", pool_dir.display()))?
511    {
512        let entry = entry?;
513        let path = entry.path();
514        if path
515            .file_name()
516            .and_then(|n| n.to_str())
517            .map(|n| n.eq_ignore_ascii_case("POOL.md"))
518            .unwrap_or(false)
519        {
520            continue;
521        }
522        if path.is_dir() {
523            let skill_md = path.join("SKILL.md");
524            if skill_md.is_file() {
525                if let Ok(skill) = load_skill_md(&skill_md, scope, Some(pool_id)) {
526                    out.push(skill);
527                }
528            }
529        } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
530            if let Ok(skill) = load_skill_md(&path, scope, Some(pool_id)) {
531                out.push(skill);
532            }
533        }
534    }
535    Ok(out)
536}
537
538fn list_skills_recursive(
539    root: &Path,
540    scope: SkillWriteScope,
541    pool: Option<&str>,
542) -> Result<Vec<SkillManifest>> {
543    if !root.is_dir() {
544        return Ok(Vec::new());
545    }
546    let mut out = Vec::new();
547    // Root skills
548    out.extend(list_root_skills_in(root, scope)?);
549    // Pool members
550    for pool_meta in list_pools_in(root, scope)? {
551        out.extend(list_skills_in_pool_dir(
552            &pool_meta.path,
553            &pool_meta.id,
554            scope,
555        )?);
556    }
557    // If we're already inside a pool path (pool arg set), list that dir only
558    if let Some(p) = pool {
559        out.extend(list_skills_in_pool_dir(root, p, scope)?);
560    }
561    Ok(out)
562}
563
564fn find_skill_in_pools(
565    root: &Path,
566    skill_id: &str,
567    scope: SkillWriteScope,
568) -> Result<Option<SkillManifest>> {
569    for pool in list_pools_in(root, scope)? {
570        let path = pool.path.join(skill_id).join("SKILL.md");
571        if path.is_file() {
572            return Ok(Some(load_skill_md(&path, scope, Some(&pool.id))?));
573        }
574    }
575    Ok(None)
576}
577
578fn load_skill_md(path: &Path, scope: SkillWriteScope, pool: Option<&str>) -> Result<SkillManifest> {
579    let raw = fs::read_to_string(path)
580        .with_context(|| format!("failed to read skill file {}", path.display()))?;
581    let fallback = path.file_stem().and_then(|s| s.to_str()).unwrap_or("skill");
582    let fallback = if fallback.eq_ignore_ascii_case("skill") {
583        path.parent()
584            .and_then(|p| p.file_name())
585            .and_then(|s| s.to_str())
586            .unwrap_or(fallback)
587    } else {
588        fallback
589    };
590    let parsed = parse_skill_md(&raw, fallback);
591    Ok(parsed_to_manifest(parsed, path, scope, pool))
592}
593
594fn parsed_to_manifest(
595    parsed: ParsedSkillFile,
596    path: &Path,
597    scope: SkillWriteScope,
598    pool: Option<&str>,
599) -> SkillManifest {
600    let id = parsed
601        .id
602        .as_deref()
603        .map(str::trim)
604        .filter(|s| !s.is_empty())
605        .map(|s| s.to_string())
606        .unwrap_or_else(|| {
607            path.parent()
608                .and_then(|p| p.file_name())
609                .and_then(|s| s.to_str())
610                .filter(|s| !s.eq_ignore_ascii_case("skills"))
611                .unwrap_or("skill")
612                .to_string()
613        });
614    let pool = pool
615        .map(|p| p.to_string())
616        .or(parsed.pool)
617        .filter(|p| !p.is_empty());
618    SkillManifest {
619        id: slugify_skill_id(&id),
620        name: if parsed.name.trim().is_empty() {
621            id
622        } else {
623            parsed.name
624        },
625        description: parsed.description,
626        version: parsed.version,
627        author: parsed.author,
628        tags: parsed.tags,
629        requires: parsed.requires,
630        allow_tools: parsed.allow_tools,
631        deny_tools: parsed.deny_tools,
632        harness: parsed.harness,
633        pool,
634        path: path.to_path_buf(),
635        instructions: parsed.instructions,
636        source: SkillSource::Store,
637        scope,
638    }
639}
640
641/// Render a skill request to SKILL.md with YAML frontmatter.
642pub fn render_skill_md(request: &SkillWriteRequest, id: &str) -> String {
643    let mut out = String::from("---\n");
644    out.push_str(&format!("name: {}\n", yaml_quote(&request.name)));
645    out.push_str(&format!("id: {}\n", yaml_quote(id)));
646    if let Some(desc) = request
647        .description
648        .as_ref()
649        .map(|s| s.trim())
650        .filter(|s| !s.is_empty())
651    {
652        out.push_str(&format!("description: {}\n", yaml_quote(desc)));
653    }
654    if let Some(version) = request
655        .version
656        .as_ref()
657        .map(|s| s.trim())
658        .filter(|s| !s.is_empty())
659    {
660        out.push_str(&format!("version: {}\n", yaml_quote(version)));
661    }
662    if let Some(author) = request
663        .author
664        .as_ref()
665        .map(|s| s.trim())
666        .filter(|s| !s.is_empty())
667    {
668        out.push_str(&format!("author: {}\n", yaml_quote(author)));
669    }
670    if let Some(pool) = request
671        .pool
672        .as_ref()
673        .map(|s| s.trim())
674        .filter(|s| !s.is_empty())
675    {
676        out.push_str(&format!("pool: {}\n", yaml_quote(pool)));
677    }
678    if !request.tags.is_empty() {
679        out.push_str(&format!("tags: {}\n", yaml_list(&request.tags)));
680    }
681    if !request.requires.is_empty() {
682        out.push_str(&format!("requires: {}\n", yaml_list(&request.requires)));
683    }
684    if !request.allow_tools.is_empty() {
685        out.push_str(&format!(
686            "allow_tools: {}\n",
687            yaml_list(&request.allow_tools)
688        ));
689    }
690    if !request.deny_tools.is_empty() {
691        out.push_str(&format!("deny_tools: {}\n", yaml_list(&request.deny_tools)));
692    }
693    if request.harness {
694        out.push_str("harness: true\n");
695    }
696    out.push_str("---\n\n");
697    out.push_str(request.instructions.trim());
698    out.push('\n');
699    out
700}
701
702fn yaml_quote(value: &str) -> String {
703    if value.is_empty() {
704        return "\"\"".into();
705    }
706    if value.contains(':')
707        || value.contains('#')
708        || value.contains('"')
709        || value.contains('\'')
710        || value.starts_with(' ')
711        || value.ends_with(' ')
712        || value.contains('\n')
713    {
714        format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
715    } else {
716        value.to_string()
717    }
718}
719
720fn yaml_list(items: &[String]) -> String {
721    let cleaned: Vec<String> = items
722        .iter()
723        .map(|s| s.trim().to_string())
724        .filter(|s| !s.is_empty())
725        .collect();
726    format!(
727        "[{}]",
728        cleaned
729            .iter()
730            .map(|s| yaml_quote(s))
731            .collect::<Vec<_>>()
732            .join(", ")
733    )
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739
740    #[test]
741    fn upsert_and_list_roundtrip() {
742        let store = SkillStore::open_memory().expect("open");
743        let result = store
744            .upsert(
745                &SkillWriteRequest {
746                    id: "demo".into(),
747                    name: "Demo".into(),
748                    description: Some("desc".into()),
749                    version: None,
750                    author: None,
751                    tags: vec!["t".into()],
752                    requires: vec![],
753                    allow_tools: vec!["read_file".into(), "skill_save".into()],
754                    deny_tools: vec![],
755                    harness: false,
756                    pool: None,
757                    instructions: "Do the demo.".into(),
758                    scope: SkillWriteScope::User,
759                },
760                None,
761            )
762            .expect("upsert");
763        assert!(result.created);
764        assert_eq!(result.skill.allow_tools, vec!["read_file", "skill_save"]);
765        assert!(result.path.ends_with("SKILL.md"));
766        let listed = store.list_for_discovery(None).expect("list");
767        assert_eq!(listed.len(), 1);
768        assert_eq!(listed[0].id, "demo");
769    }
770
771    #[test]
772    fn pool_hides_members_from_root_list() {
773        let store = SkillStore::open_memory().expect("open");
774        store
775            .upsert(
776                &SkillWriteRequest {
777                    id: "create-skill".into(),
778                    name: "Create Skill".into(),
779                    description: Some("Author skills".into()),
780                    version: None,
781                    author: None,
782                    tags: vec!["navi".into()],
783                    requires: vec![],
784                    allow_tools: vec!["skill_save".into()],
785                    deny_tools: vec![],
786                    harness: false,
787                    pool: Some("navi".into()),
788                    instructions: "Create carefully.".into(),
789                    scope: SkillWriteScope::User,
790                },
791                None,
792            )
793            .expect("upsert");
794        let root = store.list_root_skills().expect("root");
795        assert!(root.is_empty(), "pool members must not be root: {root:?}");
796        let pools = store.list_pools().expect("pools");
797        assert_eq!(pools.len(), 1);
798        assert_eq!(pools[0].id, "navi");
799        assert_eq!(pools[0].skill_count, 1);
800        let members = store.list_pool_skills("navi").expect("members");
801        assert_eq!(members.len(), 1);
802        assert_eq!(members[0].id, "create-skill");
803        assert_eq!(members[0].pool.as_deref(), Some("navi"));
804        let loaded = store
805            .get_in_pool("create-skill", Some("navi"))
806            .expect("get")
807            .expect("present");
808        assert!(loaded.instructions.contains("Create carefully"));
809    }
810}