use super::evolution::EvolutionEvent;
use super::mcp::McpRequirement;
use super::types::{Category, ContentMode, HostId, Priority, TriggerKind, TrustLevel};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Skill {
#[serde(flatten)]
pub manifest: SkillManifest,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_sha256: Option<String>,
#[serde(default)]
pub trust_level: TrustLevel,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities_declared: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher_signature: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillManifest {
pub name: String,
pub version: String,
pub publisher: String,
pub description: String,
pub category: Category,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hosts: Vec<HostId>,
pub content: Content,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<Requirement>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub triggers: Vec<Trigger>,
#[serde(default)]
pub priority: Priority,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evolution_log: Vec<EvolutionEvent>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub transfer_chain: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_requirements: Vec<McpRequirement>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Content {
pub r#abstract: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub procedure: Option<Procedure>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
}
impl Content {
pub fn mode(&self) -> Option<ContentMode> {
match (
self.context.is_some(),
self.procedure.is_some(),
self.command.is_some(),
) {
(true, false, false) => Some(ContentMode::Context),
(false, true, false) => Some(ContentMode::Workflow),
(false, false, true) => Some(ContentMode::Command),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Procedure {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub variables: Vec<Variable>,
pub steps: Vec<ProcedureStep>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Variable {
pub name: String,
#[serde(rename = "type")]
pub var_type: String,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<serde_yaml_ng::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcedureStep {
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trigger {
#[serde(rename = "type")]
pub kind: TriggerKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
}
impl Trigger {
pub fn exact_keyword(&self) -> Option<&str> {
if matches!(self.kind, TriggerKind::Keyword) {
self.pattern.as_deref()
} else {
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Requirement {
pub name: String,
#[serde(default = "default_any_version")]
pub version: String,
}
fn default_any_version() -> String {
"*".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_manifest_roundtrips() {
let yaml = r#"
name: research-prices
version: 1.0.0
publisher: human:david
description: Search product prices
category: workflow
hosts: [mur-agent]
content:
abstract: Searches product prices.
procedure:
variables:
- name: product_name
type: string
required: true
steps:
- description: Navigate
tool: browser.navigate
triggers:
- type: command
pattern: /research-prices
priority: normal
"#;
let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(m.name, "research-prices");
assert_eq!(m.category, Category::Workflow);
assert_eq!(m.content.mode(), Some(ContentMode::Workflow));
let back = serde_yaml_ng::to_string(&m).unwrap();
let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
assert_eq!(m2.name, m.name);
}
#[test]
fn context_mode_detected() {
let c = Content {
r#abstract: "a".into(),
context: Some("ctx".into()),
procedure: None,
command: None,
};
assert_eq!(c.mode(), Some(ContentMode::Context));
}
#[test]
fn empty_content_returns_no_mode() {
let c = Content {
r#abstract: "a".into(),
context: None,
procedure: None,
command: None,
};
assert_eq!(c.mode(), None);
}
#[test]
fn skill_without_evolution_log_defaults_to_empty() {
let yaml = r#"
name: no-evol
version: 0.1.0
publisher: human:test
description: test
category: workflow
content:
abstract: test
"#;
let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
assert!(m.evolution_log.is_empty());
}
#[test]
fn skill_with_evolution_log_roundtrips() {
let yaml = r#"
name: with-evol
version: 0.1.0
publisher: human:test
description: test
category: workflow
content:
abstract: test
evolution_log:
- version: "0.1.0"
generation: 0
source: "human:test"
changes: "Initial"
timestamp: "2026-01-01T00:00:00Z"
"#;
let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(m.evolution_log.len(), 1);
assert_eq!(m.evolution_log[0].version, "0.1.0");
let back = serde_yaml_ng::to_string(&m).unwrap();
let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
assert_eq!(m2.evolution_log.len(), 1);
assert_eq!(m2.evolution_log[0].generation, 0);
}
#[test]
fn exact_keyword_returns_pattern_for_keyword_triggers() {
let t = Trigger {
kind: TriggerKind::Keyword,
pattern: Some("search".into()),
};
assert_eq!(t.exact_keyword(), Some("search"));
}
#[test]
fn exact_keyword_returns_none_for_non_keyword_triggers() {
let t = Trigger {
kind: TriggerKind::Command,
pattern: Some("run".into()),
};
assert_eq!(t.exact_keyword(), None);
let t = Trigger {
kind: TriggerKind::SessionStart,
pattern: None,
};
assert_eq!(t.exact_keyword(), None);
let t = Trigger {
kind: TriggerKind::Manual,
pattern: None,
};
assert_eq!(t.exact_keyword(), None);
}
#[test]
fn exact_keyword_returns_none_when_pattern_is_none() {
let t = Trigger {
kind: TriggerKind::Keyword,
pattern: None,
};
assert_eq!(t.exact_keyword(), None);
}
}