Skip to main content

grapheme_stdlib/
registry.rs

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}