1use crate::{
2 core, csv, email, html, http, json as json_mod, research, smtp, sql, surreal, tcp, web, yaml,
3};
4use serde_json::{json, Value as JsonValue};
5
6struct RegisteredModule {
7 module_id: &'static str,
8 handler: fn(&str, &JsonValue) -> Option<JsonValue>,
9}
10
11struct RegisteredOp {
12 op: &'static str,
13 handler: fn(&JsonValue) -> JsonValue,
14}
15
16const REGISTERED_MODULES: &[RegisteredModule] = &[
17 RegisteredModule {
18 module_id: "core",
19 handler: dispatch_core,
20 },
21 RegisteredModule {
22 module_id: "http",
23 handler: dispatch_http,
24 },
25 RegisteredModule {
26 module_id: "web",
27 handler: dispatch_web,
28 },
29 RegisteredModule {
30 module_id: "websearch",
31 handler: dispatch_websearch,
32 },
33 RegisteredModule {
34 module_id: "tcp",
35 handler: dispatch_tcp,
36 },
37 RegisteredModule {
38 module_id: "smtp",
39 handler: dispatch_smtp,
40 },
41 RegisteredModule {
42 module_id: "email",
43 handler: dispatch_email,
44 },
45 RegisteredModule {
46 module_id: "sql",
47 handler: dispatch_sql,
48 },
49 RegisteredModule {
50 module_id: "surreal",
51 handler: dispatch_surreal,
52 },
53 RegisteredModule {
54 module_id: "html",
55 handler: dispatch_html,
56 },
57 RegisteredModule {
58 module_id: "json",
59 handler: dispatch_json,
60 },
61 RegisteredModule {
62 module_id: "csv",
63 handler: dispatch_csv,
64 },
65 RegisteredModule {
66 module_id: "yaml",
67 handler: dispatch_yaml,
68 },
69];
70
71const CORE_OPS: &[RegisteredOp] = &[
72 RegisteredOp {
73 op: "echo",
74 handler: core::echo,
75 },
76 RegisteredOp {
77 op: "tap",
78 handler: core::tap,
79 },
80 RegisteredOp {
81 op: "pack_state_data",
82 handler: core::pack_state_data,
83 },
84 RegisteredOp {
85 op: "get_state",
86 handler: core::get_state,
87 },
88 RegisteredOp {
89 op: "get_data",
90 handler: core::get_data,
91 },
92 RegisteredOp {
93 op: "apply_lane",
94 handler: core::apply_lane,
95 },
96 RegisteredOp {
97 op: "map",
98 handler: core::map,
99 },
100 RegisteredOp {
101 op: "filter",
102 handler: core::filter,
103 },
104 RegisteredOp {
105 op: "find",
106 handler: core::find,
107 },
108 RegisteredOp {
109 op: "reduce",
110 handler: core::reduce,
111 },
112 RegisteredOp {
113 op: "group_by",
114 handler: core::group_by,
115 },
116 RegisteredOp {
117 op: "merge",
118 handler: core::merge,
119 },
120 RegisteredOp {
121 op: "pick",
122 handler: core::pick,
123 },
124 RegisteredOp {
125 op: "validate_schema",
126 handler: core::validate_schema,
127 },
128 RegisteredOp {
129 op: "add",
130 handler: core::add,
131 },
132 RegisteredOp {
133 op: "sub",
134 handler: core::sub,
135 },
136 RegisteredOp {
137 op: "inc",
138 handler: core::inc,
139 },
140 RegisteredOp {
141 op: "dec",
142 handler: core::dec,
143 },
144 RegisteredOp {
145 op: "eq",
146 handler: core::eq,
147 },
148 RegisteredOp {
149 op: "lt",
150 handler: core::lt,
151 },
152 RegisteredOp {
153 op: "gt",
154 handler: core::gt,
155 },
156 RegisteredOp {
157 op: "gte",
158 handler: core::gte,
159 },
160 RegisteredOp {
161 op: "lte",
162 handler: core::lte,
163 },
164 RegisteredOp {
165 op: "inc_field",
166 handler: core::inc_field,
167 },
168 RegisteredOp {
169 op: "dec_field",
170 handler: core::dec_field,
171 },
172 RegisteredOp {
173 op: "set_fields",
174 handler: core::set_fields,
175 },
176 RegisteredOp {
177 op: "split",
178 handler: core::split,
179 },
180 RegisteredOp {
181 op: "join",
182 handler: core::join,
183 },
184 RegisteredOp {
185 op: "replace",
186 handler: core::replace,
187 },
188 RegisteredOp {
189 op: "trim",
190 handler: core::trim,
191 },
192 RegisteredOp {
193 op: "lower",
194 handler: core::lower,
195 },
196 RegisteredOp {
197 op: "upper",
198 handler: core::upper,
199 },
200 RegisteredOp {
201 op: "contains",
202 handler: core::contains,
203 },
204 RegisteredOp {
205 op: "get_path",
206 handler: core::get_path,
207 },
208 RegisteredOp {
209 op: "set_path",
210 handler: core::set_path,
211 },
212 RegisteredOp {
213 op: "has_path",
214 handler: core::has_path,
215 },
216];
217
218const JSON_OPS: &[RegisteredOp] = &[RegisteredOp {
219 op: "parse",
220 handler: json_mod::parse,
221}];
222
223const CSV_OPS: &[RegisteredOp] = &[RegisteredOp {
224 op: "to_list",
225 handler: csv::to_list,
226}];
227
228const YAML_OPS: &[RegisteredOp] = &[RegisteredOp {
229 op: "to_json",
230 handler: yaml::to_json,
231}];
232
233const HTML_OPS: &[RegisteredOp] = &[
234 RegisteredOp {
235 op: "to_md",
236 handler: html::to_md,
237 },
238 RegisteredOp {
239 op: "clean_text",
240 handler: html::clean_text,
241 },
242];
243
244const EXPLICIT_UNSUPPORTED_SIGNATURE_OPS: &[(&str, &str)] = &[];
245
246pub fn dispatch(module: &str, op: &str, args: &JsonValue) -> Option<JsonValue> {
247 REGISTERED_MODULES
248 .iter()
249 .find(|m| m.module_id == module)
250 .and_then(|m| (m.handler)(op, args))
251 .or_else(|| dispatch_capability(module, op, args))
252}
253
254pub fn is_registered_op(module: &str, op: &str) -> bool {
255 match module {
256 "core" => CORE_OPS.iter().any(|entry| entry.op == op),
257 "http" => matches!(op, "get" | "post"),
258 "web" => matches!(
259 op,
260 "duckduckgo" | "google" | "xaviv" | "tavily" | "brave" | "providers" | "capabilities"
261 ),
262 "websearch" => matches!(op, "search" | "research_materials" | "research_report"),
263 "tcp" => matches!(op, "connect" | "send" | "receive"),
264 "smtp" => matches!(op, "send_mail"),
265 "email" => matches!(op, "smtp" | "gmail" | "providers" | "capabilities"),
266 "sql" => matches!(op, "query" | "execute" | "transaction" | "health"),
267 "surreal" => matches!(
268 op,
269 "query" | "select" | "create" | "update" | "delete" | "health"
270 ),
271 "html" => HTML_OPS.iter().any(|entry| entry.op == op),
272 "json" => JSON_OPS.iter().any(|entry| entry.op == op),
273 "csv" => CSV_OPS.iter().any(|entry| entry.op == op),
274 "yaml" => YAML_OPS.iter().any(|entry| entry.op == op),
275 #[cfg(feature = "data")]
276 "data" => matches!(
277 op,
278 "read_csv" | "filter" | "group_by" | "aggregate" | "to_json" | "schema"
279 ),
280 #[cfg(feature = "pdf")]
281 "pdf" => matches!(op, "generate" | "extract_text"),
282 #[cfg(feature = "image")]
283 "image" => matches!(op, "resize" | "convert" | "metadata"),
284 #[cfg(feature = "plot")]
285 "plot" => matches!(op, "line" | "bar" | "scatter"),
286 #[cfg(feature = "media")]
287 "media" => matches!(op, "probe" | "transcode"),
288 _ => false,
289 }
290}
291
292pub fn registered_ops_for_module(module: &str) -> Vec<&'static str> {
293 match module {
294 "core" => CORE_OPS.iter().map(|entry| entry.op).collect(),
295 "http" => vec!["get", "post"],
296 "web" => vec![
297 "duckduckgo",
298 "google",
299 "xaviv",
300 "tavily",
301 "brave",
302 "providers",
303 "capabilities",
304 ],
305 "websearch" => vec!["search", "research_materials", "research_report"],
306 "tcp" => vec!["connect", "send", "receive"],
307 "smtp" => vec!["send_mail"],
308 "email" => vec!["smtp", "gmail", "providers", "capabilities"],
309 "sql" => vec!["query", "execute", "transaction", "health"],
310 "surreal" => vec!["query", "select", "create", "update", "delete", "health"],
311 "html" => HTML_OPS.iter().map(|entry| entry.op).collect(),
312 "json" => JSON_OPS.iter().map(|entry| entry.op).collect(),
313 "csv" => CSV_OPS.iter().map(|entry| entry.op).collect(),
314 "yaml" => YAML_OPS.iter().map(|entry| entry.op).collect(),
315 #[cfg(feature = "data")]
316 "data" => vec![
317 "read_csv", "filter", "group_by", "aggregate", "to_json", "schema",
318 ],
319 #[cfg(feature = "pdf")]
320 "pdf" => vec!["generate", "extract_text"],
321 #[cfg(feature = "image")]
322 "image" => vec!["resize", "convert", "metadata"],
323 #[cfg(feature = "plot")]
324 "plot" => vec!["line", "bar", "scatter"],
325 #[cfg(feature = "media")]
326 "media" => vec!["probe", "transcode"],
327 _ => Vec::new(),
328 }
329}
330
331pub fn is_explicitly_unsupported_signature_op(module: &str, op: &str) -> bool {
332 EXPLICIT_UNSUPPORTED_SIGNATURE_OPS
333 .iter()
334 .any(|(m, o)| *m == module && *o == op)
335}
336
337fn dispatch_core(op: &str, args: &JsonValue) -> Option<JsonValue> {
338 dispatch_table(op, args, CORE_OPS)
339}
340
341fn dispatch_http(op: &str, args: &JsonValue) -> Option<JsonValue> {
342 match op {
343 "get" => {
344 let req = HttpGetRequest::from_args(args);
345 Some(http::request("GET", &req.url, None))
346 }
347 "post" => {
348 let req = HttpPostRequest::from_args(args);
349 Some(http::request("POST", &req.url, req.body.as_ref()))
350 }
351 _ => None,
352 }
353}
354
355fn dispatch_web(op: &str, args: &JsonValue) -> Option<JsonValue> {
356 match op {
357 "duckduckgo" => Some(web::search_provider(args, "duckduckgo")),
358 "google" => Some(web::search_provider(args, "google")),
359 "xaviv" => Some(web::search_provider(args, "xaviv")),
360 "tavily" => Some(web::search_provider(args, "tavily")),
361 "brave" => Some(web::search_provider(args, "brave")),
362 "providers" => Some(web::providers()),
363 "capabilities" => Some(web::capabilities(args)),
364 _ => None,
365 }
366}
367
368fn dispatch_websearch(op: &str, args: &JsonValue) -> Option<JsonValue> {
369 match op {
370 "search" => {
371 let req = WebsearchSearchRequest::from_args(args);
372 Some(web::search(&req.to_args_json()))
373 }
374 "research_materials" => {
375 let req = ResearchMaterialsRequest::from_args(args);
376 Some(research::materials(&req.to_args_json()))
377 }
378 "research_report" => {
379 let req = ResearchReportRequest::from_args(args);
380 Some(research::report(&req.to_args_json()))
381 }
382 _ => None,
383 }
384}
385
386fn dispatch_tcp(op: &str, args: &JsonValue) -> Option<JsonValue> {
387 match op {
388 "connect" => {
389 let req = TcpConnectRequest::from_args(args);
390 Some(tcp::connect(&req.target))
391 }
392 "send" => {
393 let req = TcpSendRequest::from_args(args);
394 Some(tcp::send(&req.target, &req.data))
395 }
396 "receive" => {
397 let req = TcpReceiveRequest::from_args(args);
398 Some(tcp::receive(&req.target, req.max_bytes))
399 }
400 _ => None,
401 }
402}
403
404fn dispatch_smtp(op: &str, args: &JsonValue) -> Option<JsonValue> {
405 match op {
406 "send_mail" => Some(smtp::send_mail(args)),
407 _ => None,
408 }
409}
410
411fn dispatch_email(op: &str, args: &JsonValue) -> Option<JsonValue> {
412 match op {
413 "smtp" => Some(email::send_provider(args, "smtp")),
414 "gmail" => Some(email::send_provider(args, "gmail")),
415 "providers" => Some(email::providers()),
416 "capabilities" => Some(email::capabilities(args)),
417 _ => None,
418 }
419}
420
421fn dispatch_sql(op: &str, args: &JsonValue) -> Option<JsonValue> {
422 match op {
423 "query" => Some(sql::query(args)),
424 "execute" => Some(sql::execute(args)),
425 "health" => Some(sql::health(args)),
426 "transaction" => Some(sql::transaction(args)),
427 _ => None,
428 }
429}
430
431fn dispatch_surreal(op: &str, args: &JsonValue) -> Option<JsonValue> {
432 match op {
433 "query" => Some(surreal::query(args)),
434 "select" => Some(surreal::select(args)),
435 "create" => Some(surreal::create(args)),
436 "update" => Some(surreal::update(args)),
437 "delete" => Some(surreal::delete(args)),
438 "health" => Some(surreal::health(args)),
439 _ => None,
440 }
441}
442
443fn dispatch_html(op: &str, args: &JsonValue) -> Option<JsonValue> {
444 dispatch_table(op, args, HTML_OPS)
445}
446
447fn dispatch_json(op: &str, args: &JsonValue) -> Option<JsonValue> {
448 dispatch_table(op, args, JSON_OPS)
449}
450
451fn dispatch_csv(op: &str, args: &JsonValue) -> Option<JsonValue> {
452 dispatch_table(op, args, CSV_OPS)
453}
454
455fn dispatch_yaml(op: &str, args: &JsonValue) -> Option<JsonValue> {
456 dispatch_table(op, args, YAML_OPS)
457}
458
459fn dispatch_capability(module: &str, op: &str, args: &JsonValue) -> Option<JsonValue> {
460 match module {
461 #[cfg(feature = "data")]
462 "data" => dispatch_data(op, args),
463 #[cfg(feature = "pdf")]
464 "pdf" => dispatch_pdf(op, args),
465 #[cfg(feature = "image")]
466 "image" => dispatch_image(op, args),
467 #[cfg(feature = "plot")]
468 "plot" => dispatch_plot(op, args),
469 #[cfg(feature = "media")]
470 "media" => dispatch_media(op, args),
471 _ => None,
472 }
473}
474
475#[cfg(feature = "data")]
476fn dispatch_data(op: &str, args: &JsonValue) -> Option<JsonValue> {
477 use crate::data;
478 match op {
479 "read_csv" => Some(data::read_csv(args)),
480 "filter" => Some(data::filter(args)),
481 "group_by" => Some(data::group_by(args)),
482 "aggregate" => Some(data::aggregate(args)),
483 "to_json" => Some(data::to_json(args)),
484 "schema" => Some(data::schema(args)),
485 _ => None,
486 }
487}
488
489#[cfg(feature = "pdf")]
490fn dispatch_pdf(op: &str, args: &JsonValue) -> Option<JsonValue> {
491 use crate::pdf;
492 match op {
493 "generate" => Some(pdf::generate(args)),
494 "extract_text" => Some(pdf::extract_text(args)),
495 _ => None,
496 }
497}
498
499#[cfg(feature = "image")]
500fn dispatch_image(op: &str, args: &JsonValue) -> Option<JsonValue> {
501 use crate::image;
502 match op {
503 "resize" => Some(image::resize(args)),
504 "convert" => Some(image::convert(args)),
505 "metadata" => Some(image::metadata(args)),
506 _ => None,
507 }
508}
509
510#[cfg(feature = "plot")]
511fn dispatch_plot(op: &str, args: &JsonValue) -> Option<JsonValue> {
512 use crate::plot;
513 match op {
514 "line" => Some(plot::line(args)),
515 "bar" => Some(plot::bar(args)),
516 "scatter" => Some(plot::scatter(args)),
517 _ => None,
518 }
519}
520
521#[cfg(feature = "media")]
522fn dispatch_media(op: &str, args: &JsonValue) -> Option<JsonValue> {
523 use crate::media;
524 match op {
525 "probe" => Some(media::probe(args)),
526 "transcode" => Some(media::transcode(args)),
527 _ => None,
528 }
529}
530
531fn dispatch_table(op: &str, args: &JsonValue, ops: &[RegisteredOp]) -> Option<JsonValue> {
532 ops.iter()
533 .find(|entry| entry.op == op)
534 .map(|entry| (entry.handler)(args))
535}
536
537#[derive(Debug, Clone)]
538struct HttpGetRequest {
539 url: String,
540}
541
542impl HttpGetRequest {
543 fn from_args(args: &JsonValue) -> Self {
544 Self {
545 url: arg_str(args, "url").unwrap_or_default(),
546 }
547 }
548}
549
550#[derive(Debug, Clone)]
551struct HttpPostRequest {
552 url: String,
553 body: Option<JsonValue>,
554}
555
556impl HttpPostRequest {
557 fn from_args(args: &JsonValue) -> Self {
558 Self {
559 url: arg_str(args, "url").unwrap_or_default(),
560 body: args.get("body").cloned().or_else(|| {
561 args.get("__input")
562 .and_then(|v| v.as_object())
563 .and_then(|obj| obj.get("body").cloned())
564 }),
565 }
566 }
567}
568
569#[derive(Debug, Clone)]
570struct TcpConnectRequest {
571 target: String,
572}
573
574impl TcpConnectRequest {
575 fn from_args(args: &JsonValue) -> Self {
576 Self {
577 target: arg_str_alt(args, &["target", "session"]).unwrap_or_default(),
578 }
579 }
580}
581
582#[derive(Debug, Clone)]
583struct TcpSendRequest {
584 target: String,
585 data: String,
586}
587
588impl TcpSendRequest {
589 fn from_args(args: &JsonValue) -> Self {
590 let data = arg_str(args, "data")
591 .or_else(|| arg_str(args, "text"))
592 .unwrap_or_default();
593
594 Self {
595 target: arg_str_alt(args, &["target", "session"]).unwrap_or_default(),
596 data,
597 }
598 }
599}
600
601#[derive(Debug, Clone)]
602struct TcpReceiveRequest {
603 target: String,
604 max_bytes: usize,
605}
606
607impl TcpReceiveRequest {
608 fn from_args(args: &JsonValue) -> Self {
609 let max_bytes = args
610 .get("max_bytes")
611 .and_then(|v| v.as_u64())
612 .or_else(|| {
613 args.get("__input")
614 .and_then(|v| v.as_object())
615 .and_then(|obj| obj.get("max_bytes"))
616 .and_then(|v| v.as_u64())
617 })
618 .map(|v| v as usize)
619 .unwrap_or(1024);
620
621 Self {
622 target: arg_str_alt(args, &["target", "session"]).unwrap_or_default(),
623 max_bytes,
624 }
625 }
626}
627
628fn arg_str(args: &JsonValue, key: &str) -> Option<String> {
629 args.get(key)
630 .and_then(|v| v.as_str())
631 .map(ToOwned::to_owned)
632 .or_else(|| {
633 args.get("__input")
634 .and_then(|v| v.as_object())
635 .and_then(|obj| obj.get(key))
636 .and_then(|v| v.as_str())
637 .map(ToOwned::to_owned)
638 })
639}
640
641fn arg_str_alt(args: &JsonValue, keys: &[&str]) -> Option<String> {
642 for key in keys {
643 if let Some(value) = arg_str(args, key) {
644 return Some(value);
645 }
646 }
647 None
648}
649
650fn arg_u64(args: &JsonValue, key: &str) -> Option<u64> {
651 args.get(key)
652 .and_then(|v| {
653 v.as_u64()
654 .or_else(|| v.as_str().and_then(|s| s.parse::<u64>().ok()))
655 })
656 .or_else(|| {
657 args.get("__input")
658 .and_then(|v| v.as_object())
659 .and_then(|obj| obj.get(key))
660 .and_then(|v| {
661 v.as_u64()
662 .or_else(|| v.as_str().and_then(|s| s.parse::<u64>().ok()))
663 })
664 })
665}
666
667fn arg_bool(args: &JsonValue, key: &str) -> Option<bool> {
668 args.get(key).and_then(parse_bool_value).or_else(|| {
669 args.get("__input")
670 .and_then(|v| v.as_object())
671 .and_then(|obj| obj.get(key))
672 .and_then(parse_bool_value)
673 })
674}
675
676fn parse_bool_value(v: &JsonValue) -> Option<bool> {
677 v.as_bool().or_else(|| {
678 v.as_str()
679 .and_then(|s| match s.trim().to_ascii_lowercase().as_str() {
680 "true" | "1" | "yes" | "on" => Some(true),
681 "false" | "0" | "no" | "off" => Some(false),
682 _ => None,
683 })
684 })
685}
686
687#[derive(Debug, Clone)]
688struct WebsearchSearchRequest {
689 query: Option<String>,
690 provider: Option<String>,
691 max_results: Option<u64>,
692}
693
694impl WebsearchSearchRequest {
695 fn from_args(args: &JsonValue) -> Self {
696 Self {
697 query: arg_str(args, "query").or_else(|| arg_str(args, "text")),
698 provider: arg_str(args, "provider"),
699 max_results: arg_u64(args, "max_results"),
700 }
701 }
702
703 fn to_args_json(&self) -> JsonValue {
704 let mut out = json!({});
705 if let Some(query) = &self.query {
706 out["query"] = JsonValue::String(query.clone());
707 }
708 if let Some(provider) = &self.provider {
709 out["provider"] = JsonValue::String(provider.clone());
710 }
711 if let Some(max_results) = self.max_results {
712 out["max_results"] = JsonValue::Number(max_results.into());
713 }
714 out
715 }
716}
717
718#[derive(Debug, Clone)]
719struct ResearchMaterialsRequest {
720 query: Option<String>,
721 provider: Option<String>,
722 max_results: Option<u64>,
723 per_source_chars: Option<u64>,
724 include_http_body: Option<bool>,
725 md_options: Option<JsonValue>,
726}
727
728impl ResearchMaterialsRequest {
729 fn from_args(args: &JsonValue) -> Self {
730 Self {
731 query: arg_str(args, "query").or_else(|| arg_str(args, "text")),
732 provider: arg_str(args, "provider"),
733 max_results: arg_u64(args, "max_results"),
734 per_source_chars: arg_u64(args, "per_source_chars"),
735 include_http_body: arg_bool(args, "include_http_body"),
736 md_options: args.get("md_options").cloned().or_else(|| {
737 args.get("__input")
738 .and_then(|v| v.as_object())
739 .and_then(|obj| obj.get("md_options").cloned())
740 }),
741 }
742 }
743
744 fn to_args_json(&self) -> JsonValue {
745 let mut out = json!({});
746 if let Some(query) = &self.query {
747 out["query"] = JsonValue::String(query.clone());
748 }
749 if let Some(provider) = &self.provider {
750 out["provider"] = JsonValue::String(provider.clone());
751 }
752 if let Some(max_results) = self.max_results {
753 out["max_results"] = JsonValue::Number(max_results.into());
754 }
755 if let Some(per_source_chars) = self.per_source_chars {
756 out["per_source_chars"] = JsonValue::Number(per_source_chars.into());
757 }
758 if let Some(include_http_body) = self.include_http_body {
759 out["include_http_body"] = JsonValue::Bool(include_http_body);
760 }
761 if let Some(md_options) = &self.md_options {
762 out["md_options"] = md_options.clone();
763 }
764 out
765 }
766}
767
768#[derive(Debug, Clone)]
769struct ResearchReportRequest {
770 query: Option<String>,
771 provider: Option<String>,
772 max_results: Option<u64>,
773 per_source_chars: Option<u64>,
774 report_chars: Option<u64>,
775 include_http_body: Option<bool>,
776 md_options: Option<JsonValue>,
777}
778
779impl ResearchReportRequest {
780 fn from_args(args: &JsonValue) -> Self {
781 Self {
782 query: arg_str(args, "query").or_else(|| arg_str(args, "text")),
783 provider: arg_str(args, "provider"),
784 max_results: arg_u64(args, "max_results"),
785 per_source_chars: arg_u64(args, "per_source_chars"),
786 report_chars: arg_u64(args, "report_chars"),
787 include_http_body: arg_bool(args, "include_http_body"),
788 md_options: args.get("md_options").cloned().or_else(|| {
789 args.get("__input")
790 .and_then(|v| v.as_object())
791 .and_then(|obj| obj.get("md_options").cloned())
792 }),
793 }
794 }
795
796 fn to_args_json(&self) -> JsonValue {
797 let mut out = json!({});
798 if let Some(query) = &self.query {
799 out["query"] = JsonValue::String(query.clone());
800 }
801 if let Some(provider) = &self.provider {
802 out["provider"] = JsonValue::String(provider.clone());
803 }
804 if let Some(max_results) = self.max_results {
805 out["max_results"] = JsonValue::Number(max_results.into());
806 }
807 if let Some(per_source_chars) = self.per_source_chars {
808 out["per_source_chars"] = JsonValue::Number(per_source_chars.into());
809 }
810 if let Some(report_chars) = self.report_chars {
811 out["report_chars"] = JsonValue::Number(report_chars.into());
812 }
813 if let Some(include_http_body) = self.include_http_body {
814 out["include_http_body"] = JsonValue::Bool(include_http_body);
815 }
816 if let Some(md_options) = &self.md_options {
817 out["md_options"] = md_options.clone();
818 }
819 out
820 }
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826 use grapheme_signatures::op_specs;
827 use serde_json::json;
828
829 const SIGNATURE_SCOPE_MODULES: &[&str] = &[
830 "core",
831 "http",
832 "web",
833 "websearch",
834 "tcp",
835 "smtp",
836 "email",
837 "sql",
838 "surreal",
839 "html",
840 "json",
841 "csv",
842 "yaml",
843 ];
844
845 #[test]
846 fn tcp_send_request_reads_target_and_data_from_pipeline_input_object() {
847 let args = json!({
848 "__input": {
849 "target": "127.0.0.1:9000",
850 "text": "hello"
851 }
852 });
853
854 let req = TcpSendRequest::from_args(&args);
855 assert_eq!(req.target, "127.0.0.1:9000");
856 assert_eq!(req.data, "hello");
857 }
858
859 #[test]
860 fn tcp_receive_request_uses_session_fallback_and_default_max_bytes() {
861 let args = json!({
862 "session": "127.0.0.1:9100"
863 });
864
865 let req = TcpReceiveRequest::from_args(&args);
866 assert_eq!(req.target, "127.0.0.1:9100");
867 assert_eq!(req.max_bytes, 1024);
868 }
869
870 #[test]
871 fn http_post_request_reads_body_from_pipeline_input() {
872 let args = json!({
873 "url": "https://example.com",
874 "__input": {
875 "body": {"message": "ok"}
876 }
877 });
878
879 let req = HttpPostRequest::from_args(&args);
880 assert_eq!(req.url, "https://example.com");
881 assert!(req.body.is_some());
882 }
883
884 #[test]
885 fn websearch_search_request_accepts_query_from_pipeline_text() {
886 let args = json!({
887 "__input": {
888 "text": "rust async runtime"
889 }
890 });
891
892 let req = WebsearchSearchRequest::from_args(&args);
893 assert_eq!(req.query.as_deref(), Some("rust async runtime"));
894 }
895
896 #[test]
897 fn research_materials_request_reads_bool_and_numbers_from_pipeline_input() {
898 let args = json!({
899 "__input": {
900 "query": "typed pipelines",
901 "max_results": "7",
902 "per_source_chars": 1200,
903 "include_http_body": "true"
904 }
905 });
906
907 let req = ResearchMaterialsRequest::from_args(&args);
908 assert_eq!(req.query.as_deref(), Some("typed pipelines"));
909 assert_eq!(req.max_results, Some(7));
910 assert_eq!(req.per_source_chars, Some(1200));
911 assert_eq!(req.include_http_body, Some(true));
912 }
913
914 #[test]
915 fn research_report_request_includes_report_chars_in_normalized_args() {
916 let args = json!({
917 "query": "normalization",
918 "report_chars": "3000"
919 });
920
921 let req = ResearchReportRequest::from_args(&args);
922 let normalized = req.to_args_json();
923 assert_eq!(
924 normalized.get("report_chars").and_then(|v| v.as_u64()),
925 Some(3000)
926 );
927 }
928
929 #[test]
930 fn signature_scope_ops_are_registered_or_explicitly_unsupported() {
931 let mut missing = Vec::new();
932
933 for spec in op_specs()
934 .iter()
935 .filter(|spec| SIGNATURE_SCOPE_MODULES.contains(&spec.module))
936 {
937 if !is_registered_op(spec.module, spec.op)
938 && !is_explicitly_unsupported_signature_op(spec.module, spec.op)
939 {
940 missing.push(format!("{}.{}", spec.module, spec.op));
941 }
942 }
943
944 assert!(
945 missing.is_empty(),
946 "signature ops missing registry coverage: {}",
947 missing.join(", ")
948 );
949 }
950
951 #[test]
952 fn sql_query_executes_basic_select() {
953 let out = dispatch(
954 "sql",
955 "query",
956 &json!({
957 "connection": "sqlite::memory:",
958 "sql": "select 1"
959 }),
960 )
961 .expect("sql.query should be registered");
962
963 assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(true));
964 }
965
966 #[test]
967 fn sql_transaction_executes_registered_path() {
968 let out = dispatch(
969 "sql",
970 "transaction",
971 &json!({
972 "connection": "sqlite::memory:",
973 "steps": [
974 {
975 "sql": "select 1",
976 "mode": "query"
977 }
978 ]
979 }),
980 )
981 .expect("sql.transaction should be registered");
982
983 assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(true));
984 }
985
986 #[test]
987 fn surreal_select_executes_registered_path() {
988 let out = dispatch(
989 "surreal",
990 "select",
991 &json!({
992 "connection": "missing_surreal_conn",
993 "thing_or_table": "doc"
994 }),
995 )
996 .expect("surreal.select should be registered");
997
998 assert_eq!(
999 out.get("error")
1000 .and_then(|v| v.get("code"))
1001 .and_then(|v| v.as_str()),
1002 Some("surreal_connection_unresolved")
1003 );
1004 }
1005
1006 #[test]
1007 #[cfg(feature = "full")]
1008 fn capability_signature_scope_ops_are_registered() {
1009 let modules = ["data", "pdf", "image", "plot", "media"];
1010 let mut missing = Vec::new();
1011
1012 for spec in op_specs().iter().filter(|spec| modules.contains(&spec.module)) {
1013 if !is_registered_op(spec.module, spec.op) {
1014 missing.push(format!("{}.{}", spec.module, spec.op));
1015 }
1016 }
1017
1018 assert!(
1019 missing.is_empty(),
1020 "capability ops missing registry coverage: {}",
1021 missing.join(", ")
1022 );
1023 }
1024
1025 #[test]
1026 #[cfg(feature = "full")]
1027 fn data_read_csv_returns_frame_envelope() {
1028 let path = concat!(
1029 env!("CARGO_MANIFEST_DIR"),
1030 "/../../examples/fixtures/sample-users.csv"
1031 );
1032 let out = dispatch("data", "read_csv", &json!({ "path": path }))
1033 .expect("data.read_csv should be registered");
1034
1035 assert!(out.get("data").and_then(|v| v.get("frame")).is_some());
1036 assert_eq!(
1037 out.get("data")
1038 .and_then(|v| v.get("frame"))
1039 .and_then(|v| v.get("row_count"))
1040 .and_then(|v| v.as_u64()),
1041 Some(3)
1042 );
1043 assert!(out.get("meta").is_some());
1044 assert!(out.get("error").and_then(|v| v.as_null()).is_some());
1045 }
1046}