Skip to main content

runmat_runtime/builtins/strings/transform/
strcat.rs

1//! MATLAB-compatible `strcat` builtin with GPU-aware semantics for RunMat.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6};
7use runmat_builtins::{BuiltinIntegerAuditDescriptor, BuiltinIntegerAuditKind};
8use runmat_macros::runtime_builtin;
9use runmat_value::{CellArray, CharArray, StringArray, Value};
10
11use crate::builtins::common::broadcast::{broadcast_index, broadcast_shapes, compute_strides};
12use crate::builtins::common::map_control_flow_with_builtin;
13use crate::builtins::common::spec::{
14    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
15    ReductionNaN, ResidencyPolicy, ShapeRequirements,
16};
17use crate::builtins::strings::common::{
18    char_row_to_string_slice, contains_numeric_or_resident_text_input, is_missing_string,
19};
20use crate::builtins::strings::type_resolvers::text_concat_type;
21use crate::{
22    build_runtime_error, gather_if_needed_async, make_cell_with_shape, BuiltinResult, RuntimeError,
23};
24
25#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::transform::strcat")]
26pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
27    name: "strcat",
28    op_kind: GpuOpKind::Custom("string-transform"),
29    supported_precisions: &[],
30    broadcast: BroadcastSemantics::Matlab,
31    provider_hooks: &[],
32    constant_strategy: ConstantStrategy::InlineLiteral,
33    residency: ResidencyPolicy::GatherImmediately,
34    nan_mode: ReductionNaN::Include,
35    two_pass_threshold: None,
36    workgroup_size: None,
37    accepts_nan_mode: false,
38    notes: "Executes on the CPU with trailing-space trimming; GPU inputs are gathered before concatenation.",
39};
40
41#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::transform::strcat")]
42pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
43    name: "strcat",
44    shape: ShapeRequirements::BroadcastCompatible,
45    constant_strategy: ConstantStrategy::InlineLiteral,
46    elementwise: None,
47    reduction: None,
48    emits_nan: false,
49    notes: "String concatenation runs on the host and is not eligible for fusion.",
50};
51
52const BUILTIN_NAME: &str = "strcat";
53
54const STRCAT_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
55    name: "out",
56    ty: BuiltinParamType::Any,
57    arity: BuiltinParamArity::Required,
58    default: None,
59    description: "Concatenated text preserving strcat output container semantics.",
60}];
61
62const STRCAT_INPUTS: [BuiltinParamDescriptor; 2] = [
63    BuiltinParamDescriptor {
64        name: "str1",
65        ty: BuiltinParamType::Any,
66        arity: BuiltinParamArity::Required,
67        default: None,
68        description: "First text input (string/char/cell).",
69    },
70    BuiltinParamDescriptor {
71        name: "str2",
72        ty: BuiltinParamType::Any,
73        arity: BuiltinParamArity::Variadic,
74        default: None,
75        description: "Additional text inputs to concatenate element-wise.",
76    },
77];
78
79const STRCAT_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
80    label: "out = strcat(str1, str2, ...)",
81    inputs: &STRCAT_INPUTS,
82    outputs: &STRCAT_OUTPUT,
83}];
84
85const STRCAT_ERROR_NOT_ENOUGH_INPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
86    code: "RM.STRCAT.NOT_ENOUGH_INPUTS",
87    identifier: Some("RunMat:strcat:NotEnoughInputs"),
88    when: "No arguments are supplied.",
89    message: "strcat: not enough input arguments",
90};
91
92const STRCAT_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
93    code: "RM.STRCAT.INVALID_INPUT",
94    identifier: Some("RunMat:strcat:InvalidInput"),
95    when: "An input is not a string, character array, or cell array of text scalars.",
96    message:
97        "strcat: inputs must be strings, character arrays, or cell arrays of character vectors",
98};
99
100const STRCAT_ERROR_INVALID_CELL_ELEMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
101    code: "RM.STRCAT.CELL_ELEMENT",
102    identifier: Some("RunMat:strcat:CellElement"),
103    when: "A cell array contains a non-text element or non-row char array element.",
104    message: "strcat: cell array elements must be character vectors or string scalars",
105};
106
107const STRCAT_ERROR_SIZE_MISMATCH: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
108    code: "RM.STRCAT.SIZE_MISMATCH",
109    identifier: Some("RunMat:strcat:SizeMismatch"),
110    when: "Input shapes are not broadcast-compatible.",
111    message: "strcat: array sizes are not compatible for broadcasting",
112};
113
114const STRCAT_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
115    code: "RM.STRCAT.INTERNAL",
116    identifier: Some("RunMat:strcat:InternalError"),
117    when: "Internal output container construction failed.",
118    message: "strcat: internal error",
119};
120
121const STRCAT_ERRORS: [BuiltinErrorDescriptor; 5] = [
122    STRCAT_ERROR_NOT_ENOUGH_INPUTS,
123    STRCAT_ERROR_INVALID_INPUT,
124    STRCAT_ERROR_INVALID_CELL_ELEMENT,
125    STRCAT_ERROR_SIZE_MISMATCH,
126    STRCAT_ERROR_INTERNAL,
127];
128
129pub const STRCAT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
130    signatures: &STRCAT_SIGNATURES,
131    output_mode: BuiltinOutputMode::Fixed,
132    completion_policy: BuiltinCompletionPolicy::Public,
133    errors: &STRCAT_ERRORS,
134};
135
136pub const STRCAT_INTEGER_AUDIT: BuiltinIntegerAuditDescriptor = BuiltinIntegerAuditDescriptor {
137    kind: BuiltinIntegerAuditKind::NotApplicable,
138    canonical_builtin: None,
139    notes: "strcat concatenates character arrays, string arrays, and cell arrays of character vectors. Numeric, integer, and provider-resident values reject before provider access without implicit text conversion.",
140};
141
142fn map_flow(err: RuntimeError) -> RuntimeError {
143    map_control_flow_with_builtin(err, BUILTIN_NAME)
144}
145
146fn strcat_error_with_message(
147    message: impl Into<String>,
148    error: &'static BuiltinErrorDescriptor,
149) -> RuntimeError {
150    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
151    if let Some(identifier) = error.identifier {
152        builder = builder.with_identifier(identifier);
153    }
154    builder.build()
155}
156
157fn strcat_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
158    strcat_error_with_message(error.message, error)
159}
160
161#[derive(Clone, Copy, PartialEq, Eq)]
162enum OperandKind {
163    String,
164    Cell,
165    Char,
166}
167
168#[derive(Clone)]
169struct TextElement {
170    text: String,
171    missing: bool,
172}
173
174#[derive(Clone)]
175struct TextOperand {
176    data: Vec<TextElement>,
177    shape: Vec<usize>,
178    strides: Vec<usize>,
179    kind: OperandKind,
180}
181
182impl TextOperand {
183    fn from_value(value: Value) -> BuiltinResult<Self> {
184        match value {
185            Value::String(s) => Ok(Self::from_string_scalar(s)),
186            Value::StringArray(sa) => Ok(Self::from_string_array(sa)),
187            Value::CharArray(ca) => Self::from_char_array(&ca),
188            Value::Cell(ca) => Self::from_cell_array(&ca),
189            _ => Err(strcat_error(&STRCAT_ERROR_INVALID_INPUT)),
190        }
191    }
192
193    fn from_string_scalar(text: String) -> Self {
194        let missing = is_missing_string(&text);
195        Self {
196            data: vec![TextElement { text, missing }],
197            shape: vec![1, 1],
198            strides: vec![1, 1],
199            kind: OperandKind::String,
200        }
201    }
202
203    fn from_string_array(array: StringArray) -> Self {
204        let missing_flags: Vec<bool> = array.data.iter().map(|s| is_missing_string(s)).collect();
205        let data = array
206            .data
207            .into_iter()
208            .zip(missing_flags)
209            .map(|(text, missing)| TextElement { text, missing })
210            .collect();
211        let shape = array.shape.clone();
212        let strides = compute_strides(&shape);
213        Self {
214            data,
215            shape,
216            strides,
217            kind: OperandKind::String,
218        }
219    }
220
221    fn from_char_array(array: &CharArray) -> BuiltinResult<Self> {
222        let rows = array.rows;
223        let cols = array.cols;
224        let mut elements = Vec::with_capacity(rows);
225        for row in 0..rows {
226            let text = char_row_to_string_slice(&array.data, cols, row);
227            let trimmed = trim_trailing_spaces(&text);
228            elements.push(TextElement {
229                text: trimmed,
230                missing: false,
231            });
232        }
233        let shape = vec![rows, 1];
234        let strides = compute_row_major_strides(&shape);
235        Ok(Self {
236            data: elements,
237            shape,
238            strides,
239            kind: OperandKind::Char,
240        })
241    }
242
243    fn from_cell_array(array: &CellArray) -> BuiltinResult<Self> {
244        let total = array.data.len();
245        let mut elements = Vec::with_capacity(total);
246        for handle in &array.data {
247            let text_element = cell_element_to_text(handle)?;
248            elements.push(text_element);
249        }
250        let shape = array.shape.clone();
251        let strides = compute_row_major_strides(&shape);
252        Ok(Self {
253            data: elements,
254            shape,
255            strides,
256            kind: OperandKind::Cell,
257        })
258    }
259}
260
261#[derive(Clone, Copy, PartialEq, Eq)]
262enum OutputKind {
263    Char,
264    Cell,
265    String,
266}
267
268impl OutputKind {
269    fn update(self, operand_kind: OperandKind) -> Self {
270        match (self, operand_kind) {
271            (_, OperandKind::String) => OutputKind::String,
272            (OutputKind::String, _) => OutputKind::String,
273            (OutputKind::Cell, _) => OutputKind::Cell,
274            (_, OperandKind::Cell) => OutputKind::Cell,
275            _ => self,
276        }
277    }
278}
279
280fn trim_trailing_spaces(text: &str) -> String {
281    text.trim_end_matches(|ch: char| ch.is_ascii_whitespace())
282        .to_string()
283}
284
285fn compute_row_major_strides(shape: &[usize]) -> Vec<usize> {
286    if shape.is_empty() {
287        return Vec::new();
288    }
289    let mut strides = vec![0usize; shape.len()];
290    let mut stride = 1usize;
291    for dim in (0..shape.len()).rev() {
292        strides[dim] = stride;
293        let extent = shape[dim].max(1);
294        stride = stride.saturating_mul(extent);
295    }
296    strides
297}
298
299fn column_major_coords(mut index: usize, shape: &[usize]) -> Vec<usize> {
300    if shape.is_empty() {
301        return Vec::new();
302    }
303    let mut coords = Vec::with_capacity(shape.len());
304    for &extent in shape {
305        if extent == 0 {
306            coords.push(0);
307        } else {
308            coords.push(index % extent);
309            index /= extent;
310        }
311    }
312    coords
313}
314
315fn row_major_index(coords: &[usize], shape: &[usize]) -> usize {
316    if coords.is_empty() {
317        return 0;
318    }
319    let mut index = 0usize;
320    let mut stride = 1usize;
321    for dim in (0..coords.len()).rev() {
322        let extent = shape[dim].max(1);
323        index += coords[dim] * stride;
324        stride = stride.saturating_mul(extent);
325    }
326    index
327}
328
329fn cell_element_to_text(value: &Value) -> BuiltinResult<TextElement> {
330    match value {
331        Value::String(s) => Ok(TextElement {
332            text: s.clone(),
333            missing: is_missing_string(s),
334        }),
335        Value::StringArray(sa) if sa.data.len() == 1 => {
336            let text = sa.data[0].clone();
337            Ok(TextElement {
338                missing: is_missing_string(&text),
339                text,
340            })
341        }
342        Value::CharArray(ca) if ca.rows <= 1 => {
343            let text = if ca.rows == 0 {
344                String::new()
345            } else {
346                char_row_to_string_slice(&ca.data, ca.cols, 0)
347            };
348            Ok(TextElement {
349                text: trim_trailing_spaces(&text),
350                missing: false,
351            })
352        }
353        Value::CharArray(_) => Err(strcat_error(&STRCAT_ERROR_INVALID_CELL_ELEMENT)),
354        _ => Err(strcat_error(&STRCAT_ERROR_INVALID_CELL_ELEMENT)),
355    }
356}
357
358#[runtime_builtin(
359    name = "strcat",
360    category = "strings/transform",
361    summary = "Concatenate text inputs element-wise across compatible array sizes.",
362    keywords = "strcat,string concatenation,character arrays,cell arrays",
363    accel = "sink",
364    type_resolver(text_concat_type),
365    descriptor(crate::builtins::strings::transform::strcat::STRCAT_DESCRIPTOR),
366    integer_audit(crate::builtins::strings::transform::strcat::STRCAT_INTEGER_AUDIT),
367    builtin_path = "crate::builtins::strings::transform::strcat"
368)]
369async fn strcat_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
370    if rest.is_empty() {
371        return Err(strcat_error(&STRCAT_ERROR_NOT_ENOUGH_INPUTS));
372    }
373
374    let mut operands = Vec::with_capacity(rest.len());
375    let mut output_kind = OutputKind::Char;
376
377    for value in rest {
378        if contains_numeric_or_resident_text_input(&value) {
379            return Err(strcat_error(&STRCAT_ERROR_INVALID_INPUT));
380        }
381        let gathered = gather_if_needed_async(&value).await.map_err(map_flow)?;
382        let operand = TextOperand::from_value(gathered)?;
383        output_kind = output_kind.update(operand.kind);
384        operands.push(operand);
385    }
386
387    let mut output_shape = operands
388        .first()
389        .map(|op| op.shape.clone())
390        .unwrap_or_else(|| vec![1, 1]);
391    for operand in operands.iter().skip(1) {
392        output_shape =
393            broadcast_shapes(BUILTIN_NAME, &output_shape, &operand.shape).map_err(|e| {
394                strcat_error_with_message(
395                    format!("{}: {e}", STRCAT_ERROR_SIZE_MISMATCH.message),
396                    &STRCAT_ERROR_SIZE_MISMATCH,
397                )
398            })?;
399    }
400
401    let total_len: usize = output_shape.iter().product();
402    let mut concatenated = Vec::with_capacity(total_len);
403
404    for linear in 0..total_len {
405        let mut buffer = String::new();
406        let mut any_missing = false;
407        for operand in &operands {
408            let idx = broadcast_index(linear, &output_shape, &operand.shape, &operand.strides);
409            let element = &operand.data[idx];
410            if output_kind == OutputKind::String && element.missing {
411                any_missing = true;
412                continue;
413            }
414            buffer.push_str(&element.text);
415        }
416        if matches!(output_kind, OutputKind::String) && any_missing {
417            concatenated.push(String::from("<missing>"));
418        } else {
419            concatenated.push(buffer);
420        }
421    }
422
423    match output_kind {
424        OutputKind::String => build_string_output(concatenated, &output_shape),
425        OutputKind::Cell => build_cell_output(concatenated, &output_shape),
426        OutputKind::Char => build_char_output(concatenated),
427    }
428}
429
430fn build_string_output(data: Vec<String>, shape: &[usize]) -> BuiltinResult<Value> {
431    if data.is_empty() {
432        let array = StringArray::new(data, shape.to_vec()).map_err(|e| {
433            strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
434        })?;
435        return Ok(Value::StringArray(array));
436    }
437
438    let is_scalar = shape.is_empty() || shape.iter().all(|&dim| dim == 1);
439    if is_scalar {
440        return Ok(Value::String(data[0].clone()));
441    }
442
443    let array = StringArray::new(data, shape.to_vec()).map_err(|e| {
444        strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
445    })?;
446    Ok(Value::StringArray(array))
447}
448
449fn build_cell_output(mut data: Vec<String>, shape: &[usize]) -> BuiltinResult<Value> {
450    if data.is_empty() {
451        return make_cell_with_shape(Vec::new(), shape.to_vec()).map_err(|e| {
452            strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
453        });
454    }
455    if shape.len() > 1 {
456        let mut reordered = vec![String::new(); data.len()];
457        for (cm_index, text) in data.into_iter().enumerate() {
458            let coords = column_major_coords(cm_index, shape);
459            let rm_index = row_major_index(&coords, shape);
460            reordered[rm_index] = text;
461        }
462        data = reordered;
463    }
464    let mut values = Vec::with_capacity(data.len());
465    for text in data {
466        let char_array = CharArray::new_row(&text);
467        values.push(Value::CharArray(char_array));
468    }
469    make_cell_with_shape(values, shape.to_vec()).map_err(|e| {
470        strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
471    })
472}
473
474fn build_char_output(data: Vec<String>) -> BuiltinResult<Value> {
475    let rows = data.len();
476    if rows == 0 {
477        let array = CharArray::new(Vec::new(), 0, 0).map_err(|e| {
478            strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
479        })?;
480        return Ok(Value::CharArray(array));
481    }
482
483    let max_cols = data.iter().map(|s| s.chars().count()).max().unwrap_or(0);
484    let mut chars = Vec::with_capacity(rows * max_cols);
485    for text in data {
486        let mut row_chars: Vec<char> = text.chars().collect();
487        if row_chars.len() < max_cols {
488            row_chars.resize(max_cols, ' ');
489        }
490        chars.extend(row_chars.into_iter());
491    }
492    let array = CharArray::new(chars, rows, max_cols).map_err(|e| {
493        strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
494    })?;
495    Ok(Value::CharArray(array))
496}
497
498#[cfg(test)]
499pub(crate) mod tests {
500    use super::*;
501    #[cfg(feature = "wgpu")]
502    use crate::builtins::common::test_support;
503    use runmat_builtins::{ResolveContext, Type};
504    #[cfg(feature = "wgpu")]
505    use runmat_value::Tensor;
506    use runmat_value::{CellArray, CharArray, IntValue, StringArray};
507
508    fn run_strcat(rest: Vec<Value>) -> BuiltinResult<Value> {
509        futures::executor::block_on(strcat_builtin(rest))
510    }
511
512    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
513    #[test]
514    fn strcat_string_scalar_concatenation() {
515        let result = run_strcat(vec![
516            Value::String("Run".into()),
517            Value::String("Mat".into()),
518        ])
519        .expect("strcat");
520        assert_eq!(result, Value::String("RunMat".into()));
521    }
522
523    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
524    #[test]
525    fn strcat_string_array_broadcasts_scalar() {
526        let array = StringArray::new(vec!["core".into(), "runtime".into()], vec![1, 2]).unwrap();
527        let result = run_strcat(vec![
528            Value::String("runmat-".into()),
529            Value::StringArray(array),
530        ])
531        .expect("strcat");
532        match result {
533            Value::StringArray(sa) => {
534                assert_eq!(sa.shape, vec![1, 2]);
535                assert_eq!(
536                    sa.data,
537                    vec![String::from("runmat-core"), String::from("runmat-runtime")]
538                );
539            }
540            other => panic!("expected string array, got {other:?}"),
541        }
542    }
543
544    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
545    #[test]
546    fn strcat_char_array_multiple_rows_concatenates_per_row() {
547        let first = CharArray::new(vec!['A', ' ', 'B', 'C'], 2, 2).expect("char");
548        let second = CharArray::new(vec!['X', 'Y', 'Z', ' '], 2, 2).expect("char");
549        let result =
550            run_strcat(vec![Value::CharArray(first), Value::CharArray(second)]).expect("strcat");
551        match result {
552            Value::CharArray(ca) => {
553                assert_eq!(ca.rows, 2);
554                assert_eq!(ca.cols, 3);
555                let expected: Vec<char> = vec!['A', 'X', 'Y', 'B', 'C', 'Z'];
556                assert_eq!(ca.data, expected);
557            }
558            other => panic!("expected char array, got {other:?}"),
559        }
560    }
561
562    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
563    #[test]
564    fn strcat_char_array_trims_trailing_spaces() {
565        let first = CharArray::new_row("GPU ");
566        let second = CharArray::new_row(" Accel  ");
567        let result =
568            run_strcat(vec![Value::CharArray(first), Value::CharArray(second)]).expect("strcat");
569        match result {
570            Value::CharArray(ca) => {
571                assert_eq!(ca.rows, 1);
572                assert_eq!(ca.cols, 9);
573                let expected: Vec<char> = "GPU Accel".chars().collect();
574                assert_eq!(ca.data, expected);
575            }
576            other => panic!("expected char array, got {other:?}"),
577        }
578    }
579
580    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
581    #[test]
582    fn strcat_mixed_char_and_string_returns_string_array() {
583        let prefixes = CharArray::new(vec!['A', ' ', 'B', ' '], 2, 2).expect("char");
584        let suffixes =
585            StringArray::new(vec!["core".into(), "runtime".into()], vec![1, 2]).expect("strings");
586        let result = run_strcat(vec![
587            Value::CharArray(prefixes),
588            Value::StringArray(suffixes),
589        ])
590        .expect("strcat");
591        match result {
592            Value::StringArray(sa) => {
593                assert_eq!(sa.shape, vec![2, 2]);
594                assert_eq!(
595                    sa.data,
596                    vec![
597                        "Acore".to_string(),
598                        "Bcore".to_string(),
599                        "Aruntime".to_string(),
600                        "Bruntime".to_string()
601                    ]
602                );
603            }
604            other => panic!("expected string array, got {other:?}"),
605        }
606    }
607
608    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
609    #[test]
610    fn strcat_cell_array_trims_trailing_spaces() {
611        let cell = make_cell_with_shape(
612            vec![
613                Value::CharArray(CharArray::new_row("Run ")),
614                Value::CharArray(CharArray::new_row("Mat ")),
615            ],
616            vec![1, 2],
617        )
618        .expect("cell");
619        let suffix = Value::CharArray(CharArray::new_row("Core "));
620        let result = run_strcat(vec![cell, suffix]).expect("strcat");
621        match result {
622            Value::Cell(ca) => {
623                assert_eq!(ca.shape, vec![1, 2]);
624                let first: &Value = &ca.data[0];
625                let second: &Value = &ca.data[1];
626                match (first, second) {
627                    (Value::CharArray(a), Value::CharArray(b)) => {
628                        assert_eq!(a.data, "RunCore".chars().collect::<Vec<char>>());
629                        assert_eq!(b.data, "MatCore".chars().collect::<Vec<char>>());
630                    }
631                    other => panic!("unexpected cell contents {other:?}"),
632                }
633            }
634            other => panic!("expected cell array, got {other:?}"),
635        }
636    }
637
638    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
639    #[test]
640    fn strcat_cell_array_two_by_two_preserves_row_major_order() {
641        let cell = make_cell_with_shape(
642            vec![
643                Value::CharArray(CharArray::new_row("Top ")),
644                Value::CharArray(CharArray::new_row("Right ")),
645                Value::CharArray(CharArray::new_row("Bottom ")),
646                Value::CharArray(CharArray::new_row("Last ")),
647            ],
648            vec![2, 2],
649        )
650        .expect("cell");
651        let suffix = Value::CharArray(CharArray::new_row("X"));
652        let result = run_strcat(vec![cell, suffix]).expect("strcat");
653        match result {
654            Value::Cell(ca) => {
655                assert_eq!(ca.shape, vec![2, 2]);
656                let v00 = ca.get(0, 0).expect("cell (0,0)");
657                let v01 = ca.get(0, 1).expect("cell (0,1)");
658                let v10 = ca.get(1, 0).expect("cell (1,0)");
659                let v11 = ca.get(1, 1).expect("cell (1,1)");
660                match (v00, v01, v10, v11) {
661                    (
662                        Value::CharArray(a),
663                        Value::CharArray(b),
664                        Value::CharArray(c),
665                        Value::CharArray(d),
666                    ) => {
667                        assert_eq!(a.data, "TopX".chars().collect::<Vec<char>>());
668                        assert_eq!(b.data, "RightX".chars().collect::<Vec<char>>());
669                        assert_eq!(c.data, "BottomX".chars().collect::<Vec<char>>());
670                        assert_eq!(d.data, "LastX".chars().collect::<Vec<char>>());
671                    }
672                    other => panic!("unexpected cell contents {other:?}"),
673                }
674            }
675            other => panic!("expected cell array, got {other:?}"),
676        }
677    }
678
679    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
680    #[test]
681    fn strcat_missing_strings_propagate() {
682        let array = StringArray::new(
683            vec![String::from("<missing>"), String::from("ready")],
684            vec![1, 2],
685        )
686        .unwrap();
687        let result = run_strcat(vec![
688            Value::String("job-".into()),
689            Value::StringArray(array),
690        ])
691        .expect("strcat");
692        match result {
693            Value::StringArray(sa) => {
694                assert_eq!(sa.data[0], "<missing>");
695                assert_eq!(sa.data[1], "job-ready");
696            }
697            other => panic!("expected string array, got {other:?}"),
698        }
699    }
700
701    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
702    #[test]
703    fn strcat_empty_dimension_returns_empty_array() {
704        let empty = StringArray::new(Vec::<String>::new(), vec![0, 2]).expect("string array");
705        let result = run_strcat(vec![
706            Value::StringArray(empty),
707            Value::String("prefix".into()),
708        ])
709        .expect("strcat");
710        match result {
711            Value::StringArray(sa) => {
712                assert_eq!(sa.shape, vec![0, 2]);
713                assert!(sa.data.is_empty());
714            }
715            other => panic!("expected empty string array, got {other:?}"),
716        }
717    }
718
719    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
720    #[test]
721    fn strcat_errors_on_invalid_input_type() {
722        let err = run_strcat(vec![Value::Int(IntValue::I32(4))]).expect_err("expected error");
723        assert_eq!(err.to_string(), STRCAT_ERROR_INVALID_INPUT.message);
724        assert_eq!(
725            err.identifier.as_deref(),
726            STRCAT_ERROR_INVALID_INPUT.identifier
727        );
728    }
729
730    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
731    #[test]
732    fn strcat_errors_on_mismatched_sizes() {
733        let left = CharArray::new(vec!['A', 'B'], 2, 1).expect("char");
734        let right = CharArray::new(vec!['C', 'D', 'E'], 3, 1).expect("char");
735        let err = run_strcat(vec![Value::CharArray(left), Value::CharArray(right)])
736            .expect_err("expected broadcast error");
737        assert!(err
738            .to_string()
739            .starts_with(STRCAT_ERROR_SIZE_MISMATCH.message));
740        assert_eq!(
741            err.identifier.as_deref(),
742            STRCAT_ERROR_SIZE_MISMATCH.identifier
743        );
744    }
745
746    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
747    #[test]
748    fn strcat_errors_on_invalid_cell_element() {
749        let cell = CellArray::new(vec![Value::Num(1.0)], 1, 1).expect("cell");
750        let err = run_strcat(vec![Value::Cell(cell)]).expect_err("expected error");
751        assert_eq!(err.to_string(), STRCAT_ERROR_INVALID_CELL_ELEMENT.message);
752        assert_eq!(
753            err.identifier.as_deref(),
754            STRCAT_ERROR_INVALID_CELL_ELEMENT.identifier
755        );
756    }
757
758    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
759    #[test]
760    fn strcat_errors_on_empty_argument_list() {
761        let err = run_strcat(Vec::new()).expect_err("expected error");
762        assert_eq!(err.to_string(), STRCAT_ERROR_NOT_ENOUGH_INPUTS.message);
763        assert_eq!(
764            err.identifier.as_deref(),
765            STRCAT_ERROR_NOT_ENOUGH_INPUTS.identifier
766        );
767    }
768
769    #[cfg(feature = "wgpu")]
770    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
771    #[test]
772    fn strcat_gpu_operand_still_errors_on_type() {
773        test_support::with_test_provider(|provider| {
774            let tensor = Tensor::new(vec![1.0, 2.0], vec![1, 2]).expect("tensor");
775            let view = runmat_accelerate_api::HostTensorView {
776                data: &tensor.materialize_f64(),
777                shape: &tensor.shape,
778            };
779            let handle = provider.upload(&view).expect("upload");
780            let err = run_strcat(vec![Value::GpuTensor(handle)]).expect_err("expected error");
781            assert_eq!(err.to_string(), STRCAT_ERROR_INVALID_INPUT.message);
782        });
783    }
784
785    #[test]
786    fn strcat_type_concatenates_text() {
787        assert_eq!(
788            text_concat_type(&[Type::String], &ResolveContext::new(Vec::new())),
789            Type::String
790        );
791    }
792}