use crate::skill::manifest::{Procedure, ProcedureStep, Requirement, SkillManifest, Trigger};
use crate::skill::mcp::{McpRequirement, SkillCapability};
use crate::skill::types::TriggerKind;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct TriggerGene {
pub kind: TriggerKind,
pub pattern: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct McpGene {
pub tool_pattern: String,
pub capability: SkillCapability,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StepGene {
pub intent: Option<String>,
pub description: String,
pub tool: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillGene {
pub triggers: BTreeSet<TriggerGene>,
pub steps: Vec<StepGene>,
pub requires: BTreeMap<String, String>,
pub mcp: BTreeSet<McpGene>,
}
#[derive(Debug, thiserror::Error)]
pub enum GeneError {
#[error("skill is not procedure-mode (recombine requires procedure-mode skills)")]
NotProcedure,
}
impl SkillGene {
pub fn from_manifest(m: &SkillManifest) -> Result<Self, GeneError> {
let proc = m
.content
.procedure
.as_ref()
.ok_or(GeneError::NotProcedure)?;
let triggers = m
.triggers
.iter()
.map(|t| TriggerGene {
kind: t.kind,
pattern: t.pattern.clone(),
})
.collect();
let steps = proc
.steps
.iter()
.map(|s| StepGene {
intent: s.intent.clone(),
description: s.description.clone(),
tool: s.tool.clone(),
})
.collect();
let requires = m
.requires
.iter()
.map(|r| (r.name.clone(), r.version.clone()))
.collect();
let mcp = m
.mcp_requirements
.iter()
.map(|r| McpGene {
tool_pattern: r.tool_pattern.clone(),
capability: r.capability,
})
.collect();
Ok(SkillGene {
triggers,
steps,
requires,
mcp,
})
}
pub fn to_procedure(&self) -> Procedure {
Procedure {
variables: Vec::new(),
steps: self
.steps
.iter()
.map(|s| ProcedureStep {
description: s.description.clone(),
tool: s.tool.clone(),
intent: s.intent.clone(),
tool_hint: None,
})
.collect(),
}
}
pub fn to_triggers(&self) -> Vec<Trigger> {
self.triggers
.iter()
.map(|t| Trigger {
kind: t.kind,
pattern: t.pattern.clone(),
})
.collect()
}
pub fn to_requirements(&self) -> Vec<Requirement> {
self.requires
.iter()
.map(|(name, version)| Requirement {
name: name.clone(),
version: version.clone(),
})
.collect()
}
pub fn to_mcp_requirements(&self) -> Vec<McpRequirement> {
self.mcp
.iter()
.map(|g| McpRequirement {
tool_pattern: g.tool_pattern.clone(),
capability: g.capability,
fallback: String::new(),
})
.collect()
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct GeneDiff {
pub triggers_added: Vec<TriggerGene>,
pub triggers_removed: Vec<TriggerGene>,
pub steps_added: Vec<StepGene>,
pub steps_removed: Vec<StepGene>,
pub steps_changed: Vec<(StepGene, StepGene)>,
pub requires_changed: Vec<(String, String, String)>,
pub requires_added: Vec<(String, String)>,
pub requires_removed: Vec<(String, String)>,
pub mcp_added: Vec<McpGene>,
pub mcp_removed: Vec<McpGene>,
}
impl GeneDiff {
pub fn between(a: &SkillGene, b: &SkillGene) -> Self {
let mut d = GeneDiff {
triggers_added: b.triggers.difference(&a.triggers).cloned().collect(),
triggers_removed: a.triggers.difference(&b.triggers).cloned().collect(),
mcp_added: b.mcp.difference(&a.mcp).cloned().collect(),
mcp_removed: a.mcp.difference(&b.mcp).cloned().collect(),
..Default::default()
};
for (name, a_ver) in &a.requires {
match b.requires.get(name) {
None => d.requires_removed.push((name.clone(), a_ver.clone())),
Some(b_ver) if b_ver != a_ver => {
d.requires_changed
.push((name.clone(), a_ver.clone(), b_ver.clone()));
}
_ => {}
}
}
for (name, b_ver) in &b.requires {
if !a.requires.contains_key(name) {
d.requires_added.push((name.clone(), b_ver.clone()));
}
}
let a_by_intent: BTreeMap<&str, &StepGene> = a
.steps
.iter()
.filter_map(|s| s.intent.as_deref().map(|i| (i, s)))
.collect();
let b_by_intent: BTreeMap<&str, &StepGene> = b
.steps
.iter()
.filter_map(|s| s.intent.as_deref().map(|i| (i, s)))
.collect();
for (intent, a_step) in &a_by_intent {
match b_by_intent.get(intent) {
None => d.steps_removed.push((*a_step).clone()),
Some(b_step) if a_step != b_step => {
d.steps_changed.push(((*a_step).clone(), (*b_step).clone()));
}
_ => {}
}
}
for (intent, b_step) in &b_by_intent {
if !a_by_intent.contains_key(intent) {
d.steps_added.push((*b_step).clone());
}
}
for s in a.steps.iter().filter(|s| s.intent.is_none()) {
d.steps_removed.push(s.clone());
}
for s in b.steps.iter().filter(|s| s.intent.is_none()) {
d.steps_added.push(s.clone());
}
d
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skill::manifest::{Content, Procedure, ProcedureStep, Trigger};
use crate::skill::types::{Category, TriggerKind};
fn manifest_with_steps(steps: Vec<ProcedureStep>, triggers: Vec<Trigger>) -> SkillManifest {
SkillManifest {
name: "x".into(),
version: "0.1.0".into(),
publisher: "human:test".into(),
description: "t".into(),
category: Category::Workflow,
hosts: vec![],
content: Content {
r#abstract: "a".into(),
context: None,
procedure: Some(Procedure {
variables: vec![],
steps,
}),
command: None,
},
requires: vec![],
tags: vec![],
triggers,
priority: Default::default(),
evolution_log: vec![],
transfer_chain: vec![],
mcp_requirements: vec![],
}
}
#[test]
fn from_manifest_extracts_gene_fields() {
let m = manifest_with_steps(
vec![ProcedureStep {
description: "navigate".into(),
tool: Some("browser.go".into()),
intent: Some("open_page".into()),
tool_hint: None,
}],
vec![Trigger {
kind: TriggerKind::Command,
pattern: Some("/x".into()),
}],
);
let g = SkillGene::from_manifest(&m).unwrap();
assert_eq!(g.steps.len(), 1);
assert_eq!(g.steps[0].intent.as_deref(), Some("open_page"));
assert_eq!(g.triggers.len(), 1);
}
#[test]
fn from_manifest_rejects_non_procedure() {
let mut m = manifest_with_steps(vec![], vec![]);
m.content.procedure = None;
m.content.context = Some("ctx".into());
assert!(matches!(
SkillGene::from_manifest(&m),
Err(GeneError::NotProcedure)
));
}
#[test]
fn diff_detects_added_and_changed_steps() {
let a = manifest_with_steps(
vec![ProcedureStep {
description: "old".into(),
tool: None,
intent: Some("i1".into()),
tool_hint: None,
}],
vec![],
);
let b = manifest_with_steps(
vec![
ProcedureStep {
description: "new desc".into(),
tool: None,
intent: Some("i1".into()),
tool_hint: None,
},
ProcedureStep {
description: "added".into(),
tool: None,
intent: Some("i2".into()),
tool_hint: None,
},
],
vec![],
);
let ga = SkillGene::from_manifest(&a).unwrap();
let gb = SkillGene::from_manifest(&b).unwrap();
let d = GeneDiff::between(&ga, &gb);
assert_eq!(d.steps_changed.len(), 1);
assert_eq!(d.steps_added.len(), 1);
assert_eq!(d.steps_removed.len(), 0);
}
#[test]
fn diff_treats_intentless_steps_as_unmatched() {
let a = manifest_with_steps(
vec![ProcedureStep {
description: "no-intent".into(),
tool: None,
intent: None,
tool_hint: None,
}],
vec![],
);
let b = manifest_with_steps(vec![], vec![]);
let ga = SkillGene::from_manifest(&a).unwrap();
let gb = SkillGene::from_manifest(&b).unwrap();
let d = GeneDiff::between(&ga, &gb);
assert_eq!(d.steps_removed.len(), 1);
}
#[test]
fn round_trip_to_procedure_preserves_intent_and_tool() {
let g = SkillGene {
triggers: BTreeSet::new(),
steps: vec![StepGene {
intent: Some("i".into()),
description: "d".into(),
tool: Some("t".into()),
}],
requires: BTreeMap::new(),
mcp: BTreeSet::new(),
};
let p = g.to_procedure();
assert_eq!(p.steps.len(), 1);
assert_eq!(p.steps[0].intent.as_deref(), Some("i"));
assert_eq!(p.steps[0].tool.as_deref(), Some("t"));
assert!(p.steps[0].tool_hint.is_none());
}
}