1use serde::{Deserialize, Serialize};
8
9use crate::skill_spec::{SkillArgumentSpec, SkillSpec, SkillSpecContext};
10
11pub type NullablePatch<T> = Option<Option<T>>;
12
13#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
14#[serde(default, deny_unknown_fields)]
15pub struct SkillSpecPatch {
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub name: Option<String>,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub description: Option<String>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub instructions_md: Option<String>,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub allowed_tools: Option<Vec<String>>,
24 #[serde(
25 default,
26 deserialize_with = "nullable_patch::deserialize",
27 serialize_with = "nullable_patch::serialize",
28 skip_serializing_if = "nullable_patch::is_missing"
29 )]
30 pub when_to_use: NullablePatch<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub arguments: Option<Vec<SkillArgumentSpec>>,
33 #[serde(
34 default,
35 deserialize_with = "nullable_patch::deserialize",
36 serialize_with = "nullable_patch::serialize",
37 skip_serializing_if = "nullable_patch::is_missing"
38 )]
39 pub argument_hint: NullablePatch<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub user_invocable: Option<bool>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub model_invocable: Option<bool>,
44 #[serde(
45 default,
46 deserialize_with = "nullable_patch::deserialize",
47 serialize_with = "nullable_patch::serialize",
48 skip_serializing_if = "nullable_patch::is_missing"
49 )]
50 pub model_override: NullablePatch<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub context: Option<SkillSpecContext>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub paths: Option<Vec<String>>,
55}
56
57impl SkillSpecPatch {
58 pub fn is_empty(&self) -> bool {
59 self.name.is_none()
60 && self.description.is_none()
61 && self.instructions_md.is_none()
62 && self.allowed_tools.is_none()
63 && self.when_to_use.is_none()
64 && self.arguments.is_none()
65 && self.argument_hint.is_none()
66 && self.user_invocable.is_none()
67 && self.model_invocable.is_none()
68 && self.model_override.is_none()
69 && self.context.is_none()
70 && self.paths.is_none()
71 }
72}
73
74pub fn merge_skill_spec(base: SkillSpec, patch: SkillSpecPatch) -> SkillSpec {
75 SkillSpec {
76 id: base.id,
77 name: patch.name.unwrap_or(base.name),
78 description: patch.description.unwrap_or(base.description),
79 instructions_md: patch.instructions_md.unwrap_or(base.instructions_md),
80 allowed_tools: patch.allowed_tools.unwrap_or(base.allowed_tools),
81 when_to_use: merge_nullable(base.when_to_use, patch.when_to_use),
82 arguments: patch.arguments.unwrap_or(base.arguments),
83 argument_hint: merge_nullable(base.argument_hint, patch.argument_hint),
84 user_invocable: patch.user_invocable.unwrap_or(base.user_invocable),
85 model_invocable: patch.model_invocable.unwrap_or(base.model_invocable),
86 model_override: merge_nullable(base.model_override, patch.model_override),
87 context: patch.context.unwrap_or(base.context),
88 paths: patch.paths.unwrap_or(base.paths),
89 }
90}
91
92fn merge_nullable<T>(base: Option<T>, patch: NullablePatch<T>) -> Option<T> {
93 patch.unwrap_or(base)
94}
95
96mod nullable_patch {
97 use serde::{Deserialize, Deserializer, Serialize, Serializer};
98
99 pub fn serialize<S, T>(value: &Option<Option<T>>, serializer: S) -> Result<S::Ok, S::Error>
100 where
101 S: Serializer,
102 T: Serialize,
103 {
104 match value {
105 None => serializer.serialize_none(),
106 Some(inner) => inner.serialize(serializer),
107 }
108 }
109
110 pub fn deserialize<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
111 where
112 D: Deserializer<'de>,
113 T: Deserialize<'de>,
114 {
115 Option::<T>::deserialize(deserializer).map(Some)
116 }
117
118 pub fn is_missing<T>(value: &Option<Option<T>>) -> bool {
119 value.is_none()
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use serde_json::json;
127
128 fn base() -> SkillSpec {
129 SkillSpec {
130 id: "db-management".into(),
131 name: "Database Management".into(),
132 description: "stock".into(),
133 instructions_md: "Use stock instructions.".into(),
134 when_to_use: Some("stock hint".into()),
135 argument_hint: Some("dialect=postgres".into()),
136 model_override: Some("fast".into()),
137 ..Default::default()
138 }
139 }
140
141 #[test]
142 fn empty_patch_keeps_base() {
143 assert_eq!(merge_skill_spec(base(), SkillSpecPatch::default()), base());
144 }
145
146 #[test]
147 fn scalar_patch_replaces_base() {
148 let merged = merge_skill_spec(
149 base(),
150 SkillSpecPatch {
151 description: Some("custom".into()),
152 instructions_md: Some("Custom.".into()),
153 model_invocable: Some(false),
154 ..Default::default()
155 },
156 );
157 assert_eq!(merged.description, "custom");
158 assert_eq!(merged.instructions_md, "Custom.");
159 assert!(!merged.model_invocable);
160 }
161
162 #[test]
163 fn nullable_patch_clears_values() {
164 let patch: SkillSpecPatch = serde_json::from_value(json!({
165 "when_to_use": null,
166 "argument_hint": null,
167 "model_override": null
168 }))
169 .unwrap();
170 let merged = merge_skill_spec(base(), patch);
171 assert_eq!(merged.when_to_use, None);
172 assert_eq!(merged.argument_hint, None);
173 assert_eq!(merged.model_override, None);
174 }
175
176 #[test]
177 fn unknown_field_is_rejected() {
178 let bad = json!({"description": "x", "id": "renamed"});
179 assert!(serde_json::from_value::<SkillSpecPatch>(bad).is_err());
180 }
181}