Skip to main content

runmat_runtime/builtins/io/mat/
load.rs

1//! MATLAB-compatible `load` builtin for RunMat.
2
3use std::collections::HashMap;
4use std::io::{BufReader, Cursor, Read};
5use std::path::{Path, PathBuf};
6
7use flate2::read::ZlibDecoder;
8use regex::Regex;
9use runmat_builtins::{
10    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
11    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
12    CharArray, ComplexTensor, IntValue, IntegerStorage, LogicalArray, NumericDType, SparseTensor,
13    StringArray, StructValue, Tensor, Value,
14};
15use runmat_filesystem::File;
16use runmat_macros::runtime_builtin;
17
18use super::format::{
19    MatArray, MatClass, MatData, FLAG_COMPLEX, FLAG_LOGICAL, MAT_HEADER_LEN, MI_COMPRESSED,
20    MI_DOUBLE, MI_INT16, MI_INT32, MI_INT64, MI_INT8, MI_MATRIX, MI_SINGLE, MI_UINT16, MI_UINT32,
21    MI_UINT64, MI_UINT8, MI_UTF16, MI_UTF32, MI_UTF8,
22};
23use crate::builtins::common::spec::{
24    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
25    ReductionNaN, ResidencyPolicy, ShapeRequirements,
26};
27use crate::builtins::introspection::object_serialization::restore_value_from_mat_load;
28use crate::{build_runtime_error, gather_if_needed_async, make_cell, BuiltinResult, RuntimeError};
29
30const LOAD_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
31    name: "S",
32    ty: BuiltinParamType::Any,
33    arity: BuiltinParamArity::Required,
34    default: None,
35    description: "Struct containing the loaded variables.",
36}];
37const LOAD_INPUTS_NONE: [BuiltinParamDescriptor; 0] = [];
38const LOAD_INPUTS_FILENAME: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
39    name: "filename",
40    ty: BuiltinParamType::StringScalar,
41    arity: BuiltinParamArity::Required,
42    default: Some("\"matlab.mat\""),
43    description: "MAT-file path.",
44}];
45const LOAD_INPUTS_FILENAME_VARS: [BuiltinParamDescriptor; 2] = [
46    BuiltinParamDescriptor {
47        name: "filename",
48        ty: BuiltinParamType::StringScalar,
49        arity: BuiltinParamArity::Required,
50        default: Some("\"matlab.mat\""),
51        description: "MAT-file path.",
52    },
53    BuiltinParamDescriptor {
54        name: "varName",
55        ty: BuiltinParamType::StringScalar,
56        arity: BuiltinParamArity::Variadic,
57        default: None,
58        description: "Variable names to load.",
59    },
60];
61const LOAD_INPUTS_FILENAME_REGEXP: [BuiltinParamDescriptor; 3] = [
62    BuiltinParamDescriptor {
63        name: "filename",
64        ty: BuiltinParamType::StringScalar,
65        arity: BuiltinParamArity::Required,
66        default: Some("\"matlab.mat\""),
67        description: "MAT-file path.",
68    },
69    BuiltinParamDescriptor {
70        name: "option",
71        ty: BuiltinParamType::StringScalar,
72        arity: BuiltinParamArity::Required,
73        default: Some("\"-regexp\""),
74        description: "Regular-expression selection option.",
75    },
76    BuiltinParamDescriptor {
77        name: "pattern",
78        ty: BuiltinParamType::StringScalar,
79        arity: BuiltinParamArity::Variadic,
80        default: None,
81        description: "Regex patterns matched against variable names.",
82    },
83];
84const LOAD_INPUTS_OPTIONS: [BuiltinParamDescriptor; 2] = [
85    BuiltinParamDescriptor {
86        name: "option",
87        ty: BuiltinParamType::StringScalar,
88        arity: BuiltinParamArity::Variadic,
89        default: None,
90        description: "Compatibility options such as '-mat' and '-regexp'.",
91    },
92    BuiltinParamDescriptor {
93        name: "value",
94        ty: BuiltinParamType::Any,
95        arity: BuiltinParamArity::Variadic,
96        default: None,
97        description: "Option arguments and variable selectors.",
98    },
99];
100const LOAD_SIGNATURES: [BuiltinSignatureDescriptor; 5] = [
101    BuiltinSignatureDescriptor {
102        label: "S = load()",
103        inputs: &LOAD_INPUTS_NONE,
104        outputs: &LOAD_OUTPUT,
105    },
106    BuiltinSignatureDescriptor {
107        label: "S = load(filename)",
108        inputs: &LOAD_INPUTS_FILENAME,
109        outputs: &LOAD_OUTPUT,
110    },
111    BuiltinSignatureDescriptor {
112        label: "S = load(filename, varName1, varName2, ...)",
113        inputs: &LOAD_INPUTS_FILENAME_VARS,
114        outputs: &LOAD_OUTPUT,
115    },
116    BuiltinSignatureDescriptor {
117        label: "S = load(filename, \"-regexp\", pattern1, ...)",
118        inputs: &LOAD_INPUTS_FILENAME_REGEXP,
119        outputs: &LOAD_OUTPUT,
120    },
121    BuiltinSignatureDescriptor {
122        label: "S = load(option, value, ...)",
123        inputs: &LOAD_INPUTS_OPTIONS,
124        outputs: &LOAD_OUTPUT,
125    },
126];
127const LOAD_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
128    code: "RM.LOAD.INVALID_ARGUMENT",
129    identifier: Some("RunMat:load:InvalidArgument"),
130    when: "Arguments do not match a supported load invocation form.",
131    message: "load: invalid argument",
132};
133const LOAD_ERROR_INVALID_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
134    code: "RM.LOAD.INVALID_OPTION",
135    identifier: Some("RunMat:load:InvalidOption"),
136    when: "An option token or option argument is invalid.",
137    message: "load: invalid option",
138};
139const LOAD_ERROR_FILENAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
140    code: "RM.LOAD.FILENAME",
141    identifier: Some("RunMat:load:Filename"),
142    when: "Filename is invalid or cannot be normalized.",
143    message: "load: invalid filename",
144};
145const LOAD_ERROR_SELECTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
146    code: "RM.LOAD.SELECTION",
147    identifier: Some("RunMat:load:Selection"),
148    when: "Requested variables are missing or no variables are selected.",
149    message: "load: variable selection failed",
150};
151const LOAD_ERROR_IO: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
152    code: "RM.LOAD.IO",
153    identifier: Some("RunMat:load:Io"),
154    when: "MAT-file data cannot be read or decoded.",
155    message: "load: MAT-file I/O failure",
156};
157const LOAD_ERROR_OUTPUT_COUNT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
158    code: "RM.LOAD.OUTPUT_COUNT",
159    identifier: Some("RunMat:load:OutputCount"),
160    when: "Caller requests more outputs than supported by load.",
161    message: "load: unsupported output count",
162};
163const LOAD_ERROR_WORKSPACE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
164    code: "RM.LOAD.WORKSPACE",
165    identifier: Some("RunMat:load:Workspace"),
166    when: "Statement-form load cannot assign values into workspace.",
167    message: "load: workspace assignment failed",
168};
169const LOAD_ERRORS: [BuiltinErrorDescriptor; 7] = [
170    LOAD_ERROR_INVALID_ARGUMENT,
171    LOAD_ERROR_INVALID_OPTION,
172    LOAD_ERROR_FILENAME,
173    LOAD_ERROR_SELECTION,
174    LOAD_ERROR_IO,
175    LOAD_ERROR_OUTPUT_COUNT,
176    LOAD_ERROR_WORKSPACE,
177];
178pub const LOAD_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
179    signatures: &LOAD_SIGNATURES,
180    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
181    completion_policy: BuiltinCompletionPolicy::Public,
182    errors: &LOAD_ERRORS,
183};
184
185#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::mat::load")]
186pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
187    name: "load",
188    op_kind: GpuOpKind::Custom("io-load"),
189    supported_precisions: &[],
190    broadcast: BroadcastSemantics::None,
191    provider_hooks: &[],
192    constant_strategy: ConstantStrategy::InlineLiteral,
193    residency: ResidencyPolicy::NewHandle,
194    nan_mode: ReductionNaN::Include,
195    two_pass_threshold: None,
196    workgroup_size: None,
197    accepts_nan_mode: false,
198    notes: "Reads MAT-files on the host and produces CPU-resident values. Providers are not involved until accelerated code later promotes the results.",
199};
200
201#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::mat::load")]
202pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
203    name: "load",
204    shape: ShapeRequirements::Any,
205    constant_strategy: ConstantStrategy::InlineLiteral,
206    elementwise: None,
207    reduction: None,
208    emits_nan: false,
209    notes: "File I/O is not eligible for fusion. Registration exists for documentation completeness only.",
210};
211
212#[runtime_builtin(
213    name = "load",
214    category = "io/mat",
215    summary = "Load variables from a MAT-file.",
216    keywords = "load,mat,workspace",
217    accel = "cpu",
218    sink = true,
219    type_resolver(crate::builtins::io::type_resolvers::load_type),
220    descriptor(crate::builtins::io::mat::load::LOAD_DESCRIPTOR),
221    builtin_path = "crate::builtins::io::mat::load"
222)]
223async fn load_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
224    let eval = evaluate(&args).await?;
225
226    // current_output_count() is set by the dispatcher only for multi-output Unpack patterns
227    // like `[a, b] = load(...)`. Guard against requesting more than one struct output.
228    if let Some(n) = crate::output_count::current_output_count() {
229        if n > 1 {
230            return Err(load_error_with(
231                &LOAD_ERROR_OUTPUT_COUNT,
232                "load supports at most one output argument",
233            ));
234        }
235    }
236
237    // The VM sets output_context::requested_output_count() at every call site before
238    // dispatching:
239    //   Some(0) → statement-level call (result is discarded or printed without capture)
240    //             → assign loaded variables directly into the caller's workspace.
241    //   Some(1) → single-output assignment `S = load(...)` → return a struct.
242    //   None    → called outside the VM (e.g. directly from Rust) → return a struct.
243    if crate::output_context::requested_output_count() == Some(0) {
244        for (name, value) in eval.variables() {
245            crate::workspace::assign(name, value.clone())
246                .map_err(|err| load_error_with(&LOAD_ERROR_WORKSPACE, err))?;
247        }
248        return Ok(Value::OutputList(Vec::new()));
249    }
250
251    Ok(eval.first_output())
252}
253
254#[derive(Clone, Debug)]
255pub struct LoadEval {
256    variables: Vec<(String, Value)>,
257}
258
259impl LoadEval {
260    pub fn first_output(&self) -> Value {
261        let mut st = StructValue::new();
262        for (name, value) in &self.variables {
263            st.fields.insert(name.clone(), value.clone());
264        }
265        Value::Struct(st)
266    }
267
268    pub fn variables(&self) -> &[(String, Value)] {
269        &self.variables
270    }
271
272    pub fn into_variables(self) -> Vec<(String, Value)> {
273        self.variables
274    }
275}
276
277struct LoadRequest {
278    variables: Vec<String>,
279    regex_patterns: Vec<Regex>,
280}
281
282const BUILTIN_NAME: &str = "load";
283
284fn load_error(message: impl Into<String>) -> RuntimeError {
285    load_error_with(&LOAD_ERROR_INVALID_ARGUMENT, message)
286}
287
288fn load_error_with(
289    error: &'static BuiltinErrorDescriptor,
290    message: impl Into<String>,
291) -> RuntimeError {
292    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
293    if let Some(identifier) = error.identifier {
294        builder = builder.with_identifier(identifier);
295    }
296    builder.build()
297}
298
299fn load_error_with_source(
300    error: &'static BuiltinErrorDescriptor,
301    message: impl Into<String>,
302    source: impl std::error::Error + Send + Sync + 'static,
303) -> RuntimeError {
304    let mut builder = build_runtime_error(message)
305        .with_builtin(BUILTIN_NAME)
306        .with_source(source);
307    if let Some(identifier) = error.identifier {
308        builder = builder.with_identifier(identifier);
309    }
310    builder.build()
311}
312
313pub async fn evaluate(args: &[Value]) -> BuiltinResult<LoadEval> {
314    let mut host_args = Vec::with_capacity(args.len());
315    for arg in args {
316        host_args.push(gather_if_needed_async(arg).await?);
317    }
318
319    let invocation = parse_invocation(&host_args).await?;
320
321    let mut path_value = if let Some(path) = invocation.path_value {
322        path
323    } else {
324        Value::from("matlab.mat")
325    };
326
327    if invocation.path_was_default {
328        if let Ok(override_path) = std::env::var("RUNMAT_LOAD_DEFAULT_PATH") {
329            path_value = Value::from(override_path);
330        }
331    }
332
333    let mut regex_patterns = Vec::with_capacity(invocation.regex_tokens.len());
334    for pattern in invocation.regex_tokens {
335        let regex = Regex::new(&pattern).map_err(|err| {
336            load_error_with_source(
337                &LOAD_ERROR_INVALID_OPTION,
338                format!("load: invalid regular expression '{pattern}': {err}"),
339                err,
340            )
341        })?;
342        regex_patterns.push(regex);
343    }
344
345    let request = LoadRequest {
346        variables: invocation.variables,
347        regex_patterns,
348    };
349    let path = normalise_path(&path_value)?;
350    let entries = read_mat_file(&path).await?;
351
352    let selected = select_variables(&entries, &request)?;
353    Ok(LoadEval {
354        variables: selected,
355    })
356}
357
358struct ParsedInvocation {
359    path_value: Option<Value>,
360    path_was_default: bool,
361    variables: Vec<String>,
362    regex_tokens: Vec<String>,
363}
364
365async fn parse_invocation(values: &[Value]) -> BuiltinResult<ParsedInvocation> {
366    let mut path_value = None;
367    let mut path_was_default = false;
368    let mut variables = Vec::new();
369    let mut regex_tokens = Vec::new();
370    let mut idx = 0usize;
371    while idx < values.len() {
372        if let Some(flag) = option_token(&values[idx])? {
373            match flag.as_str() {
374                "-mat" => {
375                    idx += 1;
376                    continue;
377                }
378                "-regexp" => {
379                    idx += 1;
380                    if idx >= values.len() {
381                        return Err(load_error_with(
382                            &LOAD_ERROR_INVALID_OPTION,
383                            "load: '-regexp' requires at least one pattern",
384                        ));
385                    }
386                    while idx < values.len() {
387                        if option_token(&values[idx])?.is_some() {
388                            break;
389                        }
390                        let names = extract_names(&values[idx]).await?;
391                        if names.is_empty() {
392                            return Err(load_error_with(
393                                &LOAD_ERROR_INVALID_OPTION,
394                                "load: '-regexp' requires non-empty pattern strings",
395                            ));
396                        }
397                        regex_tokens.extend(names);
398                        idx += 1;
399                    }
400                    continue;
401                }
402                other => {
403                    return Err(load_error_with(
404                        &LOAD_ERROR_INVALID_OPTION,
405                        format!("load: unsupported option '{other}'"),
406                    ));
407                }
408            }
409        } else {
410            if path_value.is_none() {
411                path_value = Some(values[idx].clone());
412                idx += 1;
413                continue;
414            }
415            let names = extract_names(&values[idx]).await?;
416            variables.extend(names);
417            idx += 1;
418        }
419    }
420
421    if path_value.is_none() {
422        path_was_default = true;
423    }
424
425    Ok(ParsedInvocation {
426        path_value,
427        path_was_default,
428        variables,
429        regex_tokens,
430    })
431}
432
433fn normalise_path(value: &Value) -> BuiltinResult<PathBuf> {
434    let raw = value_to_string_scalar(value).ok_or_else(|| {
435        load_error_with(
436            &LOAD_ERROR_FILENAME,
437            "load: filename must be a character vector or string scalar",
438        )
439    })?;
440    let mut path = PathBuf::from(raw);
441    if path.extension().is_none() {
442        path.set_extension("mat");
443    }
444    Ok(path)
445}
446
447fn select_variables(
448    entries: &[(String, Value)],
449    request: &LoadRequest,
450) -> BuiltinResult<Vec<(String, Value)>> {
451    if request.variables.is_empty() && request.regex_patterns.is_empty() {
452        return Ok(entries.to_vec());
453    }
454
455    let mut by_name: HashMap<&str, &Value> = HashMap::with_capacity(entries.len());
456    for (name, value) in entries {
457        by_name.insert(name, value);
458    }
459
460    let mut selected = Vec::new();
461
462    for name in &request.variables {
463        let value = by_name.get(name.as_str()).ok_or_else(|| {
464            load_error_with(
465                &LOAD_ERROR_SELECTION,
466                format!("load: variable '{name}' was not found in the file"),
467            )
468        })?;
469        insert_or_replace(&mut selected, name, (*value).clone());
470    }
471
472    if !request.regex_patterns.is_empty() {
473        let mut matched = 0usize;
474        for (name, value) in entries {
475            if request
476                .regex_patterns
477                .iter()
478                .any(|regex| regex.is_match(name))
479            {
480                matched += 1;
481                insert_or_replace(&mut selected, name, value.clone());
482            }
483        }
484        if matched == 0 && request.variables.is_empty() {
485            return Err(load_error_with(
486                &LOAD_ERROR_SELECTION,
487                "load: no variables matched '-regexp' patterns",
488            ));
489        }
490    }
491
492    if selected.is_empty() {
493        return Err(load_error_with(
494            &LOAD_ERROR_SELECTION,
495            "load: no variables selected",
496        ));
497    }
498
499    Ok(selected)
500}
501
502fn insert_or_replace(selected: &mut Vec<(String, Value)>, name: &str, value: Value) {
503    if let Some(entry) = selected.iter_mut().find(|(existing, _)| existing == name) {
504        entry.1 = value;
505    } else {
506        selected.push((name.to_string(), value));
507    }
508}
509
510pub(crate) async fn read_mat_file_for_builtin(
511    path: &Path,
512    builtin: &str,
513) -> crate::BuiltinResult<Vec<(String, Value)>> {
514    match read_mat_file(path).await {
515        Ok(entries) => Ok(entries),
516        Err(err) => {
517            let message = err.message().replacen("load:", &format!("{builtin}:"), 1);
518            let mut builder = build_runtime_error(message).with_builtin(builtin);
519            if let Some(identifier) = err.identifier() {
520                builder = builder.with_identifier(identifier);
521            }
522            Err(builder.with_source(err).build())
523        }
524    }
525}
526
527pub(crate) async fn read_mat_file(path: &Path) -> BuiltinResult<Vec<(String, Value)>> {
528    let file = File::open_async(path).await.map_err(|err| {
529        load_error_with_source(
530            &LOAD_ERROR_IO,
531            format!("load: failed to open '{}': {err}", path.display()),
532            err,
533        )
534    })?;
535    let mut reader = BufReader::new(file);
536    let entries = read_mat_reader(&mut reader)?;
537    restore_loaded_entries(entries).await
538}
539
540pub fn decode_workspace_from_mat_bytes(bytes: &[u8]) -> BuiltinResult<Vec<(String, Value)>> {
541    let mut cursor = Cursor::new(bytes);
542    let entries = read_mat_reader(&mut cursor)?;
543    futures::executor::block_on(restore_loaded_entries(entries))
544}
545
546async fn restore_loaded_entries(
547    entries: Vec<(String, Value)>,
548) -> BuiltinResult<Vec<(String, Value)>> {
549    let mut restored = Vec::with_capacity(entries.len());
550    for (name, value) in entries {
551        restored.push((name, restore_value_from_mat_load(value).await?));
552    }
553    Ok(restored)
554}
555
556#[derive(Clone, Copy, Debug)]
557enum Endian {
558    Little,
559    Big,
560}
561
562impl Endian {
563    fn read_u16(self, bytes: &[u8]) -> u16 {
564        match self {
565            Endian::Little => u16::from_le_bytes(bytes.try_into().unwrap()),
566            Endian::Big => u16::from_be_bytes(bytes.try_into().unwrap()),
567        }
568    }
569
570    fn read_i16(self, bytes: &[u8]) -> i16 {
571        match self {
572            Endian::Little => i16::from_le_bytes(bytes.try_into().unwrap()),
573            Endian::Big => i16::from_be_bytes(bytes.try_into().unwrap()),
574        }
575    }
576
577    fn read_u32(self, bytes: &[u8]) -> u32 {
578        match self {
579            Endian::Little => u32::from_le_bytes(bytes.try_into().unwrap()),
580            Endian::Big => u32::from_be_bytes(bytes.try_into().unwrap()),
581        }
582    }
583
584    fn read_i32(self, bytes: &[u8]) -> i32 {
585        match self {
586            Endian::Little => i32::from_le_bytes(bytes.try_into().unwrap()),
587            Endian::Big => i32::from_be_bytes(bytes.try_into().unwrap()),
588        }
589    }
590
591    fn read_u64(self, bytes: &[u8]) -> u64 {
592        match self {
593            Endian::Little => u64::from_le_bytes(bytes.try_into().unwrap()),
594            Endian::Big => u64::from_be_bytes(bytes.try_into().unwrap()),
595        }
596    }
597
598    fn read_i64(self, bytes: &[u8]) -> i64 {
599        match self {
600            Endian::Little => i64::from_le_bytes(bytes.try_into().unwrap()),
601            Endian::Big => i64::from_be_bytes(bytes.try_into().unwrap()),
602        }
603    }
604
605    fn read_f32(self, bytes: &[u8]) -> f32 {
606        match self {
607            Endian::Little => f32::from_le_bytes(bytes.try_into().unwrap()),
608            Endian::Big => f32::from_be_bytes(bytes.try_into().unwrap()),
609        }
610    }
611
612    fn read_f64(self, bytes: &[u8]) -> f64 {
613        match self {
614            Endian::Little => f64::from_le_bytes(bytes.try_into().unwrap()),
615            Endian::Big => f64::from_be_bytes(bytes.try_into().unwrap()),
616        }
617    }
618}
619
620fn read_mat_reader<R: Read>(reader: &mut R) -> BuiltinResult<Vec<(String, Value)>> {
621    let mut header = [0u8; MAT_HEADER_LEN];
622    reader.read_exact(&mut header).map_err(|err| {
623        load_error_with_source(
624            &LOAD_ERROR_IO,
625            format!("load: failed to read MAT-file header: {err}"),
626            err,
627        )
628    })?;
629
630    let description = String::from_utf8_lossy(&header[..116]);
631    if description.contains("MATLAB 7.3") || header.starts_with(b"\x89HDF\r\n\x1a\n") {
632        return Err(load_error(
633            "load: MATLAB v7.3 MAT-files are HDF5-backed and are not supported yet",
634        ));
635    }
636
637    let endian = match (header[126], header[127]) {
638        (b'I', b'M') => Endian::Little,
639        (b'M', b'I') => Endian::Big,
640        _ => return Err(load_error("load: file is not a MATLAB Level-5 MAT-file")),
641    };
642
643    read_variables_from_elements(reader, endian)
644}
645
646fn read_variables_from_elements<R: Read>(
647    reader: &mut R,
648    endian: Endian,
649) -> BuiltinResult<Vec<(String, Value)>> {
650    let mut variables = Vec::new();
651    while let Some(tagged) = read_tagged(reader, true, endian)? {
652        match tagged.data_type {
653            MI_MATRIX => {
654                let parsed = parse_matrix(&tagged.data, endian)?;
655                let value = mat_array_to_value(parsed.array)?;
656                variables.push((parsed.name, value));
657            }
658            MI_COMPRESSED => {
659                let mut decoder = ZlibDecoder::new(Cursor::new(tagged.data));
660                let mut inflated = Vec::new();
661                decoder.read_to_end(&mut inflated).map_err(|err| {
662                    load_error_with_source(
663                        &LOAD_ERROR_IO,
664                        format!("load: failed to decompress MAT element: {err}"),
665                        err,
666                    )
667                })?;
668                let mut cursor = Cursor::new(inflated);
669                variables.extend(read_variables_from_elements(&mut cursor, endian)?);
670            }
671            _ => continue,
672        }
673    }
674    Ok(variables)
675}
676
677struct ParsedMatrix {
678    name: String,
679    array: MatArray,
680}
681
682fn parse_matrix(buffer: &[u8], endian: Endian) -> BuiltinResult<ParsedMatrix> {
683    let mut cursor = Cursor::new(buffer);
684
685    let flags = read_tagged(&mut cursor, false, endian)?
686        .ok_or_else(|| load_error("load: matrix element missing array flags"))?;
687    if flags.data_type != MI_UINT32 || flags.data.len() < 8 {
688        return Err(load_error("load: invalid array flags block"));
689    }
690    let flags0 = endian.read_u32(&flags.data[0..4]);
691    let class_code = flags0 & 0xFF;
692    let mut class = MatClass::from_class_code(class_code)
693        .ok_or_else(|| load_error("load: unsupported MATLAB class"))?;
694    let is_logical = (flags0 & FLAG_LOGICAL) != 0;
695    let has_imag = (flags0 & FLAG_COMPLEX) != 0;
696    if is_logical {
697        class = MatClass::Logical;
698    }
699
700    let dims_elem = read_tagged(&mut cursor, false, endian)?
701        .ok_or_else(|| load_error("load: matrix element missing dimensions"))?;
702    if dims_elem.data_type != MI_INT32 {
703        return Err(load_error("load: dimension block must use MI_INT32"));
704    }
705    if dims_elem.data.is_empty() || dims_elem.data.len() % 4 != 0 {
706        return Err(load_error("load: malformed dimension block"));
707    }
708    let mut dims = Vec::with_capacity(dims_elem.data.len() / 4);
709    for chunk in dims_elem.data.chunks_exact(4) {
710        let value = endian.read_i32(chunk);
711        if value < 0 {
712            return Err(load_error("load: negative dimensions are not supported"));
713        }
714        dims.push(value as usize);
715    }
716    if dims.is_empty() {
717        dims.push(1);
718        dims.push(1);
719    }
720
721    let name_elem = read_tagged(&mut cursor, false, endian)?
722        .ok_or_else(|| load_error("load: matrix element missing name"))?;
723    let name = match name_elem.data_type {
724        MI_INT8 | MI_UINT8 => bytes_to_string(&name_elem.data),
725        MI_UINT16 | MI_UTF16 => {
726            let mut bytes = Vec::with_capacity(name_elem.data.len());
727            for chunk in name_elem.data.chunks_exact(2) {
728                let code = endian.read_u16(chunk);
729                if code == 0 {
730                    break;
731                }
732                if let Some(ch) = char::from_u32(code as u32) {
733                    bytes.push(ch);
734                }
735            }
736            bytes.into_iter().collect()
737        }
738        _ => {
739            return Err(load_error("load: unsupported array name encoding"));
740        }
741    };
742
743    let array = match class {
744        MatClass::Double => parse_numeric_array(&mut cursor, class, dims, has_imag, endian)?,
745        MatClass::Single
746        | MatClass::Int8
747        | MatClass::UInt8
748        | MatClass::Int16
749        | MatClass::UInt16
750        | MatClass::Int32
751        | MatClass::UInt32
752        | MatClass::Int64
753        | MatClass::UInt64 => parse_numeric_array(&mut cursor, class, dims, has_imag, endian)?,
754        MatClass::Logical => parse_logical_array(&mut cursor, dims, endian)?,
755        MatClass::Char => parse_char_array(&mut cursor, dims, endian)?,
756        MatClass::Cell => parse_cell_array(&mut cursor, dims, endian)?,
757        MatClass::Struct => parse_struct(&mut cursor, dims, endian)?,
758        MatClass::Sparse => parse_sparse_array(&mut cursor, dims, has_imag, endian)?,
759    };
760
761    Ok(ParsedMatrix { name, array })
762}
763
764fn parse_numeric_array(
765    cursor: &mut Cursor<&[u8]>,
766    class: MatClass,
767    dims: Vec<usize>,
768    has_imag: bool,
769    endian: Endian,
770) -> BuiltinResult<MatArray> {
771    let real_elem = read_tagged(cursor, false, endian)?
772        .ok_or_else(|| load_error("load: numeric array missing real component"))?;
773    if class_is_integer(class) {
774        if has_imag {
775            return Err(load_error(
776                "load: complex integer MAT arrays require typed complex integer storage",
777            ));
778        }
779        return Ok(MatArray {
780            class,
781            dims,
782            data: MatData::Integer {
783                storage: decode_integer_storage(&real_elem, class, endian)?,
784            },
785        });
786    }
787
788    let real = decode_numeric_values(&real_elem, endian)?;
789
790    let imag = if has_imag {
791        let imag_elem = read_tagged(cursor, false, endian)?
792            .ok_or_else(|| load_error("load: numeric array missing imaginary component"))?;
793        Some(decode_numeric_values(&imag_elem, endian)?)
794    } else {
795        None
796    };
797
798    let data = if class == MatClass::Double {
799        MatData::Double { real, imag }
800    } else {
801        MatData::Numeric { real, imag }
802    };
803
804    Ok(MatArray { class, dims, data })
805}
806
807fn class_is_integer(class: MatClass) -> bool {
808    matches!(
809        class,
810        MatClass::Int8
811            | MatClass::UInt8
812            | MatClass::Int16
813            | MatClass::UInt16
814            | MatClass::Int32
815            | MatClass::UInt32
816            | MatClass::Int64
817            | MatClass::UInt64
818    )
819}
820
821fn decode_integer_storage(
822    elem: &TaggedData,
823    class: MatClass,
824    endian: Endian,
825) -> BuiltinResult<IntegerStorage> {
826    let expected_type = match class {
827        MatClass::Int8 => MI_INT8,
828        MatClass::UInt8 => MI_UINT8,
829        MatClass::Int16 => MI_INT16,
830        MatClass::UInt16 => MI_UINT16,
831        MatClass::Int32 => MI_INT32,
832        MatClass::UInt32 => MI_UINT32,
833        MatClass::Int64 => MI_INT64,
834        MatClass::UInt64 => MI_UINT64,
835        _ => return Err(load_error("load: expected an integer MAT class")),
836    };
837    if elem.data_type != expected_type {
838        return Err(load_error(format!(
839            "load: integer MAT class payload type {} does not match class {:?}",
840            elem.data_type, class
841        )));
842    }
843
844    let data = &elem.data;
845    match class {
846        MatClass::Int8 => Ok(IntegerStorage::I8(
847            data.iter().map(|value| *value as i8).collect(),
848        )),
849        MatClass::UInt8 => Ok(IntegerStorage::U8(data.clone())),
850        MatClass::Int16 => {
851            ensure_data_width(data, 2, "integer int16 data")?;
852            Ok(IntegerStorage::I16(
853                data.chunks_exact(2)
854                    .map(|chunk| endian.read_i16(chunk))
855                    .collect(),
856            ))
857        }
858        MatClass::UInt16 => {
859            ensure_data_width(data, 2, "integer uint16 data")?;
860            Ok(IntegerStorage::U16(
861                data.chunks_exact(2)
862                    .map(|chunk| endian.read_u16(chunk))
863                    .collect(),
864            ))
865        }
866        MatClass::Int32 => {
867            ensure_data_width(data, 4, "integer int32 data")?;
868            Ok(IntegerStorage::I32(
869                data.chunks_exact(4)
870                    .map(|chunk| endian.read_i32(chunk))
871                    .collect(),
872            ))
873        }
874        MatClass::UInt32 => {
875            ensure_data_width(data, 4, "integer uint32 data")?;
876            Ok(IntegerStorage::U32(
877                data.chunks_exact(4)
878                    .map(|chunk| endian.read_u32(chunk))
879                    .collect(),
880            ))
881        }
882        MatClass::Int64 => {
883            ensure_data_width(data, 8, "integer int64 data")?;
884            Ok(IntegerStorage::I64(
885                data.chunks_exact(8)
886                    .map(|chunk| endian.read_i64(chunk))
887                    .collect(),
888            ))
889        }
890        MatClass::UInt64 => {
891            ensure_data_width(data, 8, "integer uint64 data")?;
892            Ok(IntegerStorage::U64(
893                data.chunks_exact(8)
894                    .map(|chunk| endian.read_u64(chunk))
895                    .collect(),
896            ))
897        }
898        _ => Err(load_error("load: expected an integer MAT class")),
899    }
900}
901
902fn parse_logical_array(
903    cursor: &mut Cursor<&[u8]>,
904    dims: Vec<usize>,
905    endian: Endian,
906) -> BuiltinResult<MatArray> {
907    let elem = read_tagged(cursor, false, endian)?
908        .ok_or_else(|| load_error("load: logical array missing data block"))?;
909    let data = decode_numeric_values(&elem, endian)?
910        .into_iter()
911        .map(|v| if v != 0.0 { 1 } else { 0 })
912        .collect();
913    Ok(MatArray {
914        class: MatClass::Logical,
915        dims,
916        data: MatData::Logical { data },
917    })
918}
919
920fn parse_char_array(
921    cursor: &mut Cursor<&[u8]>,
922    dims: Vec<usize>,
923    endian: Endian,
924) -> BuiltinResult<MatArray> {
925    let elem = read_tagged(cursor, false, endian)?
926        .ok_or_else(|| load_error("load: character array missing data block"))?;
927    let data = decode_char_codes(&elem, endian)?;
928    Ok(MatArray {
929        class: MatClass::Char,
930        dims,
931        data: MatData::Char { data },
932    })
933}
934
935fn parse_cell_array(
936    cursor: &mut Cursor<&[u8]>,
937    dims: Vec<usize>,
938    endian: Endian,
939) -> BuiltinResult<MatArray> {
940    let total: usize = dims
941        .iter()
942        .copied()
943        .fold(1usize, |acc, d| acc.saturating_mul(d));
944    let mut elements = Vec::with_capacity(total);
945    for _ in 0..total {
946        let elem = read_tagged(cursor, false, endian)?
947            .ok_or_else(|| load_error("load: cell element missing matrix payload"))?;
948        if elem.data_type != MI_MATRIX {
949            return Err(load_error("load: cell elements must be matrices"));
950        }
951        let parsed = parse_matrix(&elem.data, endian)?;
952        elements.push(parsed.array);
953    }
954    Ok(MatArray {
955        class: MatClass::Cell,
956        dims,
957        data: MatData::Cell { elements },
958    })
959}
960
961fn parse_struct(
962    cursor: &mut Cursor<&[u8]>,
963    dims: Vec<usize>,
964    endian: Endian,
965) -> BuiltinResult<MatArray> {
966    if dims.len() != 2 || dims[0] != 1 || dims[1] != 1 {
967        return Err(load_error("load: struct arrays are not supported yet"));
968    }
969    let len_elem = read_tagged(cursor, false, endian)?
970        .ok_or_else(|| load_error("load: struct missing maximum field length specifier"))?;
971    if len_elem.data_type != MI_INT32 || len_elem.data.len() != 4 {
972        return Err(load_error("load: struct field length must be MI_INT32"));
973    }
974    let max_len = endian.read_i32(&len_elem.data[..4]);
975    if max_len <= 0 {
976        return Err(load_error("load: struct field length must be positive"));
977    }
978
979    let names_elem = read_tagged(cursor, false, endian)?
980        .ok_or_else(|| load_error("load: struct missing field name table"))?;
981    if names_elem.data_type != MI_INT8 && names_elem.data_type != MI_UINT8 {
982        return Err(load_error(
983            "load: struct field names must be stored as MI_INT8/MI_UINT8",
984        ));
985    }
986    if names_elem.data.len() % (max_len as usize) != 0 {
987        return Err(load_error("load: malformed struct field name table"));
988    }
989    let field_count = names_elem.data.len() / (max_len as usize);
990    let mut field_names = Vec::with_capacity(field_count);
991    for i in 0..field_count {
992        let start = i * (max_len as usize);
993        let end = start + (max_len as usize);
994        let slice = &names_elem.data[start..end];
995        field_names.push(bytes_to_string(slice));
996    }
997
998    let mut field_values = Vec::with_capacity(field_count);
999    for _ in 0..field_count {
1000        let elem = read_tagged(cursor, false, endian)?
1001            .ok_or_else(|| load_error("load: struct field missing matrix payload"))?;
1002        if elem.data_type != MI_MATRIX {
1003            return Err(load_error("load: struct fields must be matrices"));
1004        }
1005        let parsed = parse_matrix(&elem.data, endian)?;
1006        field_values.push(parsed.array);
1007    }
1008
1009    Ok(MatArray {
1010        class: MatClass::Struct,
1011        dims,
1012        data: MatData::Struct {
1013            field_names,
1014            field_values,
1015        },
1016    })
1017}
1018
1019fn parse_sparse_array(
1020    cursor: &mut Cursor<&[u8]>,
1021    dims: Vec<usize>,
1022    has_imag: bool,
1023    endian: Endian,
1024) -> BuiltinResult<MatArray> {
1025    if has_imag {
1026        return Err(load_error(
1027            "load: complex sparse MAT arrays are not supported yet",
1028        ));
1029    }
1030    let rows = dims.first().copied().unwrap_or(0);
1031    let cols = dims.get(1).copied().unwrap_or(0);
1032
1033    let ir_elem = read_tagged(cursor, false, endian)?
1034        .ok_or_else(|| load_error("load: sparse array missing row indices"))?;
1035    let row_indices = decode_index_values(&ir_elem, endian, "sparse row indices")?;
1036
1037    let jc_elem = read_tagged(cursor, false, endian)?
1038        .ok_or_else(|| load_error("load: sparse array missing column pointers"))?;
1039    let col_ptrs = decode_index_values(&jc_elem, endian, "sparse column pointers")?;
1040
1041    let real_elem = read_tagged(cursor, false, endian)?
1042        .ok_or_else(|| load_error("load: sparse array missing values"))?;
1043    let values = decode_numeric_values(&real_elem, endian)?;
1044
1045    Ok(MatArray {
1046        class: MatClass::Sparse,
1047        dims,
1048        data: MatData::Sparse {
1049            rows,
1050            cols,
1051            col_ptrs,
1052            row_indices,
1053            values,
1054        },
1055    })
1056}
1057
1058fn decode_numeric_values(elem: &TaggedData, endian: Endian) -> BuiltinResult<Vec<f64>> {
1059    let data = &elem.data;
1060    match elem.data_type {
1061        MI_INT8 => Ok(data.iter().map(|v| (*v as i8) as f64).collect()),
1062        MI_UINT8 => Ok(data.iter().map(|v| *v as f64).collect()),
1063        MI_INT16 => {
1064            ensure_data_width(data, 2, "numeric int16 data")?;
1065            Ok(data
1066                .chunks_exact(2)
1067                .map(|chunk| endian.read_i16(chunk) as f64)
1068                .collect())
1069        }
1070        MI_UINT16 => {
1071            ensure_data_width(data, 2, "numeric uint16 data")?;
1072            Ok(data
1073                .chunks_exact(2)
1074                .map(|chunk| endian.read_u16(chunk) as f64)
1075                .collect())
1076        }
1077        MI_INT32 => {
1078            ensure_data_width(data, 4, "numeric int32 data")?;
1079            Ok(data
1080                .chunks_exact(4)
1081                .map(|chunk| endian.read_i32(chunk) as f64)
1082                .collect())
1083        }
1084        MI_UINT32 => {
1085            ensure_data_width(data, 4, "numeric uint32 data")?;
1086            Ok(data
1087                .chunks_exact(4)
1088                .map(|chunk| endian.read_u32(chunk) as f64)
1089                .collect())
1090        }
1091        MI_SINGLE => {
1092            ensure_data_width(data, 4, "numeric single data")?;
1093            Ok(data
1094                .chunks_exact(4)
1095                .map(|chunk| endian.read_f32(chunk) as f64)
1096                .collect())
1097        }
1098        MI_DOUBLE => {
1099            ensure_data_width(data, 8, "numeric double data")?;
1100            Ok(data
1101                .chunks_exact(8)
1102                .map(|chunk| endian.read_f64(chunk))
1103                .collect())
1104        }
1105        MI_INT64 => {
1106            ensure_data_width(data, 8, "numeric int64 data")?;
1107            Ok(data
1108                .chunks_exact(8)
1109                .map(|chunk| endian.read_i64(chunk) as f64)
1110                .collect())
1111        }
1112        MI_UINT64 => {
1113            ensure_data_width(data, 8, "numeric uint64 data")?;
1114            Ok(data
1115                .chunks_exact(8)
1116                .map(|chunk| endian.read_u64(chunk) as f64)
1117                .collect())
1118        }
1119        _ => Err(load_error(format!(
1120            "load: unsupported numeric data type {}",
1121            elem.data_type
1122        ))),
1123    }
1124}
1125
1126fn decode_char_codes(elem: &TaggedData, endian: Endian) -> BuiltinResult<Vec<u16>> {
1127    match elem.data_type {
1128        MI_INT8 | MI_UINT8 | MI_UTF8 => {
1129            let text = String::from_utf8_lossy(&elem.data);
1130            Ok(text
1131                .chars()
1132                .map(|ch| {
1133                    if ch as u32 <= u16::MAX as u32 {
1134                        ch as u16
1135                    } else {
1136                        0xFFFD
1137                    }
1138                })
1139                .collect())
1140        }
1141        MI_UINT16 | MI_UTF16 => {
1142            ensure_data_width(&elem.data, 2, "character UTF-16 data")?;
1143            Ok(elem
1144                .data
1145                .chunks_exact(2)
1146                .map(|chunk| endian.read_u16(chunk))
1147                .collect())
1148        }
1149        MI_UTF32 => {
1150            ensure_data_width(&elem.data, 4, "character UTF-32 data")?;
1151            Ok(elem
1152                .data
1153                .chunks_exact(4)
1154                .map(|chunk| {
1155                    let code = endian.read_u32(chunk);
1156                    if code <= u16::MAX as u32 {
1157                        code as u16
1158                    } else {
1159                        0xFFFD
1160                    }
1161                })
1162                .collect())
1163        }
1164        _ => Err(load_error("load: unsupported character data encoding")),
1165    }
1166}
1167
1168fn decode_index_values(
1169    elem: &TaggedData,
1170    endian: Endian,
1171    label: &str,
1172) -> BuiltinResult<Vec<usize>> {
1173    match elem.data_type {
1174        MI_INT32 => {
1175            ensure_data_width(&elem.data, 4, label)?;
1176            let mut out = Vec::with_capacity(elem.data.len() / 4);
1177            for chunk in elem.data.chunks_exact(4) {
1178                let value = endian.read_i32(chunk);
1179                if value < 0 {
1180                    return Err(load_error(format!(
1181                        "load: {label} contain a negative index"
1182                    )));
1183                }
1184                out.push(value as usize);
1185            }
1186            Ok(out)
1187        }
1188        MI_UINT32 => {
1189            ensure_data_width(&elem.data, 4, label)?;
1190            Ok(elem
1191                .data
1192                .chunks_exact(4)
1193                .map(|chunk| endian.read_u32(chunk) as usize)
1194                .collect())
1195        }
1196        _ => Err(load_error(format!(
1197            "load: {label} must be MI_INT32/MI_UINT32"
1198        ))),
1199    }
1200}
1201
1202fn ensure_data_width(data: &[u8], width: usize, label: &str) -> BuiltinResult<()> {
1203    if !data.len().is_multiple_of(width) {
1204        return Err(load_error(format!("load: malformed {label}")));
1205    }
1206    Ok(())
1207}
1208
1209fn mat_array_to_value(array: MatArray) -> BuiltinResult<Value> {
1210    match array.data {
1211        MatData::Double { real, imag } => {
1212            let len = real.len();
1213            if let Some(imag) = imag {
1214                if imag.len() != len {
1215                    return Err(load_error(
1216                        "load: complex data has mismatched real/imag parts",
1217                    ));
1218                }
1219                if len == 1 {
1220                    Ok(Value::Complex(real[0], imag[0]))
1221                } else {
1222                    let mut pairs = Vec::with_capacity(len);
1223                    for i in 0..len {
1224                        pairs.push((real[i], imag[i]));
1225                    }
1226                    let tensor = ComplexTensor::new(pairs, array.dims.clone())
1227                        .map_err(|e| load_error(format!("load: {e}")))?;
1228                    Ok(Value::ComplexTensor(tensor))
1229                }
1230            } else if len == 1 {
1231                Ok(Value::Num(real[0]))
1232            } else {
1233                let tensor = Tensor::new(real, array.dims.clone())
1234                    .map_err(|e| load_error(format!("load: {e}")))?;
1235                Ok(Value::Tensor(tensor))
1236            }
1237        }
1238        MatData::Numeric { real, imag } => {
1239            let len = real.len();
1240            if let Some(imag) = imag {
1241                if imag.len() != len {
1242                    return Err(load_error(
1243                        "load: complex data has mismatched real/imag parts",
1244                    ));
1245                }
1246                if len == 1 {
1247                    return Ok(Value::Complex(real[0], imag[0]));
1248                }
1249                let pairs: Vec<(f64, f64)> = real.into_iter().zip(imag).collect();
1250                let tensor = ComplexTensor::new(pairs, array.dims.clone())
1251                    .map_err(|e| load_error(format!("load: {e}")))?;
1252                return Ok(Value::ComplexTensor(tensor));
1253            }
1254
1255            if len == 1 {
1256                if let Some(value) = numeric_scalar_to_int_value(array.class, real[0]) {
1257                    return Ok(value);
1258                }
1259                return Ok(Value::Num(real[0]));
1260            }
1261
1262            let dtype = numeric_tensor_dtype(array.class);
1263            let tensor = Tensor::new_with_dtype(real, array.dims.clone(), dtype)
1264                .map_err(|e| load_error(format!("load: {e}")))?;
1265            Ok(Value::Tensor(tensor))
1266        }
1267        MatData::Integer { storage } => {
1268            if storage.len() == 1 {
1269                return Ok(Value::Int(integer_storage_scalar(&storage, 0)));
1270            }
1271            let tensor = Tensor::new_integer(storage, array.dims.clone())
1272                .map_err(|e| load_error(format!("load: {e}")))?;
1273            Ok(Value::Tensor(tensor))
1274        }
1275        MatData::Logical { data } => {
1276            let total: usize = array
1277                .dims
1278                .iter()
1279                .copied()
1280                .fold(1usize, |acc, d| acc.saturating_mul(d));
1281            if data.len() != total {
1282                return Err(load_error("load: logical data length mismatch"));
1283            }
1284            if total == 1 {
1285                Ok(Value::Bool(data.first().copied().unwrap_or(0) != 0))
1286            } else {
1287                let logical = LogicalArray::new(data, array.dims.clone())
1288                    .map_err(|e| load_error(format!("load: {e}")))?;
1289                Ok(Value::LogicalArray(logical))
1290            }
1291        }
1292        MatData::Char { data } => {
1293            let rows = array.dims.first().copied().unwrap_or(1);
1294            let cols = array.dims.get(1).copied().unwrap_or(1);
1295            let mut chars = Vec::with_capacity(rows.saturating_mul(cols));
1296            for code in data {
1297                let ch = char::from_u32(code as u32).unwrap_or('\u{FFFD}');
1298                chars.push(ch);
1299            }
1300            let char_array =
1301                CharArray::new(chars, rows, cols).map_err(|e| load_error(format!("load: {e}")))?;
1302            Ok(Value::CharArray(char_array))
1303        }
1304        MatData::Cell { elements } => {
1305            if let Some(strings) = cell_elements_to_strings(&elements) {
1306                let string_array = StringArray::new(strings, array.dims.clone())
1307                    .map_err(|e| load_error(format!("load: {e}")))?;
1308                return Ok(Value::StringArray(string_array));
1309            }
1310            if array.dims.len() != 2 {
1311                return Err(load_error(
1312                    "load: cell arrays with more than two dimensions are not supported yet",
1313                ));
1314            }
1315            let rows = array.dims[0];
1316            let cols = array.dims[1];
1317            let expected = rows.saturating_mul(cols);
1318            if elements.len() != expected {
1319                return Err(load_error("load: cell array element count mismatch"));
1320            }
1321            let mut converted = Vec::with_capacity(elements.len());
1322            for elem in elements {
1323                converted.push(mat_array_to_value(elem)?);
1324            }
1325            let mut row_major = vec![Value::Num(0.0); expected];
1326            for col in 0..cols {
1327                for row in 0..rows {
1328                    let cm_idx = col * rows + row;
1329                    let rm_idx = row * cols + col;
1330                    row_major[rm_idx] = converted[cm_idx].clone();
1331                }
1332            }
1333            make_cell(row_major, rows, cols).map_err(|err| load_error(format!("load: {err}")))
1334        }
1335        MatData::Struct {
1336            field_names,
1337            field_values,
1338        } => {
1339            if field_names.len() != field_values.len() {
1340                return Err(load_error("load: struct field metadata is inconsistent"));
1341            }
1342            let mut st = StructValue::new();
1343            for (name, value) in field_names.into_iter().zip(field_values.into_iter()) {
1344                let converted = mat_array_to_value(value)?;
1345                st.fields.insert(name, converted);
1346            }
1347            Ok(Value::Struct(st))
1348        }
1349        MatData::Sparse {
1350            rows,
1351            cols,
1352            col_ptrs,
1353            row_indices,
1354            values,
1355        } => {
1356            let sparse = SparseTensor::new(rows, cols, col_ptrs, row_indices, values)
1357                .map_err(|e| load_error(format!("load: {e}")))?;
1358            Ok(Value::SparseTensor(sparse))
1359        }
1360    }
1361}
1362
1363fn integer_storage_scalar(storage: &IntegerStorage, index: usize) -> IntValue {
1364    match storage {
1365        IntegerStorage::I8(values) => IntValue::I8(values[index]),
1366        IntegerStorage::I16(values) => IntValue::I16(values[index]),
1367        IntegerStorage::I32(values) => IntValue::I32(values[index]),
1368        IntegerStorage::I64(values) => IntValue::I64(values[index]),
1369        IntegerStorage::U8(values) => IntValue::U8(values[index]),
1370        IntegerStorage::U16(values) => IntValue::U16(values[index]),
1371        IntegerStorage::U32(values) => IntValue::U32(values[index]),
1372        IntegerStorage::U64(values) => IntValue::U64(values[index]),
1373    }
1374}
1375
1376fn numeric_scalar_to_int_value(class: MatClass, value: f64) -> Option<Value> {
1377    match class {
1378        MatClass::Int8 => Some(Value::Int(IntValue::I8(value as i8))),
1379        MatClass::UInt8 => Some(Value::Int(IntValue::U8(value as u8))),
1380        MatClass::Int16 => Some(Value::Int(IntValue::I16(value as i16))),
1381        MatClass::UInt16 => Some(Value::Int(IntValue::U16(value as u16))),
1382        MatClass::Int32 => Some(Value::Int(IntValue::I32(value as i32))),
1383        MatClass::UInt32 => Some(Value::Int(IntValue::U32(value as u32))),
1384        MatClass::Int64 => Some(Value::Int(IntValue::I64(value as i64))),
1385        MatClass::UInt64 => Some(Value::Int(IntValue::U64(value as u64))),
1386        _ => None,
1387    }
1388}
1389
1390fn numeric_tensor_dtype(class: MatClass) -> NumericDType {
1391    match class {
1392        MatClass::Single => NumericDType::F32,
1393        MatClass::UInt8 => NumericDType::U8,
1394        MatClass::UInt16 => NumericDType::U16,
1395        _ => NumericDType::F64,
1396    }
1397}
1398
1399fn cell_elements_to_strings(elements: &[MatArray]) -> Option<Vec<String>> {
1400    let mut strings = Vec::with_capacity(elements.len());
1401    for element in elements {
1402        if element.class != MatClass::Char {
1403            return None;
1404        }
1405        let rows = element.dims.first().copied().unwrap_or(1);
1406        if rows > 1 {
1407            return None;
1408        }
1409        match &element.data {
1410            MatData::Char { data } => strings.push(utf16_codes_to_string(data)),
1411            _ => return None,
1412        }
1413    }
1414    Some(strings)
1415}
1416
1417fn utf16_codes_to_string(data: &[u16]) -> String {
1418    let mut chars: Vec<char> = data
1419        .iter()
1420        .map(|code| char::from_u32(*code as u32).unwrap_or('\u{FFFD}'))
1421        .collect();
1422    while matches!(chars.last(), Some(&'\0')) {
1423        chars.pop();
1424    }
1425    chars.into_iter().collect()
1426}
1427
1428fn option_token(value: &Value) -> BuiltinResult<Option<String>> {
1429    if let Some(token) = value_to_string_scalar(value) {
1430        if token.starts_with('-') {
1431            return Ok(Some(token.to_ascii_lowercase()));
1432        }
1433    }
1434    Ok(None)
1435}
1436
1437#[async_recursion::async_recursion(?Send)]
1438async fn extract_names(value: &Value) -> BuiltinResult<Vec<String>> {
1439    match value {
1440        Value::String(s) => Ok(vec![s.clone()]),
1441        Value::CharArray(ca) => Ok(char_array_rows_as_strings(ca)),
1442        Value::StringArray(sa) => Ok(sa.data.clone()),
1443        Value::Cell(ca) => {
1444            let mut names = Vec::with_capacity(ca.data.len());
1445            for handle in &ca.data {
1446                let inner = handle;
1447                let text = value_to_string_scalar(inner).ok_or_else(|| {
1448                    load_error(
1449                        "load: cell arrays used for variable selection must contain string scalars",
1450                    )
1451                })?;
1452                names.push(text);
1453            }
1454            Ok(names)
1455        }
1456        other => {
1457            let gathered = gather_if_needed_async(other).await?;
1458            extract_names(&gathered).await
1459        }
1460    }
1461}
1462
1463fn value_to_string_scalar(value: &Value) -> Option<String> {
1464    match value {
1465        Value::String(s) => Some(s.clone()),
1466        Value::CharArray(ca) if ca.rows == 1 => Some(ca.data.iter().collect()),
1467        Value::StringArray(sa) if sa.data.len() == 1 => Some(sa.data[0].clone()),
1468        _ => None,
1469    }
1470}
1471
1472fn char_array_rows_as_strings(ca: &CharArray) -> Vec<String> {
1473    let mut rows = Vec::with_capacity(ca.rows);
1474    for r in 0..ca.rows {
1475        let mut row = String::with_capacity(ca.cols);
1476        for c in 0..ca.cols {
1477            let idx = r * ca.cols + c;
1478            row.push(ca.data[idx]);
1479        }
1480        let trimmed = row.trim_end_matches([' ', '\0']).to_string();
1481        rows.push(trimmed);
1482    }
1483    rows
1484}
1485
1486fn bytes_to_string(bytes: &[u8]) -> String {
1487    let trimmed = bytes
1488        .iter()
1489        .copied()
1490        .take_while(|b| *b != 0)
1491        .collect::<Vec<u8>>();
1492    String::from_utf8(trimmed).unwrap_or_default()
1493}
1494
1495struct TaggedData {
1496    data_type: u32,
1497    data: Vec<u8>,
1498}
1499
1500fn read_tagged<R: Read>(
1501    reader: &mut R,
1502    allow_eof: bool,
1503    endian: Endian,
1504) -> BuiltinResult<Option<TaggedData>> {
1505    let mut type_bytes = [0u8; 4];
1506    match reader.read_exact(&mut type_bytes) {
1507        Ok(()) => {}
1508        Err(err) => {
1509            if allow_eof && err.kind() == std::io::ErrorKind::UnexpectedEof {
1510                return Ok(None);
1511            }
1512            return Err(load_error_with_source(
1513                &LOAD_ERROR_IO,
1514                format!("load: failed to read MAT element header: {err}"),
1515                err,
1516            ));
1517        }
1518    }
1519
1520    if allow_eof && type_bytes == [0; 4] {
1521        return Ok(None);
1522    }
1523
1524    let type_field = endian.read_u32(&type_bytes);
1525    let high = (type_field >> 16) & 0xFFFF;
1526    let low = type_field & 0xFFFF;
1527    let small = match endian {
1528        Endian::Little if high != 0 => Some((low, high as usize)),
1529        Endian::Big if high != 0 && low <= 4 => Some((high, low as usize)),
1530        _ => None,
1531    };
1532
1533    if let Some((data_type, num_bytes)) = small {
1534        let mut inline = [0u8; 4];
1535        reader.read_exact(&mut inline).map_err(|err| {
1536            load_error_with_source(
1537                &LOAD_ERROR_IO,
1538                format!("load: failed to read compact MAT element: {err}"),
1539                err,
1540            )
1541        })?;
1542        let mut data = inline[..num_bytes.min(4)].to_vec();
1543        data.truncate(num_bytes.min(4));
1544        Ok(Some(TaggedData { data_type, data }))
1545    } else {
1546        let mut len_bytes = [0u8; 4];
1547        reader.read_exact(&mut len_bytes).map_err(|err| {
1548            load_error_with_source(
1549                &LOAD_ERROR_IO,
1550                format!("load: failed to read MAT element length: {err}"),
1551                err,
1552            )
1553        })?;
1554        let length = endian.read_u32(&len_bytes) as usize;
1555        let mut data = vec![0u8; length];
1556        reader.read_exact(&mut data).map_err(|err| {
1557            load_error_with_source(
1558                &LOAD_ERROR_IO,
1559                format!("load: failed to read MAT element body: {err}"),
1560                err,
1561            )
1562        })?;
1563        let padding = if type_field == MI_COMPRESSED {
1564            0
1565        } else {
1566            (8 - (length % 8)) % 8
1567        };
1568        if padding != 0 {
1569            let mut pad = vec![0u8; padding];
1570            reader.read_exact(&mut pad).map_err(|err| {
1571                load_error_with_source(
1572                    &LOAD_ERROR_IO,
1573                    format!("load: failed to read MAT padding: {err}"),
1574                    err,
1575                )
1576            })?;
1577        }
1578        Ok(Some(TaggedData {
1579            data_type: type_field,
1580            data,
1581        }))
1582    }
1583}
1584
1585#[cfg(test)]
1586pub(crate) mod tests {
1587    use super::*;
1588    use crate::builtins::io::mat::save::encode_workspace_to_mat_bytes;
1589    use crate::workspace::WorkspaceResolver;
1590    use flate2::write::ZlibEncoder;
1591    use flate2::Compression;
1592    use futures::executor::block_on;
1593    use runmat_builtins::{IntegerStorage, StringArray};
1594    use runmat_thread_local::runmat_thread_local;
1595    use std::cell::RefCell;
1596    use std::collections::HashMap;
1597    use std::io::Write;
1598    use tempfile::tempdir;
1599
1600    runmat_thread_local! {
1601        static TEST_WORKSPACE: RefCell<HashMap<String, Value>> = RefCell::new(HashMap::new());
1602    }
1603
1604    fn ensure_test_resolver() {
1605        crate::workspace::register_workspace_resolver(WorkspaceResolver {
1606            lookup: |name| TEST_WORKSPACE.with(|slot| slot.borrow().get(name).cloned()),
1607            snapshot: || {
1608                let mut entries: Vec<(String, Value)> =
1609                    TEST_WORKSPACE.with(|slot| slot.borrow().clone().into_iter().collect());
1610                entries.sort_by(|a, b| a.0.cmp(&b.0));
1611                entries
1612            },
1613            globals: || Vec::new(),
1614            assign: None,
1615            clear: None,
1616            remove: None,
1617        });
1618    }
1619
1620    fn set_workspace(entries: &[(&str, Value)]) {
1621        TEST_WORKSPACE.with(|slot| {
1622            let mut map = slot.borrow_mut();
1623            map.clear();
1624            for (name, value) in entries {
1625                map.insert((*name).to_string(), value.clone());
1626            }
1627        });
1628    }
1629
1630    fn workspace_guard() -> std::sync::MutexGuard<'static, ()> {
1631        crate::workspace::test_guard()
1632    }
1633
1634    fn assert_error_contains<T>(result: crate::BuiltinResult<T>, snippet: &str) {
1635        match result {
1636            Err(err) => {
1637                assert!(
1638                    err.message().contains(snippet),
1639                    "expected error to contain '{snippet}', got '{}'",
1640                    err.message()
1641                );
1642            }
1643            Ok(_) => panic!("expected error containing '{snippet}'"),
1644        }
1645    }
1646
1647    fn wrap_payload_as_compressed(mat_bytes: &[u8]) -> Vec<u8> {
1648        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
1649        encoder.write_all(&mat_bytes[MAT_HEADER_LEN..]).unwrap();
1650        let compressed = encoder.finish().unwrap();
1651
1652        let mut out = mat_bytes[..MAT_HEADER_LEN].to_vec();
1653        out.extend_from_slice(&MI_COMPRESSED.to_le_bytes());
1654        out.extend_from_slice(&(compressed.len() as u32).to_le_bytes());
1655        out.extend_from_slice(&compressed);
1656        out
1657    }
1658
1659    fn load_entries_from_bytes(bytes: Vec<u8>) -> Vec<(String, Value)> {
1660        let mut cursor = Cursor::new(bytes);
1661        read_mat_reader(&mut cursor).expect("read MAT bytes")
1662    }
1663
1664    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1665    #[test]
1666    fn load_descriptor_signatures_cover_core_forms() {
1667        let labels: Vec<&str> = LOAD_DESCRIPTOR
1668            .signatures
1669            .iter()
1670            .map(|sig| sig.label)
1671            .collect();
1672        assert!(labels.contains(&"S = load()"));
1673        assert!(labels.contains(&"S = load(filename)"));
1674        assert!(labels.contains(&"S = load(filename, varName1, varName2, ...)"));
1675        assert!(labels.contains(&"S = load(filename, \"-regexp\", pattern1, ...)"));
1676    }
1677
1678    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1679    #[test]
1680    fn load_roundtrip_numeric() {
1681        let _guard = workspace_guard();
1682        ensure_test_resolver();
1683        let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0], vec![2, 2]).unwrap();
1684        set_workspace(&[("A", Value::Tensor(tensor))]);
1685
1686        let dir = tempdir().unwrap();
1687        let path = dir.path().join("numeric.mat");
1688        let save_arg = Value::from(path.to_string_lossy().to_string());
1689        block_on(crate::call_builtin_async(
1690            "save",
1691            std::slice::from_ref(&save_arg),
1692        ))
1693        .unwrap();
1694
1695        let eval = block_on(evaluate(&[Value::from(path.to_string_lossy().to_string())]))
1696            .expect("load numeric");
1697        let struct_value = eval.first_output();
1698        match struct_value {
1699            Value::Struct(sv) => {
1700                assert!(sv.fields.contains_key("A"));
1701                match sv.fields.get("A").unwrap() {
1702                    Value::Tensor(t) => {
1703                        assert_eq!(t.shape, vec![2, 2]);
1704                        assert_eq!(t.data, vec![1.0, 4.0, 2.0, 5.0]);
1705                    }
1706                    other => panic!("expected tensor, got {other:?}"),
1707                }
1708            }
1709            other => panic!("expected struct, got {other:?}"),
1710        }
1711    }
1712
1713    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1714    #[test]
1715    fn load_selected_variables() {
1716        let _guard = workspace_guard();
1717        ensure_test_resolver();
1718        set_workspace(&[("signal", Value::Num(42.0)), ("noise", Value::Num(5.0))]);
1719        let dir = tempdir().unwrap();
1720        let path = dir.path().join("selection.mat");
1721        let save_arg = Value::from(path.to_string_lossy().to_string());
1722        block_on(crate::call_builtin_async(
1723            "save",
1724            std::slice::from_ref(&save_arg),
1725        ))
1726        .unwrap();
1727
1728        let eval = block_on(evaluate(&[
1729            Value::from(path.to_string_lossy().to_string()),
1730            Value::from("signal"),
1731        ]))
1732        .expect("load selection");
1733        let vars = eval.variables();
1734        assert_eq!(vars.len(), 1);
1735        assert_eq!(vars[0].0, "signal");
1736        assert!(matches!(vars[0].1, Value::Num(42.0)));
1737    }
1738
1739    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1740    #[test]
1741    fn load_regex_selection() {
1742        let _guard = workspace_guard();
1743        ensure_test_resolver();
1744        set_workspace(&[
1745            ("w1", Value::Num(1.0)),
1746            ("w2", Value::Num(2.0)),
1747            ("bias", Value::Num(3.0)),
1748        ]);
1749        let dir = tempdir().unwrap();
1750        let path = dir.path().join("regex.mat");
1751        let save_arg = Value::from(path.to_string_lossy().to_string());
1752        block_on(crate::call_builtin_async(
1753            "save",
1754            std::slice::from_ref(&save_arg),
1755        ))
1756        .unwrap();
1757
1758        let eval = block_on(evaluate(&[
1759            Value::from(path.to_string_lossy().to_string()),
1760            Value::from("-regexp"),
1761            Value::from("^w\\d$"),
1762        ]))
1763        .expect("load regex");
1764        let mut names: Vec<_> = eval.variables().iter().map(|(n, _)| n.clone()).collect();
1765        names.sort();
1766        assert_eq!(names, vec!["w1".to_string(), "w2".to_string()]);
1767    }
1768
1769    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1770    #[test]
1771    fn load_missing_variable_errors() {
1772        let _guard = workspace_guard();
1773        ensure_test_resolver();
1774        set_workspace(&[("existing", Value::Num(7.0))]);
1775        let dir = tempdir().unwrap();
1776        let path = dir.path().join("missing.mat");
1777        let save_arg = Value::from(path.to_string_lossy().to_string());
1778        block_on(crate::call_builtin_async(
1779            "save",
1780            std::slice::from_ref(&save_arg),
1781        ))
1782        .unwrap();
1783
1784        assert_error_contains(
1785            block_on(evaluate(&[
1786                Value::from(path.to_string_lossy().to_string()),
1787                Value::from("missing"),
1788            ])),
1789            "variable 'missing' was not found",
1790        );
1791    }
1792
1793    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1794    #[test]
1795    fn load_string_array_roundtrip() {
1796        let _guard = workspace_guard();
1797        ensure_test_resolver();
1798        let strings = StringArray::new(vec!["foo".into(), "bar".into()], vec![1, 2]).unwrap();
1799        set_workspace(&[("labels", Value::StringArray(strings))]);
1800        let dir = tempdir().unwrap();
1801        let path = dir.path().join("strings.mat");
1802        let save_arg = Value::from(path.to_string_lossy().to_string());
1803        block_on(crate::call_builtin_async(
1804            "save",
1805            std::slice::from_ref(&save_arg),
1806        ))
1807        .unwrap();
1808
1809        let eval = block_on(evaluate(&[Value::from(path.to_string_lossy().to_string())]))
1810            .expect("load strings");
1811        let struct_value = eval.first_output();
1812        match struct_value {
1813            Value::Struct(sv) => {
1814                let value = sv
1815                    .fields
1816                    .get("labels")
1817                    .expect("labels field missing in struct");
1818                match value {
1819                    Value::StringArray(sa) => {
1820                        assert_eq!(sa.shape, vec![1, 2]);
1821                        assert_eq!(sa.data, vec![String::from("foo"), String::from("bar")]);
1822                    }
1823                    other => panic!("expected string array, got {other:?}"),
1824                }
1825            }
1826            other => panic!("expected struct, got {other:?}"),
1827        }
1828    }
1829
1830    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1831    #[test]
1832    fn load_compressed_level5_payload() {
1833        let mat_bytes = block_on(encode_workspace_to_mat_bytes(&[
1834            ("A".to_string(), Value::Num(123.0)),
1835            ("B".to_string(), Value::from("text")),
1836        ]))
1837        .unwrap();
1838        let compressed = wrap_payload_as_compressed(&mat_bytes);
1839
1840        let entries = load_entries_from_bytes(compressed);
1841        assert_eq!(entries.len(), 2);
1842        assert_eq!(entries[0].0, "A");
1843        assert!(matches!(entries[0].1, Value::Num(123.0)));
1844        assert_eq!(entries[1].0, "B");
1845        match &entries[1].1 {
1846            Value::CharArray(ca) => assert_eq!(ca.data.iter().collect::<String>(), "text"),
1847            other => panic!("expected char array, got {other:?}"),
1848        }
1849    }
1850
1851    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1852    #[test]
1853    fn load_preserves_supported_numeric_mat_classes() {
1854        let single = Tensor::new_with_dtype(vec![1.5, 2.5], vec![1, 2], NumericDType::F32)
1855            .expect("single tensor");
1856        let uint16 = Tensor::new_with_dtype(vec![10.0, 20.0], vec![1, 2], NumericDType::U16)
1857            .expect("uint16 tensor");
1858        let bytes = block_on(encode_workspace_to_mat_bytes(&[
1859            ("s".to_string(), Value::Tensor(single)),
1860            ("u16".to_string(), Value::Tensor(uint16)),
1861            ("i".to_string(), Value::Int(IntValue::I16(-7))),
1862        ]))
1863        .unwrap();
1864
1865        let entries = load_entries_from_bytes(bytes);
1866        let values: HashMap<_, _> = entries.into_iter().collect();
1867        match values.get("s").unwrap() {
1868            Value::Tensor(t) => {
1869                assert_eq!(t.dtype, NumericDType::F32);
1870                assert_eq!(t.data, vec![1.5, 2.5]);
1871            }
1872            other => panic!("expected tensor, got {other:?}"),
1873        }
1874        match values.get("u16").unwrap() {
1875            Value::Tensor(t) => {
1876                assert_eq!(
1877                    t.integer_storage(),
1878                    Some(&IntegerStorage::U16(vec![10, 20]))
1879                );
1880                assert_eq!(t.data, vec![10.0, 20.0]);
1881            }
1882            other => panic!("expected tensor, got {other:?}"),
1883        }
1884        assert_eq!(values.get("i"), Some(&Value::Int(IntValue::I16(-7))));
1885    }
1886
1887    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1888    #[test]
1889    fn load_save_roundtrip_preserves_every_integer_tensor_class_exactly() {
1890        let cases = vec![
1891            ("i8", IntegerStorage::I8(vec![i8::MIN, i8::MAX])),
1892            ("u8", IntegerStorage::U8(vec![0, u8::MAX])),
1893            ("i16", IntegerStorage::I16(vec![i16::MIN, i16::MAX])),
1894            ("u16", IntegerStorage::U16(vec![0, u16::MAX])),
1895            ("i32", IntegerStorage::I32(vec![i32::MIN, i32::MAX])),
1896            ("u32", IntegerStorage::U32(vec![0, u32::MAX])),
1897            ("i64", IntegerStorage::I64(vec![i64::MIN, i64::MAX])),
1898            ("u64", IntegerStorage::U64(vec![1_u64 << 63, u64::MAX])),
1899        ];
1900        let mut entries = Vec::new();
1901        for (name, storage) in &cases {
1902            let tensor = Tensor::new_integer(storage.clone(), vec![2, 1]).expect("integer tensor");
1903            entries.push(((*name).to_string(), Value::Tensor(tensor)));
1904        }
1905        let empty = Tensor::new_integer(IntegerStorage::U64(Vec::new()), vec![0, 2])
1906            .expect("empty integer tensor");
1907        entries.push(("empty_u64".to_string(), Value::Tensor(empty)));
1908        entries.push((
1909            "scalar_u64".to_string(),
1910            Value::Int(IntValue::U64(u64::MAX)),
1911        ));
1912
1913        let bytes = block_on(encode_workspace_to_mat_bytes(&entries)).expect("encode MAT bytes");
1914        let values: HashMap<_, _> = load_entries_from_bytes(bytes).into_iter().collect();
1915
1916        for (name, storage) in cases {
1917            match values.get(name).expect("loaded integer tensor") {
1918                Value::Tensor(tensor) => assert_eq!(tensor.integer_storage(), Some(&storage)),
1919                other => panic!("expected tensor for {name}, got {other:?}"),
1920            }
1921        }
1922        match values.get("empty_u64").expect("loaded empty tensor") {
1923            Value::Tensor(tensor) => {
1924                assert_eq!(tensor.shape, vec![0, 2]);
1925                assert_eq!(
1926                    tensor.integer_storage(),
1927                    Some(&IntegerStorage::U64(Vec::new()))
1928                );
1929            }
1930            other => panic!("expected empty tensor, got {other:?}"),
1931        }
1932        assert_eq!(
1933            values.get("scalar_u64"),
1934            Some(&Value::Int(IntValue::U64(u64::MAX)))
1935        );
1936    }
1937
1938    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1939    #[test]
1940    fn load_save_roundtrip_preserves_nested_integer_tensors() {
1941        let unsigned =
1942            Tensor::new_integer(IntegerStorage::U64(vec![1_u64 << 63, u64::MAX]), vec![1, 2])
1943                .expect("unsigned tensor");
1944        let signed = Tensor::new_integer(IntegerStorage::I64(vec![i64::MIN, i64::MAX]), vec![2, 1])
1945            .expect("signed tensor");
1946        let cell = make_cell(
1947            vec![
1948                Value::Tensor(unsigned.clone()),
1949                Value::Tensor(signed.clone()),
1950            ],
1951            1,
1952            2,
1953        )
1954        .expect("cell");
1955        let mut nested = StructValue::new();
1956        nested.insert("unsigned", Value::Tensor(unsigned));
1957        nested.insert("values", cell);
1958
1959        let bytes = block_on(encode_workspace_to_mat_bytes(&[(
1960            "nested".to_string(),
1961            Value::Struct(nested),
1962        )]))
1963        .expect("encode MAT bytes");
1964        let values: HashMap<_, _> = load_entries_from_bytes(bytes).into_iter().collect();
1965
1966        let Value::Struct(nested) = values.get("nested").expect("nested struct") else {
1967            panic!("expected struct");
1968        };
1969        match nested.fields.get("unsigned") {
1970            Some(Value::Tensor(tensor)) => assert_eq!(
1971                tensor.integer_storage(),
1972                Some(&IntegerStorage::U64(vec![1_u64 << 63, u64::MAX]))
1973            ),
1974            other => panic!("expected unsigned tensor, got {other:?}"),
1975        }
1976        match nested.fields.get("values") {
1977            Some(Value::Cell(cell)) => {
1978                match &cell.data[0] {
1979                    Value::Tensor(tensor) => assert_eq!(
1980                        tensor.integer_storage(),
1981                        Some(&IntegerStorage::U64(vec![1_u64 << 63, u64::MAX]))
1982                    ),
1983                    other => panic!("expected unsigned cell tensor, got {other:?}"),
1984                }
1985                match &cell.data[1] {
1986                    Value::Tensor(tensor) => assert_eq!(
1987                        tensor.integer_storage(),
1988                        Some(&IntegerStorage::I64(vec![i64::MIN, i64::MAX]))
1989                    ),
1990                    other => panic!("expected signed cell tensor, got {other:?}"),
1991                }
1992            }
1993            other => panic!("expected cell, got {other:?}"),
1994        }
1995    }
1996
1997    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1998    #[test]
1999    fn load_save_real_sparse_roundtrip() {
2000        let sparse = SparseTensor::new(3, 3, vec![0, 1, 1, 3], vec![1, 0, 2], vec![4.0, 5.0, 6.0])
2001            .expect("sparse");
2002        let bytes = block_on(encode_workspace_to_mat_bytes(&[(
2003            "S".to_string(),
2004            Value::SparseTensor(sparse.clone()),
2005        )]))
2006        .unwrap();
2007
2008        let entries = load_entries_from_bytes(bytes);
2009        assert_eq!(entries.len(), 1);
2010        assert_eq!(entries[0].0, "S");
2011        assert_eq!(entries[0].1, Value::SparseTensor(sparse));
2012    }
2013
2014    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2015    #[test]
2016    fn load_option_before_filename() {
2017        let _guard = workspace_guard();
2018        ensure_test_resolver();
2019        set_workspace(&[("alpha", Value::Num(1.0)), ("beta", Value::Num(2.0))]);
2020        let dir = tempdir().unwrap();
2021        let path = dir.path().join("option_first.mat");
2022        let save_arg = Value::from(path.to_string_lossy().to_string());
2023        block_on(crate::call_builtin_async(
2024            "save",
2025            std::slice::from_ref(&save_arg),
2026        ))
2027        .unwrap();
2028
2029        let eval = block_on(evaluate(&[
2030            Value::from("-mat"),
2031            Value::from(path.to_string_lossy().to_string()),
2032            Value::from("beta"),
2033        ]))
2034        .expect("load with option first");
2035        let vars = eval.variables();
2036        assert_eq!(vars.len(), 1);
2037        assert_eq!(vars[0].0, "beta");
2038        assert!(matches!(vars[0].1, Value::Num(2.0)));
2039    }
2040
2041    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2042    #[test]
2043    fn load_char_array_names_trimmed() {
2044        let _guard = workspace_guard();
2045        ensure_test_resolver();
2046        set_workspace(&[("short", Value::Num(5.0)), ("longer", Value::Num(9.0))]);
2047        let dir = tempdir().unwrap();
2048        let path = dir.path().join("char_names.mat");
2049        let save_arg = Value::from(path.to_string_lossy().to_string());
2050        block_on(crate::call_builtin_async(
2051            "save",
2052            std::slice::from_ref(&save_arg),
2053        ))
2054        .unwrap();
2055
2056        let cols = 6;
2057        let mut data = Vec::new();
2058        for name in ["short", "longer"] {
2059            let mut chars: Vec<char> = name.chars().collect();
2060            while chars.len() < cols {
2061                chars.push(' ');
2062            }
2063            data.extend(chars);
2064        }
2065        let name_array = CharArray::new(data, 2, cols).unwrap();
2066
2067        let eval = block_on(evaluate(&[
2068            Value::from(path.to_string_lossy().to_string()),
2069            Value::CharArray(name_array),
2070        ]))
2071        .expect("load with char array names");
2072        let vars = eval.variables();
2073        assert_eq!(vars.len(), 2);
2074        assert_eq!(vars[0].0, "short");
2075        assert!(matches!(vars[0].1, Value::Num(5.0)));
2076        assert_eq!(vars[1].0, "longer");
2077        assert!(matches!(vars[1].1, Value::Num(9.0)));
2078    }
2079
2080    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2081    #[test]
2082    fn load_duplicate_names_last_wins() {
2083        let _guard = workspace_guard();
2084        ensure_test_resolver();
2085        set_workspace(&[("dup", Value::Num(11.0))]);
2086        let dir = tempdir().unwrap();
2087        let path = dir.path().join("duplicates.mat");
2088        let save_arg = Value::from(path.to_string_lossy().to_string());
2089        block_on(crate::call_builtin_async(
2090            "save",
2091            std::slice::from_ref(&save_arg),
2092        ))
2093        .unwrap();
2094
2095        let eval = block_on(evaluate(&[
2096            Value::from(path.to_string_lossy().to_string()),
2097            Value::from("dup"),
2098            Value::from("dup"),
2099        ]))
2100        .expect("load with duplicate names");
2101        let vars = eval.variables();
2102        assert_eq!(vars.len(), 1);
2103        assert_eq!(vars[0].0, "dup");
2104        assert!(matches!(vars[0].1, Value::Num(11.0)));
2105    }
2106
2107    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2108    #[test]
2109    #[cfg(feature = "wgpu")]
2110    fn load_wgpu_tensor_roundtrip() {
2111        let _guard = workspace_guard();
2112        ensure_test_resolver();
2113        if runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
2114            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
2115        )
2116        .is_err()
2117        {
2118            return;
2119        }
2120        let Some(provider) = runmat_accelerate_api::provider() else {
2121            return;
2122        };
2123
2124        use runmat_accelerate_api::HostTensorView;
2125
2126        let tensor = Tensor::new(vec![0.0, 1.0, 2.0, 3.0], vec![2, 2]).unwrap();
2127        let view = HostTensorView {
2128            data: &tensor.data,
2129            shape: &tensor.shape,
2130        };
2131        let handle = provider.upload(&view).expect("upload tensor");
2132        set_workspace(&[("gpu_var", Value::GpuTensor(handle))]);
2133
2134        let dir = tempdir().unwrap();
2135        let path = dir.path().join("wgpu_load.mat");
2136        let save_args = vec![
2137            Value::from(path.to_string_lossy().to_string()),
2138            Value::from("gpu_var"),
2139        ];
2140        block_on(crate::call_builtin_async("save", &save_args)).unwrap();
2141
2142        let eval = block_on(evaluate(&[Value::from(path.to_string_lossy().to_string())]))
2143            .expect("load wgpu file");
2144        let struct_value = eval.first_output();
2145        match struct_value {
2146            Value::Struct(sv) => match sv.fields.get("gpu_var") {
2147                Some(Value::Tensor(t)) => {
2148                    assert_eq!(t.shape, vec![2, 2]);
2149                    assert_eq!(t.data, tensor.data);
2150                }
2151                other => panic!("expected tensor, got {other:?}"),
2152            },
2153            other => panic!("expected struct, got {other:?}"),
2154        }
2155    }
2156}