1use crate::primitives::Address;
8use serde::{Deserialize, Serialize};
9
10pub const SYSTEM_CREATOR_DID: &str = "did:tenzro:system:tenzro-network";
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20#[derive(Default)]
21pub enum SkillStatus {
22 #[default]
24 Active,
25 Inactive,
27 Deprecated,
29}
30
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SkillDefinition {
39 pub skill_id: String,
41
42 pub name: String,
44
45 pub version: String,
47
48 pub creator_did: String,
50
51 pub creator_wallet: Option<Address>,
57
58 pub description: String,
60
61 pub input_schema: serde_json::Value,
63
64 pub output_schema: serde_json::Value,
66
67 pub price_per_call: u128,
69
70 pub tags: Vec<String>,
72
73 pub required_capabilities: Vec<String>,
75
76 pub endpoint: Option<String>,
79
80 pub created_at: u64,
82
83 pub status: SkillStatus,
85
86 #[serde(default = "default_skill_category")]
88 pub category: String,
89
90 pub invocation_count: u64,
92
93 pub rating: u8,
95
96 #[serde(default = "default_last_seen")]
104 pub last_seen_at: u64,
105}
106
107fn default_skill_category() -> String {
108 "general".to_string()
109}
110
111fn default_last_seen() -> u64 {
112 std::time::SystemTime::now()
113 .duration_since(std::time::UNIX_EPOCH)
114 .unwrap_or_default()
115 .as_secs()
116}
117
118impl SkillDefinition {
119 pub fn new(
121 name: String,
122 version: String,
123 creator_did: String,
124 description: String,
125 price_per_call: u128,
126 ) -> Self {
127 let skill_id = uuid::Uuid::new_v4().to_string();
128 let created_at = std::time::SystemTime::now()
129 .duration_since(std::time::UNIX_EPOCH)
130 .unwrap_or_default()
131 .as_secs();
132
133 Self {
134 skill_id,
135 name,
136 version,
137 creator_did,
138 creator_wallet: None,
139 description,
140 input_schema: serde_json::Value::Object(serde_json::Map::new()),
141 output_schema: serde_json::Value::Object(serde_json::Map::new()),
142 price_per_call,
143 tags: Vec::new(),
144 required_capabilities: Vec::new(),
145 endpoint: None,
146 created_at,
147 status: SkillStatus::Active,
148 category: default_skill_category(),
149 invocation_count: 0,
150 rating: 0,
151 last_seen_at: created_at,
152 }
153 }
154
155 pub fn is_available(&self) -> bool {
157 self.status == SkillStatus::Active
158 }
159
160 pub fn touch(&mut self) {
164 self.last_seen_at = default_last_seen();
165 }
166
167 pub fn is_paid(&self) -> bool {
169 self.price_per_call > 0
170 }
171
172 pub fn validate_for_registration(&self) -> Result<(), &'static str> {
178 if self.is_paid() && self.creator_wallet.is_none() {
179 return Err("Paid skill (price_per_call > 0) requires a creator_wallet");
180 }
181 Ok(())
182 }
183}
184
185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187pub struct SkillFilter {
188 pub tag: Option<String>,
190
191 pub creator_did: Option<String>,
193
194 pub capability: Option<String>,
196
197 pub max_price: Option<u128>,
199
200 pub active_only: Option<bool>,
202
203 pub query: Option<String>,
205
206 pub limit: Option<usize>,
208
209 pub offset: Option<usize>,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct SkillInvocationResult {
216 pub skill_id: String,
218
219 pub invocation_id: String,
221
222 pub output: serde_json::Value,
224
225 pub settlement_tx: Option<String>,
227
228 pub amount_paid: u128,
230
231 pub completed_at: u64,
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn test_skill_definition_new() {
241 let skill = SkillDefinition::new(
242 "web-search".to_string(),
243 "1.0.0".to_string(),
244 "did:tenzro:human:test-123".to_string(),
245 "Searches the web and returns results".to_string(),
246 1_000_000_000_000_000_000, );
248
249 assert!(!skill.skill_id.is_empty());
250 assert_eq!(skill.name, "web-search");
251 assert_eq!(skill.version, "1.0.0");
252 assert!(skill.is_available());
253 assert!(skill.is_paid());
254 assert!(skill.creator_wallet.is_none(), "wallet starts unset; caller must populate before paid registration");
255 assert_eq!(skill.status, SkillStatus::Active);
256 assert_eq!(skill.invocation_count, 0);
257 }
258
259 #[test]
260 fn paid_skill_without_wallet_fails_validation() {
261 let skill = SkillDefinition::new(
262 "premium-skill".to_string(),
263 "1.0.0".to_string(),
264 "did:tenzro:human:creator".to_string(),
265 "Paid skill".to_string(),
266 1_000_000_000_000_000_000, );
268 assert!(skill.validate_for_registration().is_err());
270 }
271
272 #[test]
273 fn free_skill_without_wallet_passes_validation() {
274 let skill = SkillDefinition::new(
275 "free-skill".to_string(),
276 "1.0.0".to_string(),
277 "did:tenzro:human:creator".to_string(),
278 "Free skill".to_string(),
279 0,
280 );
281 assert!(skill.validate_for_registration().is_ok());
282 }
283
284 #[test]
285 fn paid_skill_with_wallet_passes_validation() {
286 let mut skill = SkillDefinition::new(
287 "paid-skill".to_string(),
288 "1.0.0".to_string(),
289 "did:tenzro:human:creator".to_string(),
290 "Paid skill".to_string(),
291 500,
292 );
293 skill.creator_wallet = Some(Address::default());
294 assert!(skill.validate_for_registration().is_ok());
295 }
296
297 #[test]
298 fn test_skill_status_default() {
299 assert_eq!(SkillStatus::default(), SkillStatus::Active);
300 }
301
302 #[test]
303 fn test_skill_filter_default() {
304 let filter = SkillFilter::default();
305 assert!(filter.tag.is_none());
306 assert!(filter.creator_did.is_none());
307 assert!(filter.max_price.is_none());
308 assert!(filter.active_only.is_none());
309 }
310
311 #[test]
312 fn test_skill_serialization() {
313 let skill = SkillDefinition::new(
314 "code-review".to_string(),
315 "2.0.0".to_string(),
316 "did:tenzro:machine:agent-abc".to_string(),
317 "Reviews code and suggests improvements".to_string(),
318 500_000_000_000_000_000, );
320
321 let json = serde_json::to_string(&skill).unwrap();
322 let deserialized: SkillDefinition = serde_json::from_str(&json).unwrap();
323 assert_eq!(skill.skill_id, deserialized.skill_id);
324 assert_eq!(skill.name, deserialized.name);
325 assert_eq!(skill.price_per_call, deserialized.price_per_call);
326 }
327}