Skip to main content

ai_memory/mcp/tools/
skill_register.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! MCP `memory_skill_register` handler (L1-5 Agent Skills substrate).
5//!
6//! Registers a SKILL.md-format skill into the `skills` table. Accepts
7//! either:
8//! - `folder_path` — a directory containing `SKILL.md` plus optional
9//!   resource files, **or**
10//! - `inline_skill` — the raw SKILL.md text as a string.
11//!
12//! Registration is idempotent with respect to digest: re-registering
13//! the same content produces the same SHA-256 digest and creates a new
14//! row (version chain). The previous current row's `superseded_by` is
15//! set to the new row's id.
16//!
17//! # Ed25519 attestation
18//!
19//! When an `active_keypair` is provided the digest is signed with the
20//! agent's private key and the `signing_agent` column is populated.
21//! The matching `signed_events` row is appended for the Bucket 1
22//! attestation chain.
23
24use crate::models::field_names;
25use std::path::Path;
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use rusqlite::{Connection, params};
29use serde_json::{Value, json};
30use sha2::{Digest as _, Sha256};
31use uuid::Uuid;
32
33use crate::identity::keypair::AgentKeypair;
34use crate::parsing::skill_md;
35use crate::signed_events::{SignedEvent, append_signed_event, payload_hash};
36
37// ---------------------------------------------------------------------------
38// Digest computation
39// ---------------------------------------------------------------------------
40
41/// Compute the canonical SHA-256 digest over the skill's signing surface:
42///   `canonical_frontmatter_json_bytes || body_bytes || sorted_resource_digests`
43///
44/// `resource_digests` is a sorted list of per-resource SHA-256 hashes
45/// (empty when no resources are attached).
46pub(super) fn compute_skill_digest(
47    canonical_fm: &[u8],
48    body_bytes: &[u8],
49    mut resource_digests: Vec<Vec<u8>>,
50) -> Vec<u8> {
51    resource_digests.sort();
52    let mut hasher = Sha256::new();
53    hasher.update(canonical_fm);
54    hasher.update(body_bytes);
55    for rd in &resource_digests {
56        hasher.update(rd);
57    }
58    hasher.finalize().to_vec()
59}
60
61/// Compute a per-resource SHA-256 over decompressed bytes.
62pub(super) fn resource_digest(content: &[u8]) -> Vec<u8> {
63    let mut hasher = Sha256::new();
64    hasher.update(content);
65    hasher.finalize().to_vec()
66}
67
68// ---------------------------------------------------------------------------
69// zstd helpers
70// ---------------------------------------------------------------------------
71
72fn compress(data: &[u8]) -> Result<Vec<u8>, String> {
73    zstd::encode_all(data, 3).map_err(|e| format!("zstd compress error: {e}"))
74}
75
76// ---------------------------------------------------------------------------
77// Internal registration core
78// ---------------------------------------------------------------------------
79
80/// Outcome of a successful skill registration.
81pub(super) struct RegisterResult {
82    pub id: String,
83    pub digest: Vec<u8>,
84    pub superseded: Option<String>,
85}
86
87/// Core registration logic shared by the folder and inline paths.
88///
89/// `canonical_fm_json` is the sorted JSON encoding of the frontmatter
90/// fields that go into the digest surface.
91pub(super) fn register_core(
92    conn: &Connection,
93    namespace: &str,
94    name: &str,
95    description: &str,
96    license: Option<&str>,
97    compatibility: Option<&str>,
98    allowed_tools: &[String],
99    metadata: &Value,
100    body_bytes: &[u8],
101    resource_digests: Vec<Vec<u8>>,
102    resources: &[(String, String, Vec<u8>)], // (path, kind, content)
103    active_keypair: Option<&AgentKeypair>,
104) -> Result<RegisterResult, String> {
105    // Build canonical frontmatter JSON for digest computation.
106    let canonical_fm = serde_json::to_vec(&json!({
107        "namespace": namespace,
108        "name": name,
109        (field_names::DESCRIPTION): description,
110        "license": license,
111        (field_names::COMPATIBILITY): compatibility,
112        (field_names::ALLOWED_TOOLS): allowed_tools,
113    }))
114    .map_err(|e| format!("frontmatter JSON error: {e}"))?;
115
116    let digest = compute_skill_digest(&canonical_fm, body_bytes, resource_digests);
117
118    // Sign if keypair available.
119    let (signature_bytes, signing_agent_str): (Option<Vec<u8>>, Option<String>) =
120        if let Some(kp) = active_keypair {
121            use ed25519_dalek::Signer as _;
122            let sig = kp.private.as_ref().map(|sk| {
123                let signing_key = ed25519_dalek::SigningKey::from_bytes(
124                    sk.as_bytes()
125                        .try_into()
126                        .expect("ed25519 signing key is always 32 bytes"),
127                );
128                signing_key.sign(&digest).to_bytes().to_vec()
129            });
130            (sig, Some(kp.agent_id.clone()))
131        } else {
132            (None, None)
133        };
134
135    let allowed_tools_json =
136        serde_json::to_string(allowed_tools).map_err(|e| format!("allowed_tools JSON: {e}"))?;
137    let metadata_json =
138        serde_json::to_string(metadata).map_err(|e| format!("metadata JSON: {e}"))?;
139
140    let body_blob = compress(body_bytes)?;
141
142    let now_secs = SystemTime::now()
143        .duration_since(UNIX_EPOCH)
144        .unwrap_or_default()
145        .as_secs() as i64;
146
147    let new_id = Uuid::new_v4().to_string();
148
149    // Find the current (non-superseded) row for this (namespace, name).
150    let prev_id: Option<String> = conn
151        .query_row(
152            "SELECT id FROM skills WHERE namespace = ?1 AND name = ?2 AND superseded_by IS NULL",
153            params![namespace, name],
154            |row| row.get(0),
155        )
156        .ok();
157
158    // Insert new row.
159    conn.execute(
160        "INSERT INTO skills \
161            (id, namespace, name, description, license, compatibility, \
162             allowed_tools, metadata, body_blob, digest, signature, \
163             signing_agent, created_at) \
164         VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)",
165        params![
166            new_id,
167            namespace,
168            name,
169            description,
170            license,
171            compatibility,
172            allowed_tools_json,
173            metadata_json,
174            body_blob,
175            digest,
176            signature_bytes,
177            signing_agent_str,
178            now_secs,
179        ],
180    )
181    .map_err(|e| format!("skills INSERT: {e}"))?;
182
183    // Insert resources.
184    for (res_path, res_kind, res_content) in resources {
185        let res_digest = resource_digest(res_content);
186        let res_blob = compress(res_content)?;
187        conn.execute(
188            "INSERT INTO skill_resources \
189                (skill_id, resource_path, resource_kind, content_blob, digest) \
190             VALUES (?1,?2,?3,?4,?5)",
191            params![new_id, res_path, res_kind, res_blob, res_digest],
192        )
193        .map_err(|e| format!("skill_resources INSERT ({res_path}): {e}"))?;
194    }
195
196    // Update previous row's superseded_by.
197    let superseded = if let Some(ref prev) = prev_id {
198        conn.execute(
199            "UPDATE skills SET superseded_by = ?1 WHERE id = ?2",
200            params![new_id, prev],
201        )
202        .map_err(|e| format!("superseded_by UPDATE: {e}"))?;
203        Some(prev.clone())
204    } else {
205        None
206    };
207
208    // Append signed_events audit row.
209    let event_payload = json!({
210        "skill_id": new_id,
211        "namespace": namespace,
212        "name": name,
213        "action": if superseded.is_some() { "supersede" } else { "register" },
214    });
215    let event_bytes = serde_json::to_vec(&event_payload).unwrap_or_default();
216    let ev_hash = payload_hash(&event_bytes);
217    let attest = if signature_bytes.is_some() {
218        crate::models::AttestLevel::SelfSigned.as_str()
219    } else {
220        crate::models::AttestLevel::Unsigned.as_str()
221    };
222    let event = SignedEvent {
223        id: Uuid::new_v4().to_string(),
224        agent_id: signing_agent_str
225            .clone()
226            .unwrap_or_else(|| "anonymous".to_string()),
227        event_type: crate::signed_events::event_types::SKILL_REGISTERED.to_string(),
228        payload_hash: ev_hash,
229        signature: signature_bytes.clone(),
230        attest_level: attest.to_string(),
231        timestamp: chrono::Utc::now().to_rfc3339(),
232        ..SignedEvent::default()
233    };
234    let _ = append_signed_event(conn, &event); // best-effort; don't fail registration on audit err
235
236    Ok(RegisterResult {
237        id: new_id,
238        digest,
239        superseded,
240    })
241}
242
243// ---------------------------------------------------------------------------
244// Handler
245// ---------------------------------------------------------------------------
246
247pub fn handle_skill_register(
248    conn: &Connection,
249    params: &Value,
250    active_keypair: Option<&AgentKeypair>,
251) -> Result<Value, String> {
252    // -----------------------------------------------------------------------
253    // Input: folder_path or inline_skill
254    // -----------------------------------------------------------------------
255    let (skill_md_text, resource_files): (String, Vec<(String, String, Vec<u8>)>) =
256        if let Some(folder_str) = params["folder_path"].as_str() {
257            let folder = Path::new(folder_str);
258            if !folder.is_dir() {
259                return Err(format!(
260                    "folder_path '{folder_str}' is not a directory or does not exist"
261                ));
262            }
263            let md_path = folder.join("SKILL.md");
264            let text = std::fs::read_to_string(&md_path)
265                .map_err(|e| format!("cannot read SKILL.md in '{folder_str}': {e}"))?;
266
267            // Collect resource files from a 'resources/' sub-directory.
268            let mut res: Vec<(String, String, Vec<u8>)> = Vec::new();
269            let res_dir = folder.join("resources");
270            if res_dir.is_dir() {
271                collect_resources(&res_dir, &res_dir, &mut res)?;
272            }
273            (text, res)
274        } else if let Some(inline) = params["inline_skill"].as_str() {
275            (inline.to_string(), Vec::new())
276        } else {
277            return Err(
278                "memory_skill_register requires either 'folder_path' or 'inline_skill'".to_string(),
279            );
280        };
281
282    // -----------------------------------------------------------------------
283    // Parse + validate SKILL.md
284    // -----------------------------------------------------------------------
285    let manifest = skill_md::parse(&skill_md_text)?;
286
287    // #913 (security-medium / SOC2, 2026-05-19) — admin/state-change
288    // audit. Skill registration mints an executable capability bundle
289    // in the substrate; emit the forensic-chain row BEFORE the storage
290    // write so the audit trail captures intent regardless of downstream
291    // signing / storage outcome.
292    let caller = crate::identity::resolve_agent_id(params["agent_id"].as_str(), None)
293        .unwrap_or_else(|_| crate::identity::sentinels::ANONYMOUS_INVALID.to_string());
294    crate::governance::audit::record_decision(
295        &caller,
296        "allow",
297        "skill_register",
298        "",
299        json!({
300            "namespace": manifest.namespace,
301            "name": manifest.name,
302            "resource_count": resource_files.len(),
303            "signed": active_keypair.is_some(),
304        }),
305    );
306
307    let body_bytes = manifest.body.as_bytes();
308
309    // Compute resource digests for the signing surface.
310    let res_digests: Vec<Vec<u8>> = resource_files
311        .iter()
312        .map(|(_, _, content)| resource_digest(content))
313        .collect();
314
315    let result = register_core(
316        conn,
317        &manifest.namespace,
318        &manifest.name,
319        &manifest.description,
320        manifest.license.as_deref(),
321        manifest.compatibility.as_deref(),
322        &manifest.allowed_tools,
323        &manifest.metadata,
324        body_bytes,
325        res_digests,
326        &resource_files,
327        active_keypair,
328    )?;
329
330    let digest_hex = hex::encode(&result.digest);
331    let mut response = json!({
332        (field_names::REGISTERED): true,
333        "id": result.id,
334        "namespace": manifest.namespace,
335        "name": manifest.name,
336        "digest": digest_hex,
337        "signed": active_keypair.is_some(),
338    });
339    if let Some(prev) = result.superseded {
340        response[field_names::SUPERSEDED_ID] = json!(prev);
341    }
342    Ok(response)
343}
344
345// ---------------------------------------------------------------------------
346// Recursive resource directory walker
347// ---------------------------------------------------------------------------
348
349fn collect_resources(
350    base: &Path,
351    dir: &Path,
352    out: &mut Vec<(String, String, Vec<u8>)>,
353) -> Result<(), String> {
354    let entries =
355        std::fs::read_dir(dir).map_err(|e| format!("read_dir '{}': {e}", dir.display()))?;
356    for entry in entries {
357        let entry = entry.map_err(|e| format!("dir entry error: {e}"))?;
358        let path = entry.path();
359        if path.is_dir() {
360            collect_resources(base, &path, out)?;
361        } else {
362            // Always emit forward-slash-joined relative paths regardless of
363            // host OS. `to_string_lossy()` on Windows produces backslashes
364            // ("scripts\\run.sh") which then fail every downstream
365            // `WHERE resource_path = 'scripts/run.sh'` lookup — the wire
366            // format (and the `memory_skill_resource` MCP contract) is
367            // forward-slash-only. Issue #797 sibling fix.
368            let rel = path
369                .strip_prefix(base)
370                .map_err(|_| "path prefix error".to_string())?
371                .components()
372                .map(|c| c.as_os_str().to_string_lossy())
373                .collect::<Vec<_>>()
374                .join("/");
375            let content = std::fs::read(&path)
376                .map_err(|e| format!("read resource '{}': {e}", path.display()))?;
377            // Determine kind from sub-directory name or file extension.
378            let kind = infer_kind(&rel);
379            out.push((rel, kind, content));
380        }
381    }
382    Ok(())
383}
384
385fn infer_kind(rel_path: &str) -> String {
386    if rel_path.starts_with("scripts/") || rel_path.ends_with(".sh") || rel_path.ends_with(".py") {
387        "script".to_string()
388    } else if rel_path.starts_with("reference/") || rel_path.starts_with("references/") {
389        "reference".to_string()
390    } else {
391        "asset".to_string()
392    }
393}
394
395// ---------------------------------------------------------------------------
396// hex helper (inline — avoids adding hex dep)
397// ---------------------------------------------------------------------------
398
399mod hex {
400    pub(super) fn encode(bytes: &[u8]) -> String {
401        bytes.iter().map(|b| format!("{b:02x}")).collect()
402    }
403}
404
405// --- D1.5 (#986): per-tool McpTool impl for memory_skill_register ---
406
407use crate::mcp::registry::McpTool;
408use schemars::JsonSchema;
409use serde::Deserialize;
410
411/// v0.7.0 #972 D1.5 (#986) — request body for `memory_skill_register`.
412///
413/// v0.7.0 #1327 — canonical parameter names are `folder_path` and
414/// `inline_skill`. Earlier draft docs used `skill_folder` informally;
415/// the parser at `handle_skill_register` (`src/mcp/tools/skill_register.rs:254`)
416/// only accepts `folder_path`. The `tool_examples()` catalog in
417/// `src/mcp/tools/capabilities.rs` carries a byte-equal worked example
418/// for each form.
419#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
420#[allow(dead_code)]
421pub struct SkillRegisterRequest {
422    /// Dir containing SKILL.md + optional resources/. Canonical field
423    /// name is `folder_path` (NOT `skill_folder`).
424    #[serde(default)]
425    pub folder_path: Option<String>,
426
427    /// Raw SKILL.md text (frontmatter + body).
428    #[serde(default)]
429    pub inline_skill: Option<String>,
430}
431
432/// v0.7.0 #972 D1.5 (#986) — `McpTool` impl for `memory_skill_register`.
433#[allow(dead_code)]
434pub struct SkillRegisterTool;
435
436impl McpTool for SkillRegisterTool {
437    fn name() -> &'static str {
438        crate::mcp::registry::tool_names::MEMORY_SKILL_REGISTER
439    }
440    fn description() -> &'static str {
441        "Register an agentskills.io SKILL.md from a folder or inline text."
442    }
443    fn docs() -> &'static str {
444        "L1-5: Ed25519-attested skill registration with version chaining. Re-register same (name, namespace) supersedes prior row."
445    }
446    fn input_schema() -> Value {
447        crate::mcp::registry::input_schema_for::<SkillRegisterRequest>()
448    }
449    fn family() -> &'static str {
450        crate::profile::Family::Other.name()
451    }
452}
453
454#[cfg(test)]
455mod d1_5_986_tests {
456    //! D1.5 (#986) — schema parity for `memory_skill_register`.
457    //! Shared helpers live at [`crate::mcp::parity_test_helpers`].
458    use super::*;
459    use crate::mcp::parity_test_helpers::{
460        assert_descriptions_match, assert_property_set_parity, derived_props_for,
461    };
462
463    #[test]
464    fn skill_register_parity_986() {
465        let derived = derived_props_for::<SkillRegisterRequest>();
466        assert_property_set_parity("memory_skill_register", &derived);
467        assert_descriptions_match("memory_skill_register", &derived);
468    }
469
470    #[test]
471    fn skill_register_tool_metadata_986() {
472        assert_eq!(SkillRegisterTool::name(), "memory_skill_register");
473        assert_eq!(SkillRegisterTool::family(), "other");
474    }
475}
476
477// ---------------------------------------------------------------------------
478// Unit tests
479// ---------------------------------------------------------------------------
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use std::path::PathBuf;
485
486    fn open_db() -> (rusqlite::Connection, tempfile::TempDir) {
487        let dir = tempfile::tempdir().expect("tempdir");
488        let path = dir.path().join("test.db");
489        let conn = crate::db::open(&path).expect("db::open");
490        (conn, dir)
491    }
492
493    fn make_keypair() -> AgentKeypair {
494        use ed25519_dalek::{SigningKey, VerifyingKey};
495        let mut rng = rand_core::OsRng;
496        let sk = SigningKey::generate(&mut rng);
497        let vk: VerifyingKey = (&sk).into();
498        AgentKeypair {
499            agent_id: "test:signer".to_string(),
500            public: vk,
501            private: Some(sk),
502        }
503    }
504
505    fn minimal_skill_md(name: &str) -> String {
506        format!("---\nnamespace: testns\nname: {name}\ndescription: A demo skill.\n---\n\nBody.\n")
507    }
508
509    // ---- digest helpers ---------------------------------------------------
510
511    #[test]
512    fn compute_skill_digest_is_deterministic() {
513        let fm = b"{\"a\":1}";
514        let body = b"hello";
515        let d1 = compute_skill_digest(fm, body, vec![]);
516        let d2 = compute_skill_digest(fm, body, vec![]);
517        assert_eq!(d1, d2);
518        assert_eq!(d1.len(), 32);
519    }
520
521    #[test]
522    fn compute_skill_digest_resource_order_independent() {
523        // Sorted internally; same digest regardless of input order.
524        let fm = b"fm";
525        let body = b"body";
526        let r_a = vec![1u8; 32];
527        let r_b = vec![2u8; 32];
528        let d_ab = compute_skill_digest(fm, body, vec![r_a.clone(), r_b.clone()]);
529        let d_ba = compute_skill_digest(fm, body, vec![r_b, r_a]);
530        assert_eq!(d_ab, d_ba);
531    }
532
533    #[test]
534    fn resource_digest_known_value() {
535        // SHA-256 of empty = e3b0...; sanity-check we wired sha2 right.
536        let d = resource_digest(b"");
537        assert_eq!(
538            hex::encode(&d),
539            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
540        );
541    }
542
543    #[test]
544    fn compress_round_trip() {
545        let input = b"hello world".repeat(100);
546        let compressed = compress(&input).unwrap();
547        let decompressed = zstd::decode_all(compressed.as_slice()).unwrap();
548        assert_eq!(decompressed, input);
549    }
550
551    // ---- handler input validation ----------------------------------------
552
553    #[test]
554    fn rejects_missing_input() {
555        let (conn, _dir) = open_db();
556        let err = handle_skill_register(&conn, &json!({}), None).unwrap_err();
557        assert!(err.contains("folder_path") || err.contains("inline_skill"));
558    }
559
560    #[test]
561    fn rejects_nonexistent_folder_path() {
562        let (conn, dir) = open_db();
563        let bad = dir.path().join("no-such-folder");
564        let err =
565            handle_skill_register(&conn, &json!({"folder_path": bad.to_str().unwrap()}), None)
566                .unwrap_err();
567        assert!(err.contains("is not a directory"));
568    }
569
570    #[test]
571    fn rejects_folder_without_skill_md() {
572        let (conn, dir) = open_db();
573        let target = dir.path().join("empty");
574        std::fs::create_dir_all(&target).unwrap();
575        let err = handle_skill_register(
576            &conn,
577            &json!({"folder_path": target.to_str().unwrap()}),
578            None,
579        )
580        .unwrap_err();
581        assert!(err.contains("cannot read SKILL.md"));
582    }
583
584    // ---- happy path: inline ----------------------------------------------
585
586    #[test]
587    fn registers_inline_skill_minimal() {
588        let (conn, _dir) = open_db();
589        let inline = minimal_skill_md("inline-skill");
590        let v = handle_skill_register(&conn, &json!({"inline_skill": inline}), None).unwrap();
591        assert_eq!(v["registered"], json!(true));
592        assert_eq!(v["namespace"], json!("testns"));
593        assert_eq!(v["name"], json!("inline-skill"));
594        assert_eq!(v["signed"], json!(false));
595        let hex_dig = v["digest"].as_str().unwrap();
596        assert_eq!(hex_dig.len(), 64);
597        // No superseded_id on first register.
598        assert!(v.get("superseded_id").is_none());
599    }
600
601    #[test]
602    fn supersede_returns_previous_id() {
603        let (conn, _dir) = open_db();
604        let v1 = handle_skill_register(
605            &conn,
606            &json!({"inline_skill": minimal_skill_md("chain-me")}),
607            None,
608        )
609        .unwrap();
610        let id1 = v1["id"].as_str().unwrap().to_string();
611
612        // Re-register with the same name + namespace → supersede.
613        let v2 = handle_skill_register(
614            &conn,
615            &json!({"inline_skill": minimal_skill_md("chain-me")}),
616            None,
617        )
618        .unwrap();
619        assert_eq!(v2["superseded_id"], json!(id1));
620    }
621
622    #[test]
623    fn registers_with_active_keypair_signs() {
624        let (conn, _dir) = open_db();
625        let kp = make_keypair();
626        let v = handle_skill_register(
627            &conn,
628            &json!({"inline_skill": minimal_skill_md("signed-skill")}),
629            Some(&kp),
630        )
631        .unwrap();
632        assert_eq!(v["signed"], json!(true));
633        // Verify the signature column was populated.
634        let sig: Option<Vec<u8>> = conn
635            .query_row(
636                "SELECT signature FROM skills WHERE id = ?1",
637                [v["id"].as_str().unwrap()],
638                |r| r.get(0),
639            )
640            .unwrap();
641        assert!(sig.is_some());
642        let sig = sig.unwrap();
643        assert_eq!(sig.len(), 64); // Ed25519 signature size.
644
645        // signing_agent column populated.
646        let sa: Option<String> = conn
647            .query_row(
648                "SELECT signing_agent FROM skills WHERE id = ?1",
649                [v["id"].as_str().unwrap()],
650                |r| r.get(0),
651            )
652            .unwrap();
653        assert_eq!(sa.as_deref(), Some("test:signer"));
654    }
655
656    // ---- folder_path path -------------------------------------------------
657
658    fn write_skill_md(dir: &PathBuf, content: &str) {
659        std::fs::create_dir_all(dir).unwrap();
660        std::fs::write(dir.join("SKILL.md"), content).unwrap();
661    }
662
663    #[test]
664    fn registers_from_folder_with_resources() {
665        let (conn, dir) = open_db();
666        let folder = dir.path().join("skill-folder");
667        write_skill_md(&folder, &minimal_skill_md("folder-skill"));
668        // Scripts subdir
669        let scripts = folder.join("resources").join("scripts");
670        std::fs::create_dir_all(&scripts).unwrap();
671        std::fs::write(scripts.join("run.sh"), b"echo hi\n").unwrap();
672        // Reference subdir
673        let refer = folder.join("resources").join("reference");
674        std::fs::create_dir_all(&refer).unwrap();
675        std::fs::write(refer.join("notes.md"), b"# Notes\n").unwrap();
676        // Plain asset
677        let asset = folder.join("resources").join("asset.png");
678        std::fs::write(&asset, b"\x89PNG\r\n").unwrap();
679
680        let v = handle_skill_register(
681            &conn,
682            &json!({"folder_path": folder.to_str().unwrap()}),
683            None,
684        )
685        .unwrap();
686        assert_eq!(v["registered"], json!(true));
687        // Resources are inserted into skill_resources.
688        let count: i64 = conn
689            .query_row(
690                "SELECT COUNT(*) FROM skill_resources WHERE skill_id = ?1",
691                [v["id"].as_str().unwrap()],
692                |r| r.get(0),
693            )
694            .unwrap();
695        assert_eq!(count, 3);
696    }
697
698    #[test]
699    fn registers_folder_with_no_resources_dir() {
700        // folder without a resources/ subdir is valid — just no resources.
701        let (conn, dir) = open_db();
702        let folder = dir.path().join("plain-skill");
703        write_skill_md(&folder, &minimal_skill_md("plain"));
704
705        let v = handle_skill_register(
706            &conn,
707            &json!({"folder_path": folder.to_str().unwrap()}),
708            None,
709        )
710        .unwrap();
711        assert_eq!(v["registered"], json!(true));
712        let count: i64 = conn
713            .query_row(
714                "SELECT COUNT(*) FROM skill_resources WHERE skill_id = ?1",
715                [v["id"].as_str().unwrap()],
716                |r| r.get(0),
717            )
718            .unwrap();
719        assert_eq!(count, 0);
720    }
721
722    // ---- skill md parse failure ------------------------------------------
723
724    #[test]
725    fn rejects_malformed_inline_skill() {
726        let (conn, _dir) = open_db();
727        let bad = "no frontmatter here, just body text";
728        let err = handle_skill_register(&conn, &json!({"inline_skill": bad}), None).unwrap_err();
729        // The parser surfaces a non-empty error string.
730        assert!(!err.is_empty());
731    }
732
733    // ---- infer_kind --------------------------------------------------------
734
735    #[test]
736    fn infer_kind_classifies_scripts() {
737        assert_eq!(infer_kind("scripts/run.sh"), "script");
738        assert_eq!(infer_kind("a/b.sh"), "script");
739        assert_eq!(infer_kind("a/b.py"), "script");
740    }
741
742    #[test]
743    fn infer_kind_classifies_references() {
744        assert_eq!(infer_kind("reference/x.md"), "reference");
745        assert_eq!(infer_kind("references/y.md"), "reference");
746    }
747
748    #[test]
749    fn infer_kind_defaults_to_asset() {
750        assert_eq!(infer_kind("asset.png"), "asset");
751        assert_eq!(infer_kind("img/logo.svg"), "asset");
752    }
753
754    // ---- collect_resources directly --------------------------------------
755
756    #[test]
757    fn collect_resources_walks_nested_dirs() {
758        let dir = tempfile::tempdir().unwrap();
759        let base = dir.path().to_path_buf();
760        std::fs::create_dir_all(base.join("a")).unwrap();
761        std::fs::create_dir_all(base.join("b").join("c")).unwrap();
762        std::fs::write(base.join("a").join("f1.txt"), b"f1").unwrap();
763        std::fs::write(base.join("b").join("c").join("f2.txt"), b"f2").unwrap();
764
765        let mut out: Vec<(String, String, Vec<u8>)> = Vec::new();
766        collect_resources(&base, &base, &mut out).unwrap();
767        assert_eq!(out.len(), 2);
768        // Resource paths MUST be forward-slash-joined on every platform —
769        // they are the wire-format key used by `memory_skill_resource`
770        // (`WHERE resource_path = ?2`). The previous `to_string_lossy`
771        // implementation emitted backslashes on Windows ("a\\f1.txt") and
772        // every peer lookup missed; the assertion below is the exact-string
773        // form so a future regression of the same shape fails the test on
774        // Unix even when CI is Linux-only.
775        let paths: Vec<&str> = out.iter().map(|(p, _, _)| p.as_str()).collect();
776        assert!(
777            paths.iter().any(|p| *p == "a/f1.txt"),
778            "expected exact path 'a/f1.txt'; got {paths:?}"
779        );
780        assert!(
781            paths.iter().any(|p| *p == "b/c/f2.txt"),
782            "expected exact path 'b/c/f2.txt'; got {paths:?}"
783        );
784        assert!(
785            paths.iter().all(|p| !p.contains('\\')),
786            "no resource path may contain a backslash (wire format is \
787             forward-slash-only); got {paths:?}"
788        );
789    }
790
791    #[test]
792    fn collect_resources_rejects_nonexistent() {
793        let mut out: Vec<(String, String, Vec<u8>)> = Vec::new();
794        let nonexistent = std::path::PathBuf::from("/does/not/exist/at/all");
795        let err = collect_resources(&nonexistent, &nonexistent, &mut out).unwrap_err();
796        assert!(err.contains("read_dir"));
797    }
798
799    // ---- hex module --------------------------------------------------------
800
801    #[test]
802    fn hex_encode_empty_and_bytes() {
803        assert_eq!(hex::encode(&[]), "");
804        assert_eq!(hex::encode(&[0x00, 0xff, 0xab]), "00ffab");
805    }
806}