use async_trait::async_trait;
use serde_json::Value;
use nexo_tool_meta::admin::skills::{
SkillRecord, SkillSummary, SkillsDeleteAck, SkillsDeleteParams, SkillsGetParams,
SkillsGetResponse, SkillsListParams, SkillsListResponse, SkillsUpsertParams,
SkillsUpsertResponse,
};
use crate::agent::admin_rpc::dispatcher::{AdminRpcError, AdminRpcResult};
pub const MAX_SKILL_BODY_BYTES: usize = 64 * 1024;
#[async_trait]
pub trait SkillsStore: Send + Sync + std::fmt::Debug {
async fn list(&self, prefix: Option<&str>) -> anyhow::Result<Vec<SkillSummary>>;
async fn get(&self, name: &str) -> anyhow::Result<Option<SkillRecord>>;
async fn upsert(&self, params: SkillsUpsertParams) -> anyhow::Result<(SkillRecord, bool)>;
async fn delete(&self, name: &str) -> anyhow::Result<bool>;
async fn list_for_tenant(
&self,
_tenant_id: &str,
_prefix: Option<&str>,
) -> anyhow::Result<Vec<SkillSummary>> {
Ok(Vec::new())
}
async fn get_for_tenant(
&self,
_tenant_id: &str,
_name: &str,
) -> anyhow::Result<Option<SkillRecord>> {
Ok(None)
}
async fn upsert_for_tenant(
&self,
_tenant_id: &str,
_params: SkillsUpsertParams,
) -> anyhow::Result<(SkillRecord, bool)> {
Err(anyhow::anyhow!(
"upsert_for_tenant not implemented for this SkillsStore"
))
}
async fn delete_for_tenant(&self, _tenant_id: &str, _name: &str) -> anyhow::Result<bool> {
Err(anyhow::anyhow!(
"delete_for_tenant not implemented for this SkillsStore"
))
}
}
pub fn validate_skill_tenant_id(tenant_id: &str) -> Result<(), &'static str> {
if tenant_id.is_empty() {
return Err("tenant_id is empty");
}
if tenant_id.len() > 64 {
return Err("tenant_id longer than 64 chars");
}
if tenant_id == "__global__" {
return Err("tenant_id `__global__` is reserved for the shared slot");
}
let bytes = tenant_id.as_bytes();
let first = bytes[0];
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err("tenant_id must start with [a-z0-9]");
}
for &b in bytes {
let ok = b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-';
if !ok {
return Err("tenant_id must match [a-z0-9-]");
}
}
Ok(())
}
pub fn validate_skill_name(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("name is empty");
}
if name.len() > 64 {
return Err("name longer than 64 chars");
}
let bytes = name.as_bytes();
let first = bytes[0];
let starts_ok = first.is_ascii_lowercase() || first.is_ascii_digit();
if !starts_ok {
return Err("name must start with [a-z0-9]");
}
for &b in bytes {
let ok = b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-';
if !ok {
return Err("name must match [a-z0-9-]");
}
}
Ok(())
}
fn validate_body(body: &str) -> Result<(), &'static str> {
let trimmed = body.trim();
if trimmed.is_empty() {
return Err("body is empty");
}
if body.len() > MAX_SKILL_BODY_BYTES {
return Err("body exceeds 64 KiB cap");
}
Ok(())
}
pub async fn list(store: &dyn SkillsStore, params: Value) -> AdminRpcResult {
let p: SkillsListParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => return AdminRpcResult::err(AdminRpcError::InvalidParams(e.to_string())),
};
let tenant_id = p
.tenant_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(tid) = tenant_id {
if let Err(msg) = validate_skill_tenant_id(tid) {
return AdminRpcResult::err(AdminRpcError::InvalidParams(msg.into()));
}
}
let skills = match tenant_id {
Some(tid) => store.list_for_tenant(tid, p.prefix.as_deref()).await,
None => store.list(p.prefix.as_deref()).await,
};
let skills = match skills {
Ok(v) => v,
Err(e) => {
return AdminRpcResult::err(AdminRpcError::Internal(format!("skills_store.list: {e}")))
}
};
let response = SkillsListResponse { skills };
AdminRpcResult::ok(serde_json::to_value(response).unwrap_or(Value::Null))
}
pub async fn get(store: &dyn SkillsStore, params: Value) -> AdminRpcResult {
let p: SkillsGetParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => return AdminRpcResult::err(AdminRpcError::InvalidParams(e.to_string())),
};
if let Err(msg) = validate_skill_name(&p.name) {
return AdminRpcResult::err(AdminRpcError::InvalidParams(msg.into()));
}
let tenant_id = p
.tenant_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(tid) = tenant_id {
if let Err(msg) = validate_skill_tenant_id(tid) {
return AdminRpcResult::err(AdminRpcError::InvalidParams(msg.into()));
}
}
let skill = match tenant_id {
Some(tid) => store.get_for_tenant(tid, &p.name).await,
None => store.get(&p.name).await,
};
let skill = match skill {
Ok(s) => s,
Err(e) => {
return AdminRpcResult::err(AdminRpcError::Internal(format!("skills_store.get: {e}")))
}
};
let response = SkillsGetResponse { skill };
AdminRpcResult::ok(serde_json::to_value(response).unwrap_or(Value::Null))
}
pub async fn upsert(store: &dyn SkillsStore, params: Value) -> AdminRpcResult {
let p: SkillsUpsertParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => return AdminRpcResult::err(AdminRpcError::InvalidParams(e.to_string())),
};
if let Err(msg) = validate_skill_name(&p.name) {
return AdminRpcResult::err(AdminRpcError::InvalidParams(msg.into()));
}
if let Err(msg) = validate_body(&p.body) {
return AdminRpcResult::err(AdminRpcError::InvalidParams(msg.into()));
}
let tenant_id = p
.tenant_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
if let Some(tid) = &tenant_id {
if let Err(msg) = validate_skill_tenant_id(tid) {
return AdminRpcResult::err(AdminRpcError::InvalidParams(msg.into()));
}
}
let pair = match tenant_id.as_deref() {
Some(tid) => store.upsert_for_tenant(tid, p).await,
None => store.upsert(p).await,
};
let (skill, created) = match pair {
Ok(pair) => pair,
Err(e) => {
return AdminRpcResult::err(AdminRpcError::Internal(format!(
"skills_store.upsert: {e}"
)))
}
};
let response = SkillsUpsertResponse { skill, created };
AdminRpcResult::ok(serde_json::to_value(response).unwrap_or(Value::Null))
}
pub async fn delete(store: &dyn SkillsStore, params: Value) -> AdminRpcResult {
let p: SkillsDeleteParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => return AdminRpcResult::err(AdminRpcError::InvalidParams(e.to_string())),
};
if let Err(msg) = validate_skill_name(&p.name) {
return AdminRpcResult::err(AdminRpcError::InvalidParams(msg.into()));
}
let tenant_id = p
.tenant_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(tid) = tenant_id {
if let Err(msg) = validate_skill_tenant_id(tid) {
return AdminRpcResult::err(AdminRpcError::InvalidParams(msg.into()));
}
}
let deleted = match tenant_id {
Some(tid) => store.delete_for_tenant(tid, &p.name).await,
None => store.delete(&p.name).await,
};
let deleted = match deleted {
Ok(b) => b,
Err(e) => {
return AdminRpcResult::err(AdminRpcError::Internal(format!(
"skills_store.delete: {e}"
)))
}
};
let ack = SkillsDeleteAck { deleted };
AdminRpcResult::ok(serde_json::to_value(ack).unwrap_or(Value::Null))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::Mutex;
#[derive(Debug, Default, Clone)]
struct InMemoryStore {
rows: Arc<Mutex<BTreeMap<String, SkillRecord>>>,
}
#[async_trait]
impl SkillsStore for InMemoryStore {
async fn list(&self, prefix: Option<&str>) -> anyhow::Result<Vec<SkillSummary>> {
let rows = self.rows.lock().unwrap();
Ok(rows
.iter()
.filter(|(name, _)| match prefix {
Some(p) => name.starts_with(p),
None => true,
})
.map(|(_, r)| SkillSummary {
name: r.name.clone(),
display_name: r.display_name.clone(),
description: r.description.clone(),
updated_at: r.updated_at,
})
.collect())
}
async fn get(&self, name: &str) -> anyhow::Result<Option<SkillRecord>> {
Ok(self.rows.lock().unwrap().get(name).cloned())
}
async fn upsert(&self, params: SkillsUpsertParams) -> anyhow::Result<(SkillRecord, bool)> {
let mut rows = self.rows.lock().unwrap();
let created = !rows.contains_key(¶ms.name);
let record = SkillRecord {
name: params.name.clone(),
display_name: params.display_name,
description: params.description,
body: params.body,
max_chars: params.max_chars,
requires: params.requires.unwrap_or_default(),
updated_at: Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap(),
};
rows.insert(params.name, record.clone());
Ok((record, created))
}
async fn delete(&self, name: &str) -> anyhow::Result<bool> {
Ok(self.rows.lock().unwrap().remove(name).is_some())
}
}
fn store() -> Arc<InMemoryStore> {
Arc::new(InMemoryStore::default())
}
#[tokio::test]
async fn list_empty_returns_empty_vec() {
let s = store();
let r = list(s.as_ref(), json!({})).await;
assert!(r.error.is_none());
let v = r.result.unwrap();
assert_eq!(v["skills"], json!([]));
}
#[tokio::test]
async fn upsert_then_get_round_trips() {
let s = store();
let up = upsert(
s.as_ref(),
json!({
"name": "weather",
"display_name": "Weather",
"description": "Forecasts.",
"body": "Use for forecasts."
}),
)
.await;
assert!(up.error.is_none());
let v = up.result.unwrap();
assert_eq!(v["created"], json!(true));
assert_eq!(v["skill"]["name"], json!("weather"));
let g = get(s.as_ref(), json!({ "name": "weather" })).await;
assert!(g.error.is_none());
let v = g.result.unwrap();
assert_eq!(v["skill"]["body"], json!("Use for forecasts."));
}
#[tokio::test]
async fn upsert_twice_reports_created_false_second() {
let s = store();
let _ = upsert(s.as_ref(), json!({"name":"w","body":"a"})).await;
let up2 = upsert(s.as_ref(), json!({"name":"w","body":"b"})).await;
assert_eq!(up2.result.unwrap()["created"], json!(false));
}
#[tokio::test]
async fn get_missing_returns_null_not_error() {
let s = store();
let g = get(s.as_ref(), json!({ "name": "absent" })).await;
assert!(g.error.is_none());
assert_eq!(g.result.unwrap(), json!({ "skill": null }));
}
#[tokio::test]
async fn delete_missing_returns_false_not_error() {
let s = store();
let d = delete(s.as_ref(), json!({ "name": "absent" })).await;
assert!(d.error.is_none());
assert_eq!(d.result.unwrap()["deleted"], json!(false));
}
#[tokio::test]
async fn invalid_name_rejected_with_invalid_params() {
let s = store();
let cases = vec!["", "ABC", "foo bar", "../etc/passwd", "foo/bar", "1foo--ok"];
for name in cases {
let r = get(s.as_ref(), json!({ "name": name })).await;
if name == "1foo--ok" {
assert!(r.error.is_none(), "name `{name}` should be valid");
} else {
let e = r.error.expect("expected validation error");
assert_eq!(e.code(), -32602, "name `{name}` should be invalid_params");
}
}
}
#[tokio::test]
async fn body_too_large_rejected() {
let s = store();
let big = "x".repeat(MAX_SKILL_BODY_BYTES + 1);
let r = upsert(s.as_ref(), json!({ "name": "big", "body": big })).await;
let e = r.error.expect("body cap should reject");
assert_eq!(e.code(), -32602);
}
#[tokio::test]
async fn empty_body_rejected() {
let s = store();
let r = upsert(s.as_ref(), json!({ "name": "x", "body": " " })).await;
let e = r.error.expect("empty body should reject");
assert_eq!(e.code(), -32602);
}
#[tokio::test]
async fn list_prefix_filter() {
let s = store();
let _ = upsert(s.as_ref(), json!({"name":"alpha","body":"a"})).await;
let _ = upsert(s.as_ref(), json!({"name":"beta","body":"b"})).await;
let _ = upsert(s.as_ref(), json!({"name":"alpine","body":"c"})).await;
let r = list(s.as_ref(), json!({"prefix":"alp"})).await;
let v = r.result.unwrap();
let names: Vec<&str> = v["skills"]
.as_array()
.unwrap()
.iter()
.map(|s| s["name"].as_str().unwrap())
.collect();
assert_eq!(names, vec!["alpha", "alpine"]);
}
#[tokio::test]
async fn delete_then_list_drops_record() {
let s = store();
let _ = upsert(s.as_ref(), json!({"name":"gone","body":"x"})).await;
let _ = delete(s.as_ref(), json!({"name":"gone"})).await;
let r = list(s.as_ref(), json!({})).await;
assert_eq!(r.result.unwrap()["skills"], json!([]));
}
#[test]
fn validate_skill_name_accepts_kebab_lowercase() {
for name in ["weather", "lead-capture", "v1-2-3", "0test"] {
assert!(validate_skill_name(name).is_ok(), "{name} should be valid");
}
}
#[test]
fn validate_skill_name_rejects_dots_slashes_uppercase_underscores() {
for name in [
"",
"Weather",
"../escape",
"foo/bar",
"foo_bar",
"foo.bar",
"-foo",
] {
assert!(
validate_skill_name(name).is_err(),
"{name} should be invalid"
);
}
}
}