1use argentor_core::{ArgentorResult, ToolCall, ToolResult};
2use argentor_skills::skill::{Skill, SkillDescriptor};
3use async_trait::async_trait;
4use base64::{engine::general_purpose, Engine as _};
5
6pub struct EncodeDecodeSkill {
12 descriptor: SkillDescriptor,
13}
14
15impl EncodeDecodeSkill {
16 pub fn new() -> Self {
18 Self {
19 descriptor: SkillDescriptor {
20 name: "encode_decode".to_string(),
21 description:
22 "Encoding/decoding: Base64, hex, URL, HTML entities, JWT payload parsing."
23 .to_string(),
24 parameters_schema: serde_json::json!({
25 "type": "object",
26 "properties": {
27 "operation": {
28 "type": "string",
29 "enum": [
30 "base64_encode", "base64_decode",
31 "base64url_encode", "base64url_decode",
32 "hex_encode", "hex_decode",
33 "url_encode", "url_decode",
34 "html_encode", "html_decode",
35 "jwt_decode"
36 ],
37 "description": "The encoding/decoding operation to perform"
38 },
39 "input": {
40 "type": "string",
41 "description": "The input string to encode or decode"
42 }
43 },
44 "required": ["operation", "input"]
45 }),
46 required_capabilities: vec![],
47 requires_approval: false,
48 },
49 }
50 }
51}
52
53impl Default for EncodeDecodeSkill {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59fn url_encode(input: &str) -> String {
61 let mut encoded = String::with_capacity(input.len() * 3);
62 for byte in input.bytes() {
63 match byte {
64 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
65 encoded.push(byte as char);
66 }
67 _ => {
68 encoded.push('%');
69 encoded.push_str(&format!("{byte:02X}"));
70 }
71 }
72 }
73 encoded
74}
75
76fn url_decode(input: &str) -> Result<String, String> {
78 let mut bytes = Vec::with_capacity(input.len());
79 let mut chars = input.bytes().peekable();
80 while let Some(b) = chars.next() {
81 if b == b'%' {
82 let hi = chars
83 .next()
84 .ok_or("Incomplete percent-encoding: unexpected end of input")?;
85 let lo = chars
86 .next()
87 .ok_or("Incomplete percent-encoding: unexpected end of input")?;
88 let hex_str = format!("{}{}", hi as char, lo as char);
89 let decoded = u8::from_str_radix(&hex_str, 16)
90 .map_err(|_| format!("Invalid percent-encoding: %{hex_str}"))?;
91 bytes.push(decoded);
92 } else if b == b'+' {
93 bytes.push(b' ');
94 } else {
95 bytes.push(b);
96 }
97 }
98 String::from_utf8(bytes).map_err(|e| format!("Decoded bytes are not valid UTF-8: {e}"))
99}
100
101fn html_encode(input: &str) -> String {
103 let mut encoded = String::with_capacity(input.len());
104 for ch in input.chars() {
105 match ch {
106 '&' => encoded.push_str("&"),
107 '<' => encoded.push_str("<"),
108 '>' => encoded.push_str(">"),
109 '"' => encoded.push_str("""),
110 '\'' => encoded.push_str("'"),
111 _ => encoded.push(ch),
112 }
113 }
114 encoded
115}
116
117fn html_decode(input: &str) -> String {
119 input
120 .replace("&", "&")
121 .replace("<", "<")
122 .replace(">", ">")
123 .replace(""", "\"")
124 .replace("'", "'")
125 .replace("'", "'")
126 .replace("/", "/")
127}
128
129fn jwt_decode(token: &str) -> Result<String, String> {
131 let parts: Vec<&str> = token.split('.').collect();
132 if parts.len() != 3 {
133 return Err(format!(
134 "Invalid JWT format: expected 3 dot-separated parts, got {}",
135 parts.len()
136 ));
137 }
138
139 let payload_b64 = parts[1];
140
141 let padded = match payload_b64.len() % 4 {
143 2 => format!("{payload_b64}=="),
144 3 => format!("{payload_b64}="),
145 _ => payload_b64.to_string(),
146 };
147
148 let decoded_bytes = general_purpose::URL_SAFE_NO_PAD
149 .decode(padded.trim_end_matches('='))
150 .or_else(|_| general_purpose::URL_SAFE.decode(&padded))
151 .map_err(|e| format!("Failed to base64url-decode JWT payload: {e}"))?;
152
153 let payload_str = String::from_utf8(decoded_bytes)
154 .map_err(|e| format!("JWT payload is not valid UTF-8: {e}"))?;
155
156 serde_json::from_str::<serde_json::Value>(&payload_str)
158 .map_err(|e| format!("JWT payload is not valid JSON: {e}"))?;
159
160 Ok(payload_str)
161}
162
163fn success_response(call_id: &str, result: &str, encoding: &str) -> ToolResult {
165 let response = serde_json::json!({
166 "result": result,
167 "encoding": encoding
168 });
169 ToolResult::success(call_id, response.to_string())
170}
171
172#[async_trait]
173impl Skill for EncodeDecodeSkill {
174 fn descriptor(&self) -> &SkillDescriptor {
175 &self.descriptor
176 }
177
178 async fn execute(&self, call: ToolCall) -> ArgentorResult<ToolResult> {
179 let operation = match call.arguments["operation"].as_str() {
180 Some(op) => op,
181 None => {
182 return Ok(ToolResult::error(
183 &call.id,
184 "Missing required parameter: 'operation'",
185 ))
186 }
187 };
188 let input = match call.arguments["input"].as_str() {
189 Some(v) => v,
190 None => {
191 return Ok(ToolResult::error(
192 &call.id,
193 "Missing required parameter: 'input'",
194 ))
195 }
196 };
197
198 match operation {
199 "base64_encode" => {
200 let encoded = general_purpose::STANDARD.encode(input.as_bytes());
201 Ok(success_response(&call.id, &encoded, "base64"))
202 }
203 "base64_decode" => match general_purpose::STANDARD.decode(input.as_bytes()) {
204 Ok(bytes) => match String::from_utf8(bytes) {
205 Ok(decoded) => Ok(success_response(&call.id, &decoded, "base64")),
206 Err(e) => Ok(ToolResult::error(
207 &call.id,
208 format!("Decoded bytes are not valid UTF-8: {e}"),
209 )),
210 },
211 Err(e) => Ok(ToolResult::error(
212 &call.id,
213 format!("Invalid base64 input: {e}"),
214 )),
215 },
216 "base64url_encode" => {
217 let encoded = general_purpose::URL_SAFE_NO_PAD.encode(input.as_bytes());
218 Ok(success_response(&call.id, &encoded, "base64url"))
219 }
220 "base64url_decode" => {
221 let decode_result = general_purpose::URL_SAFE_NO_PAD
223 .decode(input.as_bytes())
224 .or_else(|_| general_purpose::URL_SAFE.decode(input.as_bytes()));
225 match decode_result {
226 Ok(bytes) => match String::from_utf8(bytes) {
227 Ok(decoded) => Ok(success_response(&call.id, &decoded, "base64url")),
228 Err(e) => Ok(ToolResult::error(
229 &call.id,
230 format!("Decoded bytes are not valid UTF-8: {e}"),
231 )),
232 },
233 Err(e) => Ok(ToolResult::error(
234 &call.id,
235 format!("Invalid base64url input: {e}"),
236 )),
237 }
238 }
239 "hex_encode" => {
240 let encoded = hex::encode(input.as_bytes());
241 Ok(success_response(&call.id, &encoded, "hex"))
242 }
243 "hex_decode" => match hex::decode(input) {
244 Ok(bytes) => match String::from_utf8(bytes) {
245 Ok(decoded) => Ok(success_response(&call.id, &decoded, "hex")),
246 Err(e) => Ok(ToolResult::error(
247 &call.id,
248 format!("Decoded bytes are not valid UTF-8: {e}"),
249 )),
250 },
251 Err(e) => Ok(ToolResult::error(
252 &call.id,
253 format!("Invalid hex input: {e}"),
254 )),
255 },
256 "url_encode" => {
257 let encoded = url_encode(input);
258 Ok(success_response(&call.id, &encoded, "url"))
259 }
260 "url_decode" => match url_decode(input) {
261 Ok(decoded) => Ok(success_response(&call.id, &decoded, "url")),
262 Err(e) => Ok(ToolResult::error(&call.id, e)),
263 },
264 "html_encode" => {
265 let encoded = html_encode(input);
266 Ok(success_response(&call.id, &encoded, "html"))
267 }
268 "html_decode" => {
269 let decoded = html_decode(input);
270 Ok(success_response(&call.id, &decoded, "html"))
271 }
272 "jwt_decode" => match jwt_decode(input) {
273 Ok(payload) => {
274 let response = serde_json::json!({
275 "result": serde_json::from_str::<serde_json::Value>(&payload).unwrap_or(serde_json::Value::String(payload)),
276 "encoding": "jwt"
277 });
278 Ok(ToolResult::success(&call.id, response.to_string()))
279 }
280 Err(e) => Ok(ToolResult::error(&call.id, e)),
281 },
282 _ => Ok(ToolResult::error(
283 &call.id,
284 format!(
285 "Unknown operation: '{operation}'. Supported: base64_encode, base64_decode, \
286 base64url_encode, base64url_decode, hex_encode, hex_decode, \
287 url_encode, url_decode, html_encode, html_decode, jwt_decode"
288 ),
289 )),
290 }
291 }
292}
293
294#[cfg(test)]
295#[allow(clippy::unwrap_used, clippy::expect_used)]
296mod tests {
297 use super::*;
298
299 fn make_call(args: serde_json::Value) -> ToolCall {
300 ToolCall {
301 id: "test".to_string(),
302 name: "encode_decode".to_string(),
303 arguments: args,
304 }
305 }
306
307 #[tokio::test]
308 async fn test_base64_encode() {
309 let skill = EncodeDecodeSkill::new();
310 let call = make_call(serde_json::json!({
311 "operation": "base64_encode",
312 "input": "hello world"
313 }));
314 let result = skill.execute(call).await.unwrap();
315 assert!(!result.is_error, "Result: {}", result.content);
316 let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
317 assert_eq!(parsed["result"], "aGVsbG8gd29ybGQ=");
318 assert_eq!(parsed["encoding"], "base64");
319 }
320
321 #[tokio::test]
322 async fn test_base64_decode() {
323 let skill = EncodeDecodeSkill::new();
324 let call = make_call(serde_json::json!({
325 "operation": "base64_decode",
326 "input": "aGVsbG8gd29ybGQ="
327 }));
328 let result = skill.execute(call).await.unwrap();
329 assert!(!result.is_error);
330 let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
331 assert_eq!(parsed["result"], "hello world");
332 }
333
334 #[tokio::test]
335 async fn test_base64_decode_invalid() {
336 let skill = EncodeDecodeSkill::new();
337 let call = make_call(serde_json::json!({
338 "operation": "base64_decode",
339 "input": "!!!not-base64!!!"
340 }));
341 let result = skill.execute(call).await.unwrap();
342 assert!(result.is_error);
343 }
344
345 #[tokio::test]
346 async fn test_base64url_encode() {
347 let skill = EncodeDecodeSkill::new();
348 let call = make_call(serde_json::json!({
349 "operation": "base64url_encode",
350 "input": "hello+world/foo"
351 }));
352 let result = skill.execute(call).await.unwrap();
353 assert!(!result.is_error);
354 let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
355 assert_eq!(parsed["encoding"], "base64url");
356 let encoded = parsed["result"].as_str().unwrap();
358 assert!(!encoded.contains('+'));
359 assert!(!encoded.contains('/'));
360 }
361
362 #[tokio::test]
363 async fn test_base64url_roundtrip() {
364 let skill = EncodeDecodeSkill::new();
365 let original = "data with special chars: +/=";
366
367 let enc_call = make_call(serde_json::json!({
368 "operation": "base64url_encode",
369 "input": original
370 }));
371 let enc_result = skill.execute(enc_call).await.unwrap();
372 let enc_parsed: serde_json::Value = serde_json::from_str(&enc_result.content).unwrap();
373 let encoded = enc_parsed["result"].as_str().unwrap();
374
375 let dec_call = make_call(serde_json::json!({
376 "operation": "base64url_decode",
377 "input": encoded
378 }));
379 let dec_result = skill.execute(dec_call).await.unwrap();
380 assert!(!dec_result.is_error);
381 let dec_parsed: serde_json::Value = serde_json::from_str(&dec_result.content).unwrap();
382 assert_eq!(dec_parsed["result"], original);
383 }
384
385 #[tokio::test]
386 async fn test_hex_encode() {
387 let skill = EncodeDecodeSkill::new();
388 let call = make_call(serde_json::json!({
389 "operation": "hex_encode",
390 "input": "hello"
391 }));
392 let result = skill.execute(call).await.unwrap();
393 assert!(!result.is_error);
394 let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
395 assert_eq!(parsed["result"], "68656c6c6f");
396 assert_eq!(parsed["encoding"], "hex");
397 }
398
399 #[tokio::test]
400 async fn test_hex_decode() {
401 let skill = EncodeDecodeSkill::new();
402 let call = make_call(serde_json::json!({
403 "operation": "hex_decode",
404 "input": "68656c6c6f"
405 }));
406 let result = skill.execute(call).await.unwrap();
407 assert!(!result.is_error);
408 let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
409 assert_eq!(parsed["result"], "hello");
410 }
411
412 #[tokio::test]
413 async fn test_hex_decode_invalid() {
414 let skill = EncodeDecodeSkill::new();
415 let call = make_call(serde_json::json!({
416 "operation": "hex_decode",
417 "input": "zzzz"
418 }));
419 let result = skill.execute(call).await.unwrap();
420 assert!(result.is_error);
421 }
422
423 #[tokio::test]
424 async fn test_url_encode() {
425 let skill = EncodeDecodeSkill::new();
426 let call = make_call(serde_json::json!({
427 "operation": "url_encode",
428 "input": "hello world&foo=bar"
429 }));
430 let result = skill.execute(call).await.unwrap();
431 assert!(!result.is_error);
432 let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
433 assert_eq!(parsed["result"], "hello%20world%26foo%3Dbar");
434 assert_eq!(parsed["encoding"], "url");
435 }
436
437 #[tokio::test]
438 async fn test_url_decode() {
439 let skill = EncodeDecodeSkill::new();
440 let call = make_call(serde_json::json!({
441 "operation": "url_decode",
442 "input": "hello%20world%26foo%3Dbar"
443 }));
444 let result = skill.execute(call).await.unwrap();
445 assert!(!result.is_error);
446 let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
447 assert_eq!(parsed["result"], "hello world&foo=bar");
448 }
449
450 #[tokio::test]
451 async fn test_url_decode_plus_as_space() {
452 let skill = EncodeDecodeSkill::new();
453 let call = make_call(serde_json::json!({
454 "operation": "url_decode",
455 "input": "hello+world"
456 }));
457 let result = skill.execute(call).await.unwrap();
458 assert!(!result.is_error);
459 let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
460 assert_eq!(parsed["result"], "hello world");
461 }
462
463 #[tokio::test]
464 async fn test_url_decode_incomplete_percent() {
465 let skill = EncodeDecodeSkill::new();
466 let call = make_call(serde_json::json!({
467 "operation": "url_decode",
468 "input": "hello%2"
469 }));
470 let result = skill.execute(call).await.unwrap();
471 assert!(result.is_error);
472 }
473
474 #[tokio::test]
475 async fn test_html_encode() {
476 let skill = EncodeDecodeSkill::new();
477 let call = make_call(serde_json::json!({
478 "operation": "html_encode",
479 "input": "<p class=\"test\">Hello & 'world'</p>"
480 }));
481 let result = skill.execute(call).await.unwrap();
482 assert!(!result.is_error);
483 let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
484 assert_eq!(
485 parsed["result"],
486 "<p class="test">Hello & 'world'</p>"
487 );
488 }
489
490 #[tokio::test]
491 async fn test_html_decode() {
492 let skill = EncodeDecodeSkill::new();
493 let call = make_call(serde_json::json!({
494 "operation": "html_decode",
495 "input": "<p>Hello & 'world'</p>"
496 }));
497 let result = skill.execute(call).await.unwrap();
498 assert!(!result.is_error);
499 let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
500 assert_eq!(parsed["result"], "<p>Hello & 'world'</p>");
501 }
502
503 #[tokio::test]
504 async fn test_jwt_decode() {
505 let skill = EncodeDecodeSkill::new();
506 let header =
508 general_purpose::URL_SAFE_NO_PAD.encode(b"{\"alg\":\"HS256\",\"typ\":\"JWT\"}");
509 let payload = general_purpose::URL_SAFE_NO_PAD
510 .encode(b"{\"sub\":\"1234567890\",\"name\":\"John Doe\",\"iat\":1516239022}");
511 let token = format!("{header}.{payload}.fake_signature");
512
513 let call = make_call(serde_json::json!({
514 "operation": "jwt_decode",
515 "input": token
516 }));
517 let result = skill.execute(call).await.unwrap();
518 assert!(!result.is_error, "Result: {}", result.content);
519 let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
520 assert_eq!(parsed["encoding"], "jwt");
521 assert_eq!(parsed["result"]["sub"], "1234567890");
522 assert_eq!(parsed["result"]["name"], "John Doe");
523 assert_eq!(parsed["result"]["iat"], 1516239022);
524 }
525
526 #[tokio::test]
527 async fn test_jwt_decode_invalid_format() {
528 let skill = EncodeDecodeSkill::new();
529 let call = make_call(serde_json::json!({
530 "operation": "jwt_decode",
531 "input": "not.a-jwt"
532 }));
533 let result = skill.execute(call).await.unwrap();
534 assert!(result.is_error);
535 assert!(result.content.contains("3 dot-separated parts"));
536 }
537
538 #[tokio::test]
539 async fn test_missing_operation() {
540 let skill = EncodeDecodeSkill::new();
541 let call = make_call(serde_json::json!({
542 "input": "hello"
543 }));
544 let result = skill.execute(call).await.unwrap();
545 assert!(result.is_error);
546 assert!(result.content.contains("operation"));
547 }
548
549 #[tokio::test]
550 async fn test_missing_input() {
551 let skill = EncodeDecodeSkill::new();
552 let call = make_call(serde_json::json!({
553 "operation": "base64_encode"
554 }));
555 let result = skill.execute(call).await.unwrap();
556 assert!(result.is_error);
557 assert!(result.content.contains("input"));
558 }
559
560 #[tokio::test]
561 async fn test_unknown_operation() {
562 let skill = EncodeDecodeSkill::new();
563 let call = make_call(serde_json::json!({
564 "operation": "rot13",
565 "input": "hello"
566 }));
567 let result = skill.execute(call).await.unwrap();
568 assert!(result.is_error);
569 assert!(result.content.contains("Unknown operation"));
570 }
571
572 #[tokio::test]
573 async fn test_empty_string_base64_roundtrip() {
574 let skill = EncodeDecodeSkill::new();
575 let enc_call = make_call(serde_json::json!({
576 "operation": "base64_encode",
577 "input": ""
578 }));
579 let enc_result = skill.execute(enc_call).await.unwrap();
580 assert!(!enc_result.is_error);
581 let enc_parsed: serde_json::Value = serde_json::from_str(&enc_result.content).unwrap();
582 let encoded = enc_parsed["result"].as_str().unwrap();
583
584 let dec_call = make_call(serde_json::json!({
585 "operation": "base64_decode",
586 "input": encoded
587 }));
588 let dec_result = skill.execute(dec_call).await.unwrap();
589 assert!(!dec_result.is_error);
590 let dec_parsed: serde_json::Value = serde_json::from_str(&dec_result.content).unwrap();
591 assert_eq!(dec_parsed["result"], "");
592 }
593
594 #[test]
595 fn test_url_encode_unreserved_chars_preserved() {
596 let result = url_encode("abc-123_test.file~v2");
598 assert_eq!(result, "abc-123_test.file~v2");
599 }
600
601 #[test]
602 fn test_html_encode_no_special_chars() {
603 assert_eq!(html_encode("hello world"), "hello world");
604 }
605
606 #[test]
607 fn test_html_decode_no_entities() {
608 assert_eq!(html_decode("hello world"), "hello world");
609 }
610
611 #[test]
612 fn test_descriptor_name() {
613 let skill = EncodeDecodeSkill::new();
614 assert_eq!(skill.descriptor().name, "encode_decode");
615 }
616}