use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SkillDepsMode {
#[default]
Strict,
Warn,
Disable,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct SkillRequiresRecord {
pub bins: Vec<String>,
pub env: Vec<String>,
pub mode: SkillDepsMode,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillSummary {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillRecord {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub body: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_chars: Option<usize>,
#[serde(default)]
pub requires: SkillRequiresRecord,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct SkillsListParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillsListResponse {
pub skills: Vec<SkillSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillsGetParams {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillsGetResponse {
pub skill: Option<SkillRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillsUpsertParams {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_chars: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requires: Option<SkillRequiresRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillsUpsertResponse {
pub skill: SkillRecord,
pub created: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillsDeleteParams {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillsDeleteAck {
pub deleted: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use serde_json::{from_value, json, to_value};
fn fixed_ts() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap()
}
#[test]
fn skill_record_round_trips() {
let record = SkillRecord {
name: "tarifario-2026".into(),
display_name: Some("Tarifario 2026".into()),
description: Some("Planes ETB".into()),
body: "# Tarifario\n\nPlanes:".into(),
max_chars: Some(2048),
requires: SkillRequiresRecord {
bins: vec!["jq".into()],
env: vec!["TARIFARIO_TOKEN".into()],
mode: SkillDepsMode::Warn,
},
updated_at: fixed_ts(),
};
let v = to_value(&record).unwrap();
let back: SkillRecord = from_value(v).unwrap();
assert_eq!(record, back);
}
#[test]
fn skill_record_omits_optional_fields_when_none() {
let record = SkillRecord {
name: "minimal".into(),
display_name: None,
description: None,
body: "body".into(),
max_chars: None,
requires: SkillRequiresRecord::default(),
updated_at: fixed_ts(),
};
let v = to_value(&record).unwrap();
let obj = v.as_object().unwrap();
assert!(!obj.contains_key("display_name"));
assert!(!obj.contains_key("description"));
assert!(!obj.contains_key("max_chars"));
}
#[test]
fn skill_record_uses_snake_case_keys() {
let record = SkillRecord {
name: "demo".into(),
display_name: Some("Demo".into()),
description: None,
body: "body".into(),
max_chars: None,
requires: SkillRequiresRecord::default(),
updated_at: fixed_ts(),
};
let v = to_value(&record).unwrap();
let obj = v.as_object().unwrap();
assert!(obj.contains_key("display_name"));
assert!(obj.contains_key("updated_at"));
assert!(!obj.contains_key("displayName"));
assert!(!obj.contains_key("updatedAt"));
}
#[test]
fn skill_deps_mode_serializes_snake_case() {
assert_eq!(to_value(SkillDepsMode::Strict).unwrap(), json!("strict"));
assert_eq!(to_value(SkillDepsMode::Warn).unwrap(), json!("warn"));
assert_eq!(to_value(SkillDepsMode::Disable).unwrap(), json!("disable"));
}
#[test]
fn skills_list_params_default_has_no_prefix() {
let p: SkillsListParams = serde_json::from_str("{}").unwrap();
assert!(p.prefix.is_none());
}
#[test]
fn skills_get_response_serializes_none_skill_explicitly() {
let r = SkillsGetResponse { skill: None };
let v = to_value(&r).unwrap();
assert_eq!(v, json!({ "skill": null }));
}
#[test]
fn skills_upsert_params_round_trips() {
let p = SkillsUpsertParams {
name: "weather".into(),
display_name: Some("Weather".into()),
description: Some("Forecast".into()),
body: "body".into(),
max_chars: Some(1024),
requires: Some(SkillRequiresRecord {
bins: vec!["curl".into()],
env: vec![],
mode: SkillDepsMode::Strict,
}),
tenant_id: None,
};
let v = to_value(&p).unwrap();
let back: SkillsUpsertParams = from_value(v).unwrap();
assert_eq!(p, back);
}
#[test]
fn skills_delete_ack_round_trips() {
for deleted in [true, false] {
let ack = SkillsDeleteAck { deleted };
let v = to_value(&ack).unwrap();
let back: SkillsDeleteAck = from_value(v).unwrap();
assert_eq!(ack, back);
}
}
#[test]
fn frontmatter_compose_round_trips_through_skill_loader_format() {
let record = SkillRecord {
name: "ping".into(),
display_name: Some("Ping".into()),
description: Some("Probe.".into()),
body: "Use to check liveness.".into(),
max_chars: None,
requires: SkillRequiresRecord::default(),
updated_at: fixed_ts(),
};
let blob = format!(
"---\nname: {}\ndescription: {}\n---\n\n{}",
record.display_name.as_deref().unwrap(),
record.description.as_deref().unwrap(),
record.body,
);
assert!(blob.starts_with("---\n"));
assert!(blob.contains("\n---\n\n"));
assert!(blob.ends_with("Use to check liveness."));
}
}