runmat_runtime/builtins/io/http/
weboptions.rs

1//! MATLAB-compatible `weboptions` builtin for constructing HTTP client options.
2
3use std::collections::VecDeque;
4
5use runmat_builtins::{StructValue, Value};
6use runmat_macros::runtime_builtin;
7
8use crate::builtins::common::spec::{
9    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
10    ReductionNaN, ResidencyPolicy, ShapeRequirements,
11};
12use crate::gather_if_needed;
13#[cfg(feature = "doc_export")]
14use crate::register_builtin_doc_text;
15use crate::{register_builtin_fusion_spec, register_builtin_gpu_spec};
16
17const DEFAULT_TIMEOUT_SECONDS: f64 = 60.0;
18
19#[cfg(feature = "doc_export")]
20#[allow(clippy::too_many_lines)]
21pub const DOC_MD: &str = r#"---
22title: "weboptions"
23category: "io/http"
24keywords: ["weboptions", "http options", "timeout", "headers", "rest client"]
25summary: "Create an options struct that configures webread and webwrite HTTP behaviour."
26references:
27  - https://www.mathworks.com/help/matlab/ref/weboptions.html
28gpu_support:
29  elementwise: false
30  reduction: false
31  precisions: []
32  broadcasting: "none"
33  notes: "weboptions operates on CPU data structures and gathers gpuArray inputs automatically."
34fusion:
35  elementwise: false
36  reduction: false
37  max_inputs: 1
38  constants: "inline"
39requires_feature: null
40tested:
41  unit: "builtins::io::http::weboptions::tests"
42  integration:
43    - "builtins::io::http::weboptions::tests::weboptions_default_struct_matches_expected_fields"
44    - "builtins::io::http::weboptions::tests::weboptions_overrides_timeout_and_headers"
45    - "builtins::io::http::weboptions::tests::weboptions_updates_existing_struct"
46    - "builtins::io::http::weboptions::tests::weboptions_rejects_unknown_option"
47    - "builtins::io::http::weboptions::tests::weboptions_requires_username_when_password_provided"
48    - "builtins::io::http::weboptions::tests::weboptions_rejects_timeout_nonpositive"
49    - "builtins::io::http::weboptions::tests::weboptions_rejects_headerfields_bad_cell_shape"
50    - "builtins::io::http::weboptions::tests::webread_uses_weboptions_without_polluting_query"
51    - "builtins::io::http::weboptions::tests::webwrite_uses_weboptions_auto_request_method"
52---
53
54# What does the `weboptions` function do in MATLAB / RunMat?
55`weboptions` builds a MATLAB-style options struct that controls HTTP behaviour for
56functions such as `webread`, `webwrite`, and `websave`. The struct stores option
57fields like `Timeout`, `ContentType`, `HeaderFields`, and `RequestMethod`, all with
58MATLAB-compatible defaults.
59
60## How does the `weboptions` function behave in MATLAB / RunMat?
61- Returns a struct with canonical field names: `ContentType`, `Timeout`, `HeaderFields`,
62  `UserAgent`, `Username`, `Password`, `RequestMethod`, `MediaType`, and `QueryParameters`.
63- Defaults mirror MATLAB: `ContentType="auto"`, `Timeout=60`, `UserAgent=""`
64  (RunMat substitutes a default agent when this is empty), `RequestMethod="auto"`,
65  `MediaType="auto"`, and empty structs for `HeaderFields` and `QueryParameters`.
66- Name-value arguments are case-insensitive. Values are validated to ensure MATLAB-compatible
67  types (text scalars for string options, positive scalars for `Timeout`,
68  structs or two-column cell arrays for `HeaderFields` and `QueryParameters`).
69- Passing an existing options struct as the first argument clones it before applying additional
70  overrides, matching MATLAB's update pattern `opts = weboptions(opts, "Timeout", 5)`.
71- Unknown option names raise descriptive errors.
72
73## `weboptions` Function GPU Execution Behaviour
74`weboptions` operates entirely on CPU metadata. It gathers any `gpuArray` inputs back to host
75memory before validation, because HTTP requests execute on the CPU regardless of the selected
76acceleration provider. No GPU provider hooks are required for this function.
77
78## Examples of using the `weboptions` function in MATLAB / RunMat
79
80### Setting custom timeouts for webread calls
81```matlab
82opts = weboptions("Timeout", 10);
83html = webread("https://example.com", opts);
84```
85The request aborts after 10 seconds instead of the default 60.
86
87### Providing HTTP basic authentication credentials
88```matlab
89opts = weboptions("Username", "ada", "Password", "lovelace");
90profile = webread("https://api.example.com/me", opts);
91```
92Credentials are attached automatically; an empty username leaves authentication disabled.
93
94### Sending JSON payloads with webwrite
95```matlab
96opts = weboptions("ContentType", "json", "MediaType", "application/json");
97payload = struct("title", "RunMat", "stars", 5);
98reply = webwrite("https://api.example.com/projects", payload, opts);
99```
100The request posts JSON and expects a JSON response.
101
102### Applying custom headers with struct syntax
103```matlab
104headers = struct("Accept", "application/json", "X-Client", "RunMat");
105opts = weboptions("HeaderFields", headers);
106data = webread("https://api.example.com/resources", opts);
107```
108`HeaderFields` accepts a struct or two-column cell array of header name/value pairs.
109
110### Combining existing options with overrides
111```matlab
112base = weboptions("ContentType", "json");
113opts = weboptions(base, "Timeout", 15, "QueryParameters", struct("verbose", true));
114result = webread("https://api.example.com/items", opts);
115```
116The new struct inherits all fields from `base` and overrides the ones supplied later.
117
118## FAQ
119
120### Which option names are supported in RunMat?
121`weboptions` implements the options consumed by `webread` and `webwrite`: `ContentType`,
122`Timeout`, `HeaderFields`, `UserAgent`, `Username`, `Password`, `RequestMethod`, `MediaType`,
123and `QueryParameters`. Unknown names raise a MATLAB-style error.
124
125### What does `RequestMethod="auto"` mean?
126`webread` treats `"auto"` as `"get"` while `webwrite` maps it to `"post"`. Override the method when
127you need `put`, `patch`, or `delete`.
128
129### How are empty usernames or passwords handled?
130Empty strings leave authentication disabled. A non-empty password without a username raises a
131MATLAB-compatible error.
132
133### Can I pass query parameters through the options struct?
134Yes. Supply a struct or two-column cell array in the `QueryParameters` option. Values may include
135numbers, logicals, or text scalars, and they are percent-encoded when the request is built.
136
137### Do I need to manage GPU residency for options?
138No. `weboptions` gathers any GPU-resident values automatically and always returns a host struct.
139HTTP builtins ignore GPU residency for metadata.
140
141### Does `weboptions` mutate the input struct?
142No. A copy is made before overrides are applied, preserving the original struct you pass in.
143
144### How can I clear headers or query parameters?
145Pass an empty struct (`struct()`) or empty cell array (`{}`) to reset the respective option.
146
147## See Also
148[webread](./webread), [webwrite](./webwrite), [jsondecode](../json/jsondecode), [jsonencode](../json/jsonencode)
149"#;
150
151pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
152    name: "weboptions",
153    op_kind: GpuOpKind::Custom("http-options"),
154    supported_precisions: &[],
155    broadcast: BroadcastSemantics::None,
156    provider_hooks: &[],
157    constant_strategy: ConstantStrategy::InlineLiteral,
158    residency: ResidencyPolicy::GatherImmediately,
159    nan_mode: ReductionNaN::Include,
160    two_pass_threshold: None,
161    workgroup_size: None,
162    accepts_nan_mode: false,
163    notes: "weboptions validates CPU metadata only; gpuArray inputs are gathered eagerly.",
164};
165
166register_builtin_gpu_spec!(GPU_SPEC);
167
168pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
169    name: "weboptions",
170    shape: ShapeRequirements::Any,
171    constant_strategy: ConstantStrategy::InlineLiteral,
172    elementwise: None,
173    reduction: None,
174    emits_nan: false,
175    notes: "weboptions constructs option structs and terminates fusion graphs.",
176};
177
178register_builtin_fusion_spec!(FUSION_SPEC);
179
180#[cfg(feature = "doc_export")]
181register_builtin_doc_text!("weboptions", DOC_MD);
182
183#[runtime_builtin(
184    name = "weboptions",
185    category = "io/http",
186    summary = "Create an options struct that configures webread and webwrite HTTP behaviour.",
187    keywords = "weboptions,http options,timeout,headers,rest client",
188    accel = "cpu"
189)]
190fn weboptions_builtin(rest: Vec<Value>) -> Result<Value, String> {
191    let mut gathered = Vec::with_capacity(rest.len());
192    for value in rest {
193        gathered.push(gather_if_needed(&value).map_err(|e| format!("weboptions: {e}"))?);
194    }
195    let mut queue: VecDeque<Value> = gathered.into();
196    let mut options = default_options_struct();
197
198    if matches!(queue.front(), Some(Value::Struct(_))) {
199        if let Some(Value::Struct(struct_value)) = queue.pop_front() {
200            apply_struct_fields(struct_value, &mut options)?;
201        }
202    }
203
204    while let Some(name_value) = queue.pop_front() {
205        let name = expect_string_scalar(
206            &name_value,
207            "weboptions: option names must be character vectors or string scalars",
208        )?;
209        let value = queue
210            .pop_front()
211            .ok_or_else(|| "weboptions: missing value for name-value argument".to_string())?;
212        set_option_field(&mut options, &name, &value)?;
213    }
214
215    validate_credentials(&options)?;
216
217    Ok(Value::Struct(options))
218}
219
220fn default_options_struct() -> StructValue {
221    let mut out = StructValue::new();
222    out.fields
223        .insert("ContentType".to_string(), Value::from("auto"));
224    out.fields
225        .insert("Timeout".to_string(), Value::Num(DEFAULT_TIMEOUT_SECONDS));
226    out.fields.insert(
227        "HeaderFields".to_string(),
228        Value::Struct(StructValue::new()),
229    );
230    out.fields.insert("UserAgent".to_string(), Value::from(""));
231    out.fields.insert("Username".to_string(), Value::from(""));
232    out.fields.insert("Password".to_string(), Value::from(""));
233    out.fields
234        .insert("RequestMethod".to_string(), Value::from("auto"));
235    out.fields
236        .insert("MediaType".to_string(), Value::from("auto"));
237    out.fields.insert(
238        "QueryParameters".to_string(),
239        Value::Struct(StructValue::new()),
240    );
241    out
242}
243
244fn apply_struct_fields(source: StructValue, target: &mut StructValue) -> Result<(), String> {
245    for (key, value) in &source.fields {
246        set_option_field(target, key, value)?;
247    }
248    Ok(())
249}
250
251fn set_option_field(options: &mut StructValue, name: &str, value: &Value) -> Result<(), String> {
252    let lower = name.to_ascii_lowercase();
253    match lower.as_str() {
254        "contenttype" => {
255            let canonical = parse_content_type_option(value)?;
256            options
257                .fields
258                .insert("ContentType".to_string(), Value::from(canonical));
259            Ok(())
260        }
261        "timeout" => {
262            let seconds = numeric_scalar(
263                value,
264                "weboptions: Timeout must be a finite, positive scalar",
265            )?;
266            if !seconds.is_finite() || seconds <= 0.0 {
267                return Err("weboptions: Timeout must be a finite, positive scalar".to_string());
268            }
269            options
270                .fields
271                .insert("Timeout".to_string(), Value::Num(seconds));
272            Ok(())
273        }
274        "headerfields" => {
275            let canonical = canonical_header_fields(value)?;
276            options.fields.insert("HeaderFields".to_string(), canonical);
277            Ok(())
278        }
279        "useragent" => {
280            let ua = expect_string_scalar(
281                value,
282                "weboptions: UserAgent must be a character vector or string scalar",
283            )?;
284            options
285                .fields
286                .insert("UserAgent".to_string(), Value::from(ua));
287            Ok(())
288        }
289        "username" => {
290            let username = expect_string_scalar(
291                value,
292                "weboptions: Username must be a character vector or string scalar",
293            )?;
294            options
295                .fields
296                .insert("Username".to_string(), Value::from(username));
297            Ok(())
298        }
299        "password" => {
300            let password = expect_string_scalar(
301                value,
302                "weboptions: Password must be a character vector or string scalar",
303            )?;
304            options
305                .fields
306                .insert("Password".to_string(), Value::from(password));
307            Ok(())
308        }
309        "requestmethod" => {
310            let method = parse_request_method_option(value)?;
311            options
312                .fields
313                .insert("RequestMethod".to_string(), Value::from(method));
314            Ok(())
315        }
316        "mediatype" => {
317            let media = expect_string_scalar(
318                value,
319                "weboptions: MediaType must be a character vector or string scalar",
320            )?;
321            options
322                .fields
323                .insert("MediaType".to_string(), Value::from(media));
324            Ok(())
325        }
326        "queryparameters" => {
327            let qp = canonical_query_parameters(value)?;
328            options.fields.insert("QueryParameters".to_string(), qp);
329            Ok(())
330        }
331        _ => Err(format!("weboptions: unknown option '{}'", name)),
332    }
333}
334
335fn parse_content_type_option(value: &Value) -> Result<String, String> {
336    let text = expect_string_scalar(
337        value,
338        "weboptions: ContentType must be a character vector or string scalar",
339    )?;
340    match text.trim().to_ascii_lowercase().as_str() {
341        "auto" => Ok("auto".to_string()),
342        "json" => Ok("json".to_string()),
343        "text" | "char" | "string" => Ok("text".to_string()),
344        "binary" | "raw" | "octet-stream" => Ok("binary".to_string()),
345        other => Err(format!(
346            "weboptions: unsupported ContentType '{}'; use 'auto', 'json', 'text', or 'binary'",
347            other
348        )),
349    }
350}
351
352fn parse_request_method_option(value: &Value) -> Result<String, String> {
353    let text = expect_string_scalar(
354        value,
355        "weboptions: RequestMethod must be a character vector or string scalar",
356    )?;
357    let lower = text.trim().to_ascii_lowercase();
358    match lower.as_str() {
359        "auto" | "get" | "post" | "put" | "patch" | "delete" => Ok(lower),
360        _ => Err(format!(
361            "weboptions: unsupported RequestMethod '{}'; expected auto, get, post, put, patch, or delete",
362            text
363        )),
364    }
365}
366
367fn canonical_header_fields(value: &Value) -> Result<Value, String> {
368    match value {
369        Value::Struct(struct_value) => {
370            let mut out = StructValue::new();
371            for (key, val) in &struct_value.fields {
372                let header_value = expect_string_scalar(
373                    val,
374                    "weboptions: HeaderFields values must be character vectors or string scalars",
375                )?;
376                if header_value.trim().is_empty() {
377                    return Err("weboptions: header values must not be empty".to_string());
378                }
379                if key.trim().is_empty() {
380                    return Err("weboptions: header names must not be empty".to_string());
381                }
382                out.fields.insert(key.clone(), Value::from(header_value));
383            }
384            Ok(Value::Struct(out))
385        }
386        Value::Cell(cell) => {
387            if cell.cols != 2 {
388                return Err(
389                    "weboptions: HeaderFields cell array must have exactly two columns".to_string(),
390                );
391            }
392            let mut out = StructValue::new();
393            for row in 0..cell.rows {
394                let name_val = cell.get(row, 0).map_err(|e| format!("weboptions: {e}"))?;
395                let value_val = cell.get(row, 1).map_err(|e| format!("weboptions: {e}"))?;
396                let name = expect_string_scalar(
397                    &name_val,
398                    "weboptions: header names must be character vectors or string scalars",
399                )?;
400                if name.trim().is_empty() {
401                    return Err("weboptions: header names must not be empty".to_string());
402                }
403                let header_value = expect_string_scalar(
404                    &value_val,
405                    "weboptions: header values must be character vectors or string scalars",
406                )?;
407                if header_value.trim().is_empty() {
408                    return Err("weboptions: header values must not be empty".to_string());
409                }
410                out.fields.insert(name, Value::from(header_value));
411            }
412            Ok(Value::Struct(out))
413        }
414        _ => Err("weboptions: HeaderFields must be a struct or two-column cell array".to_string()),
415    }
416}
417
418fn canonical_query_parameters(value: &Value) -> Result<Value, String> {
419    match value {
420        Value::Struct(struct_value) => {
421            let mut out = StructValue::new();
422            for (key, val) in &struct_value.fields {
423                out.fields.insert(key.clone(), val.clone());
424            }
425            Ok(Value::Struct(out))
426        }
427        Value::Cell(cell) => {
428            if cell.cols != 2 {
429                return Err(
430                    "weboptions: QueryParameters cell array must have exactly two columns"
431                        .to_string(),
432                );
433            }
434            let mut out = StructValue::new();
435            for row in 0..cell.rows {
436                let name_val = cell.get(row, 0).map_err(|e| format!("weboptions: {e}"))?;
437                let value_val = cell.get(row, 1).map_err(|e| format!("weboptions: {e}"))?;
438                let name = expect_string_scalar(
439                    &name_val,
440                    "weboptions: query parameter names must be character vectors or string scalars",
441                )?;
442                out.fields.insert(name, value_val);
443            }
444            Ok(Value::Struct(out))
445        }
446        _ => {
447            Err("weboptions: QueryParameters must be a struct or two-column cell array".to_string())
448        }
449    }
450}
451
452fn validate_credentials(options: &StructValue) -> Result<(), String> {
453    let username = string_field(options, "Username").unwrap_or_default();
454    let password = string_field(options, "Password").unwrap_or_default();
455    if !password.trim().is_empty() && username.trim().is_empty() {
456        return Err("weboptions: Password requires a Username option".to_string());
457    }
458    Ok(())
459}
460
461fn string_field(options: &StructValue, field: &str) -> Option<String> {
462    options.fields.get(field).and_then(|value| match value {
463        Value::String(text) => Some(text.clone()),
464        Value::CharArray(ca) if ca.rows == 1 => Some(ca.data.iter().collect()),
465        Value::StringArray(sa) if sa.data.len() == 1 => Some(sa.data[0].clone()),
466        _ => None,
467    })
468}
469
470fn numeric_scalar(value: &Value, context: &str) -> Result<f64, String> {
471    match value {
472        Value::Num(n) => Ok(*n),
473        Value::Int(i) => Ok(i.to_f64()),
474        Value::Tensor(tensor) => {
475            if tensor.data.len() == 1 {
476                Ok(tensor.data[0])
477            } else {
478                Err(context.to_string())
479            }
480        }
481        _ => Err(context.to_string()),
482    }
483}
484
485fn expect_string_scalar(value: &Value, context: &str) -> Result<String, String> {
486    match value {
487        Value::String(s) => Ok(s.clone()),
488        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
489        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
490        _ => Err(context.to_string()),
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use std::io::{Read, Write};
498    use std::net::{TcpListener, TcpStream};
499    use std::sync::mpsc;
500    use std::thread;
501
502    use crate::call_builtin;
503    use runmat_builtins::CellArray;
504
505    #[cfg(feature = "doc_export")]
506    use crate::builtins::common::test_support;
507
508    fn spawn_server<F>(handler: F) -> String
509    where
510        F: FnOnce(TcpStream) + Send + 'static,
511    {
512        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
513        let addr = listener.local_addr().unwrap();
514        thread::spawn(move || {
515            if let Ok((stream, _)) = listener.accept() {
516                handler(stream);
517            }
518        });
519        format!("http://{}", addr)
520    }
521
522    fn read_request(stream: &mut TcpStream) -> (String, Vec<u8>) {
523        let mut buffer = Vec::new();
524        let mut tmp = [0u8; 512];
525        loop {
526            match stream.read(&mut tmp) {
527                Ok(0) => break,
528                Ok(n) => {
529                    buffer.extend_from_slice(&tmp[..n]);
530                    if buffer.windows(4).any(|w| w == b"\r\n\r\n") {
531                        break;
532                    }
533                }
534                Err(_) => break,
535            }
536        }
537        let header_end = buffer
538            .windows(4)
539            .position(|w| w == b"\r\n\r\n")
540            .map(|idx| idx + 4)
541            .unwrap_or(buffer.len());
542        let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string();
543        let body = buffer[header_end..].to_vec();
544        (headers, body)
545    }
546
547    fn respond_with(mut stream: TcpStream, content_type: &str, body: &[u8]) {
548        let response = format!(
549            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n",
550            body.len(),
551            content_type
552        );
553        let _ = stream.write_all(response.as_bytes());
554        let _ = stream.write_all(body);
555    }
556
557    #[test]
558    fn weboptions_default_struct_matches_expected_fields() {
559        let result = weboptions_builtin(Vec::new()).expect("weboptions");
560        let Value::Struct(options) = result else {
561            panic!("expected struct result");
562        };
563        assert_eq!(
564            options.fields.get("ContentType").and_then(|v| match v {
565                Value::String(s) => Some(s.as_str()),
566                _ => None,
567            }),
568            Some("auto")
569        );
570        assert_eq!(
571            options.fields.get("Timeout").and_then(|v| match v {
572                Value::Num(n) => Some(*n),
573                _ => None,
574            }),
575            Some(DEFAULT_TIMEOUT_SECONDS)
576        );
577        match options.fields.get("HeaderFields") {
578            Some(Value::Struct(headers)) => assert!(headers.fields.is_empty()),
579            other => panic!("expected empty HeaderFields struct, got {other:?}"),
580        }
581        assert_eq!(
582            options.fields.get("RequestMethod").and_then(|v| match v {
583                Value::String(s) => Some(s.as_str()),
584                _ => None,
585            }),
586            Some("auto")
587        );
588        assert_eq!(
589            options.fields.get("MediaType").and_then(|v| match v {
590                Value::String(s) => Some(s.as_str()),
591                _ => None,
592            }),
593            Some("auto")
594        );
595    }
596
597    #[test]
598    fn weboptions_overrides_timeout_and_headers() {
599        let mut headers = StructValue::new();
600        headers
601            .fields
602            .insert("Accept".to_string(), Value::from("application/json"));
603        headers
604            .fields
605            .insert("X-Client".to_string(), Value::from("RunMat"));
606        let args = vec![
607            Value::from("Timeout"),
608            Value::Num(10.0),
609            Value::from("HeaderFields"),
610            Value::Struct(headers),
611        ];
612        let result = weboptions_builtin(args).expect("weboptions overrides");
613        let Value::Struct(opts) = result else {
614            panic!("expected struct");
615        };
616        assert_eq!(
617            opts.fields.get("Timeout").and_then(|v| match v {
618                Value::Num(n) => Some(*n),
619                _ => None,
620            }),
621            Some(10.0)
622        );
623        match opts.fields.get("HeaderFields") {
624            Some(Value::Struct(headers)) => {
625                assert_eq!(
626                    headers.fields.get("Accept"),
627                    Some(&Value::from("application/json"))
628                );
629                assert_eq!(headers.fields.get("X-Client"), Some(&Value::from("RunMat")));
630            }
631            other => panic!("expected header struct, got {other:?}"),
632        }
633    }
634
635    #[test]
636    fn weboptions_updates_existing_struct() {
637        let base = weboptions_builtin(vec![Value::from("ContentType"), Value::from("json")])
638            .expect("base weboptions");
639        let args = vec![base, Value::from("Timeout"), Value::Num(15.0)];
640        let updated = weboptions_builtin(args).expect("weboptions update");
641        let Value::Struct(opts) = updated else {
642            panic!("expected struct");
643        };
644        assert_eq!(
645            opts.fields.get("ContentType").and_then(|v| match v {
646                Value::String(s) => Some(s.as_str()),
647                _ => None,
648            }),
649            Some("json")
650        );
651        assert_eq!(
652            opts.fields.get("Timeout").and_then(|v| match v {
653                Value::Num(n) => Some(*n),
654                _ => None,
655            }),
656            Some(15.0)
657        );
658    }
659
660    #[test]
661    fn weboptions_rejects_unknown_option() {
662        let err = weboptions_builtin(vec![Value::from("BogusOption"), Value::Num(1.0)])
663            .expect_err("unknown option should fail");
664        assert!(err.contains("unknown option"), "unexpected error: {err}");
665    }
666
667    #[test]
668    fn weboptions_requires_username_when_password_provided() {
669        let err = weboptions_builtin(vec![Value::from("Password"), Value::from("secret")])
670            .expect_err("password without username");
671        assert!(
672            err.contains("Password requires a Username option"),
673            "unexpected error: {err}"
674        );
675    }
676
677    #[test]
678    fn weboptions_rejects_timeout_nonpositive() {
679        let err = weboptions_builtin(vec![Value::from("Timeout"), Value::Num(0.0)])
680            .expect_err("timeout should reject nonpositive values");
681        assert!(
682            err.contains("Timeout must be a finite, positive scalar"),
683            "unexpected error: {err}"
684        );
685    }
686
687    #[test]
688    fn weboptions_rejects_headerfields_bad_cell_shape() {
689        let cell = CellArray::new(vec![Value::from("Accept")], 1, 1).expect("cell");
690        let err = weboptions_builtin(vec![Value::from("HeaderFields"), Value::Cell(cell)])
691            .expect_err("headerfields cell shape");
692        assert!(
693            err.contains("HeaderFields cell array must have exactly two columns"),
694            "unexpected error: {err}"
695        );
696    }
697
698    #[test]
699    fn webread_uses_weboptions_without_polluting_query() {
700        let options = weboptions_builtin(Vec::new()).expect("weboptions");
701        let (tx, rx) = mpsc::channel();
702        let url = spawn_server(move |mut stream| {
703            let (headers, _) = read_request(&mut stream);
704            tx.send(headers).unwrap();
705            respond_with(stream, "application/json", br#"{"ok":true}"#);
706        });
707
708        let args = vec![Value::from(url.clone()), options];
709        let result = call_builtin("webread", &args).expect("webread with options");
710        match result {
711            Value::Struct(reply) => {
712                assert!(matches!(reply.fields.get("ok"), Some(Value::Bool(true))));
713            }
714            other => panic!("expected struct response, got {other:?}"),
715        }
716        let headers = rx.recv().expect("captured headers");
717        assert!(headers.starts_with("GET "));
718        assert!(
719            !headers.contains("MediaType=auto"),
720            "MediaType should not appear in query string"
721        );
722    }
723
724    #[test]
725    fn webwrite_uses_weboptions_auto_request_method() {
726        let options = weboptions_builtin(Vec::new()).expect("weboptions default");
727        let payload = Value::from("Hello from RunMat");
728        let (tx, rx) = mpsc::channel();
729        let url = spawn_server(move |mut stream| {
730            let (headers, body) = read_request(&mut stream);
731            tx.send((headers, body)).unwrap();
732            respond_with(stream, "application/json", br#"{"ack":true}"#);
733        });
734
735        let args = vec![Value::from(url), payload, options];
736        let result = call_builtin("webwrite", &args).expect("webwrite with weboptions");
737        match result {
738            Value::Struct(reply) => {
739                assert!(matches!(reply.fields.get("ack"), Some(Value::Bool(true))));
740            }
741            other => panic!("expected struct response, got {other:?}"),
742        }
743        let (headers, body) = rx.recv().expect("request captured");
744        assert!(
745            headers.starts_with("POST "),
746            "expected POST request, got headers: {headers}"
747        );
748        assert!(
749            !body.is_empty(),
750            "expected request body to be present when posting form data"
751        );
752    }
753
754    #[test]
755    #[cfg(feature = "doc_export")]
756    fn doc_examples_present() {
757        let blocks = test_support::doc_examples(DOC_MD);
758        assert!(!blocks.is_empty());
759    }
760}