Skip to main content

runmat_runtime/builtins/io/tabular/
writecell.rs

1//! MATLAB-compatible `writecell` builtin for heterogeneous cell-array export.
2
3use std::collections::HashMap;
4use std::io::{Cursor, Read, Seek, SeekFrom, Write};
5use std::path::{Component, Path, PathBuf};
6use std::sync::{Arc, Mutex as StdMutex, OnceLock, Weak};
7
8use futures::lock::Mutex as AsyncMutex;
9use runmat_builtins::{
10    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
11    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
12    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
13    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
14    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
15    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
16};
17use runmat_filesystem::{File, OpenOptions};
18use runmat_macros::runtime_builtin;
19use runmat_value::{CellArray, IntValue, Value};
20
21use crate::builtins::common::fs::expand_user_path;
22use crate::builtins::common::spec::{
23    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
24    ReductionNaN, ResidencyPolicy, ShapeRequirements,
25};
26use crate::builtins::common::tensor;
27use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
28
29const BUILTIN_NAME: &str = "writecell";
30const MAX_EXCEL_ROW_INDEX: usize = 1_048_575;
31const MAX_EXCEL_COLUMN_INDEX: usize = 16_383;
32pub(super) type WriteLock = Arc<AsyncMutex<()>>;
33type WeakWriteLock = Weak<AsyncMutex<()>>;
34static WRITE_LOCKS: OnceLock<StdMutex<HashMap<String, WeakWriteLock>>> = OnceLock::new();
35
36const WRITECELL_NO_OUTPUT: [BuiltinParamDescriptor; 0] = [];
37const WRITECELL_INPUTS_CELL_FILENAME: [BuiltinParamDescriptor; 2] = [
38    BuiltinParamDescriptor {
39        name: "C",
40        ty: BuiltinParamType::Any,
41        arity: BuiltinParamArity::Required,
42        default: None,
43        description: "Cell array to write.",
44    },
45    BuiltinParamDescriptor {
46        name: "filename",
47        ty: BuiltinParamType::StringScalar,
48        arity: BuiltinParamArity::Required,
49        default: None,
50        description: "Output file path.",
51    },
52];
53const WRITECELL_INPUTS_NAME_VALUE: [BuiltinParamDescriptor; 4] = [
54    BuiltinParamDescriptor {
55        name: "C",
56        ty: BuiltinParamType::Any,
57        arity: BuiltinParamArity::Required,
58        default: None,
59        description: "Cell array to write.",
60    },
61    BuiltinParamDescriptor {
62        name: "filename",
63        ty: BuiltinParamType::StringScalar,
64        arity: BuiltinParamArity::Required,
65        default: None,
66        description: "Output file path.",
67    },
68    BuiltinParamDescriptor {
69        name: "name",
70        ty: BuiltinParamType::StringScalar,
71        arity: BuiltinParamArity::Required,
72        default: None,
73        description: "Option name.",
74    },
75    BuiltinParamDescriptor {
76        name: "optionValue",
77        ty: BuiltinParamType::Any,
78        arity: BuiltinParamArity::Required,
79        default: None,
80        description: "Value for the preceding option name.",
81    },
82];
83const WRITECELL_INPUTS_NAME_VALUE_PAIRS: [BuiltinParamDescriptor; 3] = [
84    BuiltinParamDescriptor {
85        name: "C",
86        ty: BuiltinParamType::Any,
87        arity: BuiltinParamArity::Required,
88        default: None,
89        description: "Cell array to write.",
90    },
91    BuiltinParamDescriptor {
92        name: "filename",
93        ty: BuiltinParamType::StringScalar,
94        arity: BuiltinParamArity::Required,
95        default: None,
96        description: "Output file path.",
97    },
98    BuiltinParamDescriptor {
99        name: "nameValuePairs...",
100        ty: BuiltinParamType::Any,
101        arity: BuiltinParamArity::Variadic,
102        default: None,
103        description: "Name-value option pairs.",
104    },
105];
106const WRITECELL_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
107    BuiltinSignatureDescriptor {
108        label: "writecell(C, filename)",
109        inputs: &WRITECELL_INPUTS_CELL_FILENAME,
110        outputs: &WRITECELL_NO_OUTPUT,
111    },
112    BuiltinSignatureDescriptor {
113        label: "writecell(C, filename, name, optionValue)",
114        inputs: &WRITECELL_INPUTS_NAME_VALUE,
115        outputs: &WRITECELL_NO_OUTPUT,
116    },
117    BuiltinSignatureDescriptor {
118        label: "writecell(C, filename, nameValuePairs...)",
119        inputs: &WRITECELL_INPUTS_NAME_VALUE_PAIRS,
120        outputs: &WRITECELL_NO_OUTPUT,
121    },
122];
123
124const WRITECELL_ERROR_ARG_CONFIG: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
125    code: "RM.WRITECELL.ARG_CONFIG",
126    identifier: None,
127    when: "Filename argument is missing or name-value options are malformed.",
128    message: "writecell: invalid argument configuration",
129};
130const WRITECELL_ERROR_FILENAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
131    code: "RM.WRITECELL.FILENAME",
132    identifier: None,
133    when: "Filename is not a valid scalar path string.",
134    message: "writecell: invalid filename input",
135};
136const WRITECELL_ERROR_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
137    code: "RM.WRITECELL.OPTION",
138    identifier: None,
139    when: "A provided option value is invalid.",
140    message: "writecell: invalid option value",
141};
142const WRITECELL_ERROR_DATA: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
143    code: "RM.WRITECELL.DATA",
144    identifier: None,
145    when: "Input data cannot be converted into supported cell export rows.",
146    message: "writecell: invalid input data",
147};
148const WRITECELL_ERROR_DATA_SHAPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
149    code: "RM.WRITECELL.DATA_SHAPE",
150    identifier: None,
151    when: "Input cell array has unsupported dimensionality.",
152    message: "writecell: input must be 2-D",
153};
154const WRITECELL_ERROR_IO: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
155    code: "RM.WRITECELL.IO",
156    identifier: None,
157    when: "The destination file cannot be opened or written.",
158    message: "writecell: file write failed",
159};
160const WRITECELL_ERRORS: [BuiltinErrorDescriptor; 6] = [
161    WRITECELL_ERROR_ARG_CONFIG,
162    WRITECELL_ERROR_FILENAME,
163    WRITECELL_ERROR_OPTION,
164    WRITECELL_ERROR_DATA,
165    WRITECELL_ERROR_DATA_SHAPE,
166    WRITECELL_ERROR_IO,
167];
168
169pub const WRITECELL_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
170    signatures: &WRITECELL_SIGNATURES,
171    output_mode: BuiltinOutputMode::Fixed,
172    completion_policy: BuiltinCompletionPolicy::Public,
173    errors: &WRITECELL_ERRORS,
174};
175
176const WRITECELL_EXPLICIT_GPU_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
177    id: "writecell-explicit-gpu-input",
178    mode: BuiltinExtensionMode::RunMatOnly,
179    description: "writecell with explicit gpuArray input is a RunMat extension",
180    error_identifier: Some("RunMat:compatibility:WritecellExplicitGpuInputExtension"),
181};
182const WRITECELL_BYTES_OUTPUT_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
183    id: "writecell-bytes-written-output",
184    mode: BuiltinExtensionMode::RunMatOnly,
185    description: "Request a bytes-written output from writecell",
186    error_identifier: Some("RunMat:compatibility:WritecellBytesOutputExtension"),
187};
188pub const WRITECELL_EXTENSIONS: [BuiltinExtensionDescriptor; 2] = [
189    WRITECELL_EXPLICIT_GPU_EXTENSION,
190    WRITECELL_BYTES_OUTPUT_EXTENSION,
191];
192
193const WRITECELL_INTEGER_CONTENT_INPUT: [BuiltinIntegerInputCapability; 1] =
194    [BuiltinIntegerInputCapability {
195        name: "integer scalar cell contents",
196        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
197        availability: BuiltinIntegerInputAvailability::Documented,
198        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
199        notes: "Cell arrays may contain scalar values from every native integer class. RunMat reads authoritative integer storage when formatting delimited text or spreadsheet cells.",
200    }];
201const WRITECELL_INTEGER_SHEET_INPUT: [BuiltinIntegerInputCapability; 1] =
202    [BuiltinIntegerInputCapability {
203        name: "Sheet",
204        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
205        availability: BuiltinIntegerInputAvailability::Documented,
206        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
207        notes: "A numeric sheet selector may use any native integer class. RunMat decodes it exactly as a positive one-based structural index.",
208    }];
209pub const WRITECELL_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 2] = [
210    BuiltinIntegerCapabilityDescriptor {
211        form: "writecell(cell_with_integer_scalars, filename, ___)",
212        inputs: &WRITECELL_INTEGER_CONTENT_INPUT,
213        computation_domain: BuiltinIntegerComputationDomain::FunctionSpecific,
214        output_class: BuiltinIntegerOutputClassRule::NotApplicable,
215        overflow: BuiltinIntegerOverflowRule::Error,
216        backend: BuiltinIntegerBackendRule::GatherFallback,
217        overload: BuiltinIntegerOverloadKind::Multiple,
218        notes: "Integer cells retain their decimal value through text and spreadsheet serialization without an intermediate floating conversion.",
219    },
220    BuiltinIntegerCapabilityDescriptor {
221        form: "writecell(C, filename, 'Sheet', integer_index)",
222        inputs: &WRITECELL_INTEGER_SHEET_INPUT,
223        computation_domain: BuiltinIntegerComputationDomain::Structural,
224        output_class: BuiltinIntegerOutputClassRule::NotApplicable,
225        overflow: BuiltinIntegerOverflowRule::Error,
226        backend: BuiltinIntegerBackendRule::GatherFallback,
227        overload: BuiltinIntegerOverloadKind::Multiple,
228        notes: "The exact selector is range-checked before conversion to the host index used by the spreadsheet writer.",
229    },
230];
231
232#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::tabular::writecell")]
233pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
234    name: "writecell",
235    op_kind: GpuOpKind::Custom("io-writecell"),
236    supported_precisions: &[],
237    broadcast: BroadcastSemantics::None,
238    provider_hooks: &[],
239    constant_strategy: ConstantStrategy::InlineLiteral,
240    residency: ResidencyPolicy::GatherImmediately,
241    nan_mode: ReductionNaN::Include,
242    two_pass_threshold: None,
243    workgroup_size: None,
244    accepts_nan_mode: false,
245    notes: "Runs entirely on the host. Automatically resident values gather transparently; explicit gpuArray input is accepted only in RunMat mode.",
246};
247
248#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::tabular::writecell")]
249pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
250    name: "writecell",
251    shape: ShapeRequirements::Any,
252    constant_strategy: ConstantStrategy::InlineLiteral,
253    elementwise: None,
254    reduction: None,
255    emits_nan: false,
256    notes: "Not eligible for fusion; performs host-side file I/O.",
257};
258
259fn writecell_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
260    writecell_error_with(error, error.message)
261}
262
263fn writecell_error_with(
264    error: &'static BuiltinErrorDescriptor,
265    message: impl Into<String>,
266) -> RuntimeError {
267    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
268    if let Some(identifier) = error.identifier {
269        builder = builder.with_identifier(identifier);
270    }
271    builder.build()
272}
273
274fn writecell_error_with_source<E>(
275    error: &'static BuiltinErrorDescriptor,
276    message: impl Into<String>,
277    source: E,
278) -> RuntimeError
279where
280    E: std::error::Error + Send + Sync + 'static,
281{
282    let mut builder = build_runtime_error(message)
283        .with_builtin(BUILTIN_NAME)
284        .with_source(source);
285    if let Some(identifier) = error.identifier {
286        builder = builder.with_identifier(identifier);
287    }
288    builder.build()
289}
290
291fn map_control_flow(err: RuntimeError) -> RuntimeError {
292    let identifier = err.identifier().map(|value| value.to_string());
293    let message = err.message().to_string();
294    let mut builder = build_runtime_error(message)
295        .with_builtin(BUILTIN_NAME)
296        .with_source(err);
297    if let Some(identifier) = identifier {
298        builder = builder.with_identifier(identifier);
299    }
300    builder.build()
301}
302
303#[runtime_builtin(
304    name = "writecell",
305    category = "io/tabular",
306    summary = "Write heterogeneous cell arrays to delimited text or spreadsheet files.",
307    keywords = "writecell,csv,xlsx,xls,cell array,delimited text,spreadsheet,append,quote strings",
308    accel = "cpu",
309    type_resolver(crate::builtins::io::type_resolvers::num_type),
310    descriptor(crate::builtins::io::tabular::writecell::WRITECELL_DESCRIPTOR),
311    extensions(crate::builtins::io::tabular::writecell::WRITECELL_EXTENSIONS),
312    integer_capabilities(crate::builtins::io::tabular::writecell::WRITECELL_INTEGER_CAPABILITIES),
313    builtin_path = "crate::builtins::io::tabular::writecell"
314)]
315async fn writecell_builtin(data: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
316    if rest.is_empty() {
317        return Err(writecell_error(&WRITECELL_ERROR_ARG_CONFIG));
318    }
319    let requested_outputs = crate::output_count::current_output_count();
320    if requested_outputs.is_some_and(|count| count > 1) {
321        return Err(writecell_error_with(
322            &WRITECELL_ERROR_ARG_CONFIG,
323            "writecell: too many output arguments",
324        ));
325    }
326    if requested_outputs.is_some_and(|count| count > 0) {
327        crate::compatibility::ensure_builtin_extension_enabled(
328            &WRITECELL_BYTES_OUTPUT_EXTENSION,
329            BUILTIN_NAME,
330        )?;
331    }
332    if crate::builtins::common::validation::value_contains_explicit_gpu(&data)
333        || rest
334            .iter()
335            .any(crate::builtins::common::validation::value_contains_explicit_gpu)
336    {
337        crate::compatibility::ensure_builtin_extension_enabled(
338            &WRITECELL_EXPLICIT_GPU_EXTENSION,
339            BUILTIN_NAME,
340        )?;
341    }
342
343    let filename_value = gather_if_needed_async(&rest[0])
344        .await
345        .map_err(map_control_flow)?;
346    let path = resolve_path(&filename_value)?;
347    let options = parse_options(&rest[1..]).await?;
348
349    let gathered = gather_if_needed_async(&data)
350        .await
351        .map_err(map_control_flow)?;
352    let table = CellTable::from_value(gathered).await?;
353
354    let bytes_written = match options.resolve_file_type(&path)? {
355        OutputFileType::DelimitedText => write_delimited_cells(&path, &table, &options).await?,
356        OutputFileType::Spreadsheet => write_spreadsheet_cells(&path, &table, &options).await?,
357    };
358
359    Ok(Value::Num(bytes_written as f64))
360}
361
362#[derive(Debug, Clone)]
363struct WriteCellOptions {
364    delimiter: Option<String>,
365    write_mode: WriteMode,
366    quote_strings: bool,
367    line_ending: LineEnding,
368    file_type: Option<OutputFileType>,
369    sheet: SheetSelector,
370    range: Option<RangeStart>,
371}
372
373impl Default for WriteCellOptions {
374    fn default() -> Self {
375        Self {
376            delimiter: None,
377            write_mode: WriteMode::Overwrite,
378            quote_strings: true,
379            line_ending: LineEnding::Auto,
380            file_type: None,
381            sheet: SheetSelector::Default,
382            range: None,
383        }
384    }
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388enum WriteMode {
389    Overwrite,
390    Append,
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394enum LineEnding {
395    Auto,
396    Unix,
397    Windows,
398    Mac,
399}
400
401impl LineEnding {
402    fn as_str(self) -> &'static str {
403        match self {
404            LineEnding::Auto | LineEnding::Unix => "\n",
405            LineEnding::Windows => "\r\n",
406            LineEnding::Mac => "\r",
407        }
408    }
409}
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq)]
412enum OutputFileType {
413    DelimitedText,
414    Spreadsheet,
415}
416
417#[derive(Debug, Clone)]
418enum SheetSelector {
419    Default,
420    Name(String),
421    Index(usize),
422}
423
424#[derive(Debug, Clone, Copy, Default)]
425pub(super) struct RangeStart {
426    pub(super) row: usize,
427    pub(super) col: usize,
428}
429
430impl WriteCellOptions {
431    fn resolve_file_type(&self, path: &Path) -> BuiltinResult<OutputFileType> {
432        if let Some(file_type) = self.file_type {
433            if file_type == OutputFileType::Spreadsheet {
434                ensure_supported_spreadsheet_extension(path)?;
435            }
436            return Ok(file_type);
437        }
438        match path_extension_lower(path).as_deref() {
439            Some("xlsx") | Some("xlsm") => Ok(OutputFileType::Spreadsheet),
440            Some(ext) if is_unsupported_spreadsheet_extension(ext) => Err(writecell_error_with(
441                &WRITECELL_ERROR_OPTION,
442                format!("writecell: unsupported spreadsheet file extension '.{ext}'"),
443            )),
444            _ => Ok(OutputFileType::DelimitedText),
445        }
446    }
447
448    fn resolve_delimiter(&self, path: &Path) -> String {
449        self.delimiter
450            .clone()
451            .unwrap_or_else(|| default_delimiter_for_path(path))
452    }
453
454    fn sheet_name(&self) -> String {
455        match &self.sheet {
456            SheetSelector::Default => "Sheet1".to_string(),
457            SheetSelector::Name(name) => sanitize_sheet_name(name),
458            SheetSelector::Index(index) => format!("Sheet{index}"),
459        }
460    }
461
462    fn range_start(&self) -> RangeStart {
463        self.range.unwrap_or_default()
464    }
465}
466
467fn ensure_supported_spreadsheet_extension(path: &Path) -> BuiltinResult<()> {
468    match path_extension_lower(path).as_deref() {
469        Some("xlsx") | Some("xlsm") => Ok(()),
470        Some(ext) => Err(writecell_error_with(
471            &WRITECELL_ERROR_OPTION,
472            format!("writecell: unsupported spreadsheet file extension '.{ext}'"),
473        )),
474        None => Err(writecell_error_with(
475            &WRITECELL_ERROR_OPTION,
476            "writecell: spreadsheet output requires an .xlsx or .xlsm extension",
477        )),
478    }
479}
480
481fn is_unsupported_spreadsheet_extension(ext: &str) -> bool {
482    matches!(ext, "xls" | "xlsb" | "ods")
483}
484
485async fn parse_options(args: &[Value]) -> BuiltinResult<WriteCellOptions> {
486    if args.is_empty() {
487        return Ok(WriteCellOptions::default());
488    }
489    if !args.len().is_multiple_of(2) {
490        return Err(writecell_error(&WRITECELL_ERROR_ARG_CONFIG));
491    }
492
493    let mut options = WriteCellOptions::default();
494    let mut index = 0usize;
495    while index < args.len() {
496        let name_value = gather_if_needed_async(&args[index])
497            .await
498            .map_err(map_control_flow)?;
499        let name = string_scalar_from_value(&name_value, "option name")
500            .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
501        let value = gather_if_needed_async(&args[index + 1])
502            .await
503            .map_err(map_control_flow)?;
504        apply_option(&mut options, &name, &value)?;
505        index += 2;
506    }
507    Ok(options)
508}
509
510fn apply_option(options: &mut WriteCellOptions, name: &str, value: &Value) -> BuiltinResult<()> {
511    if name.eq_ignore_ascii_case("Delimiter") {
512        options.delimiter = Some(parse_delimiter(value)?);
513        return Ok(());
514    }
515    if name.eq_ignore_ascii_case("WriteMode") {
516        options.write_mode = parse_write_mode(value)?;
517        return Ok(());
518    }
519    if name.eq_ignore_ascii_case("QuoteStrings") {
520        options.quote_strings = parse_bool_like(value, "QuoteStrings")?;
521        return Ok(());
522    }
523    if name.eq_ignore_ascii_case("LineEnding") {
524        options.line_ending = parse_line_ending(value)?;
525        return Ok(());
526    }
527    if name.eq_ignore_ascii_case("FileType") {
528        options.file_type = Some(parse_file_type(value)?);
529        return Ok(());
530    }
531    if name.eq_ignore_ascii_case("Sheet") {
532        options.sheet = parse_sheet(value)?;
533        return Ok(());
534    }
535    if name.eq_ignore_ascii_case("Range") {
536        options.range = Some(parse_range_start(value)?);
537        return Ok(());
538    }
539    Ok(())
540}
541
542fn parse_delimiter(value: &Value) -> BuiltinResult<String> {
543    let text = string_scalar_from_value(value, "Delimiter")
544        .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
545    if text.is_empty() {
546        return Err(writecell_error_with(
547            &WRITECELL_ERROR_OPTION,
548            "writecell: Delimiter cannot be empty",
549        ));
550    }
551    let trimmed = text.trim();
552    match trimmed.to_ascii_lowercase().as_str() {
553        "tab" => Ok("\t".to_string()),
554        "space" | "whitespace" => Ok(" ".to_string()),
555        "comma" => Ok(",".to_string()),
556        "semicolon" => Ok(";".to_string()),
557        "pipe" => Ok("|".to_string()),
558        _ => Ok(trimmed.to_string()),
559    }
560}
561
562fn parse_write_mode(value: &Value) -> BuiltinResult<WriteMode> {
563    let text = string_scalar_from_value(value, "WriteMode")
564        .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
565    match text.trim().to_ascii_lowercase().as_str() {
566        "overwrite" => Ok(WriteMode::Overwrite),
567        "append" => Ok(WriteMode::Append),
568        _ => Err(writecell_error_with(
569            &WRITECELL_ERROR_OPTION,
570            "writecell: WriteMode must be 'overwrite' or 'append'",
571        )),
572    }
573}
574
575fn parse_bool_like(value: &Value, context: &str) -> BuiltinResult<bool> {
576    match value {
577        Value::Bool(b) => Ok(*b),
578        Value::Int(i) => match i.to_i64() {
579            0 => Ok(false),
580            1 => Ok(true),
581            _ => Err(writecell_error_with(
582                &WRITECELL_ERROR_OPTION,
583                format!("writecell: {context} must be logical (0 or 1)"),
584            )),
585        },
586        Value::Num(n) if (*n - 0.0).abs() < f64::EPSILON => Ok(false),
587        Value::Num(n) if (*n - 1.0).abs() < f64::EPSILON => Ok(true),
588        _ => {
589            let text = string_scalar_from_value(value, context)
590                .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
591            match text.trim().to_ascii_lowercase().as_str() {
592                "on" | "true" | "yes" | "1" => Ok(true),
593                "off" | "false" | "no" | "0" => Ok(false),
594                _ => Err(writecell_error_with(
595                    &WRITECELL_ERROR_OPTION,
596                    format!("writecell: {context} must be logical (true/on or false/off)"),
597                )),
598            }
599        }
600    }
601}
602
603fn parse_line_ending(value: &Value) -> BuiltinResult<LineEnding> {
604    let text = string_scalar_from_value(value, "LineEnding")
605        .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
606    match text.trim().to_ascii_lowercase().as_str() {
607        "auto" => Ok(LineEnding::Auto),
608        "unix" => Ok(LineEnding::Unix),
609        "pc" | "windows" => Ok(LineEnding::Windows),
610        "mac" => Ok(LineEnding::Mac),
611        _ => Err(writecell_error_with(
612            &WRITECELL_ERROR_OPTION,
613            "writecell: LineEnding must be 'auto', 'unix', 'pc', or 'mac'",
614        )),
615    }
616}
617
618fn parse_file_type(value: &Value) -> BuiltinResult<OutputFileType> {
619    let text = string_scalar_from_value(value, "FileType")
620        .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
621    match text.trim().to_ascii_lowercase().as_str() {
622        "text" | "delimitedtext" => Ok(OutputFileType::DelimitedText),
623        "spreadsheet" => Ok(OutputFileType::Spreadsheet),
624        _ => Err(writecell_error_with(
625            &WRITECELL_ERROR_OPTION,
626            "writecell: FileType must be 'text', 'delimitedtext', or 'spreadsheet'",
627        )),
628    }
629}
630
631fn parse_sheet(value: &Value) -> BuiltinResult<SheetSelector> {
632    match value {
633        Value::Num(n)
634            if n.is_finite() && *n >= 1.0 && n.fract() == 0.0 && *n < usize::MAX as f64 =>
635        {
636            Ok(SheetSelector::Index(*n as usize))
637        }
638        Value::Int(i) => i
639            .try_to_usize()
640            .filter(|index| *index >= 1)
641            .map(SheetSelector::Index)
642            .ok_or_else(|| {
643                writecell_error_with(
644                    &WRITECELL_ERROR_OPTION,
645                    "writecell: Sheet must be a name or one-based numeric index",
646                )
647            }),
648        Value::Tensor(tensor) if tensor::is_scalar_tensor(tensor) => {
649            if let Some(storage) = tensor.integer_storage() {
650                let value = storage.value_at(0).expect("one-element integer storage");
651                value
652                    .try_to_usize()
653                    .filter(|index| *index >= 1)
654                    .map(SheetSelector::Index)
655                    .ok_or_else(|| {
656                        writecell_error_with(
657                            &WRITECELL_ERROR_OPTION,
658                            "writecell: Sheet must be a name or one-based numeric index",
659                        )
660                    })
661            } else {
662                parse_sheet(&Value::Num(tensor::tensor_value_f64(tensor, 0)))
663            }
664        }
665        _ => {
666            let text = string_scalar_from_value(value, "Sheet")
667                .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
668            if text.trim().is_empty() {
669                return Err(writecell_error_with(
670                    &WRITECELL_ERROR_OPTION,
671                    "writecell: Sheet name cannot be empty",
672                ));
673            }
674            Ok(SheetSelector::Name(text))
675        }
676    }
677}
678
679fn parse_range_start(value: &Value) -> BuiltinResult<RangeStart> {
680    let text = string_scalar_from_value(value, "Range")
681        .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
682    let start = text.split(':').next().unwrap_or("").trim();
683    parse_a1_cell(start).ok_or_else(|| {
684        writecell_error_with(
685            &WRITECELL_ERROR_OPTION,
686            "writecell: Range must start with an Excel A1 cell reference",
687        )
688    })
689}
690
691fn parse_a1_cell(value: &str) -> Option<RangeStart> {
692    if value.is_empty() {
693        return None;
694    }
695    let mut col = 0usize;
696    let mut letters = 0usize;
697    for ch in value.chars() {
698        if ch.is_ascii_alphabetic() {
699            if letters == 0 && col != 0 {
700                return None;
701            }
702            col = col.checked_mul(26)?;
703            col = col.checked_add((ch.to_ascii_uppercase() as u8 - b'A' + 1) as usize)?;
704            letters += 1;
705        } else {
706            break;
707        }
708    }
709    let row_text = &value[letters..];
710    if letters == 0 || row_text.is_empty() || !row_text.chars().all(|ch| ch.is_ascii_digit()) {
711        return None;
712    }
713    let row: usize = row_text.parse().ok()?;
714    if row == 0 || col == 0 {
715        return None;
716    }
717    Some(RangeStart {
718        row: row - 1,
719        col: col - 1,
720    })
721}
722
723#[derive(Debug, Clone, PartialEq)]
724pub(super) enum CellValue {
725    Empty,
726    Number(f64),
727    Integer(IntValue),
728    Boolean(bool),
729    Text(String),
730}
731
732pub(super) struct CellTable {
733    pub(super) rows: usize,
734    pub(super) cols: usize,
735    data: Vec<CellValue>,
736}
737
738impl CellTable {
739    pub(super) fn from_cells(
740        rows: usize,
741        cols: usize,
742        data: Vec<CellValue>,
743    ) -> BuiltinResult<Self> {
744        let expected = rows.checked_mul(cols).ok_or_else(|| {
745            writecell_error_with(&WRITECELL_ERROR_DATA, "writecell: cell table size overflow")
746        })?;
747        if data.len() != expected {
748            return Err(writecell_error_with(
749                &WRITECELL_ERROR_DATA,
750                format!(
751                    "writecell: cell table has {} values for {rows}-by-{cols} shape",
752                    data.len()
753                ),
754            ));
755        }
756        Ok(Self { rows, cols, data })
757    }
758
759    async fn from_value(value: Value) -> BuiltinResult<Self> {
760        let cell = match value {
761            Value::Cell(cell) => cell,
762            other => {
763                return Err(writecell_error_with(
764                    &WRITECELL_ERROR_DATA,
765                    format!("writecell: input must be a cell array, got {other:?}"),
766                ));
767            }
768        };
769        ensure_cell_shape(&cell)?;
770
771        let mut data = Vec::with_capacity(cell.data.len());
772        for row in 0..cell.rows {
773            for col in 0..cell.cols {
774                let value = cell.get(row, col).map_err(|message| {
775                    writecell_error_with(&WRITECELL_ERROR_DATA, format!("writecell: {message}"))
776                })?;
777                let gathered = gather_if_needed_async(&value)
778                    .await
779                    .map_err(map_control_flow)?;
780                data.push(cell_value_from_value(gathered)?);
781            }
782        }
783        Ok(Self {
784            rows: cell.rows,
785            cols: cell.cols,
786            data,
787        })
788    }
789
790    pub(super) fn get(&self, row: usize, col: usize) -> &CellValue {
791        &self.data[row * self.cols + col]
792    }
793}
794
795fn ensure_cell_shape(cell: &CellArray) -> BuiltinResult<()> {
796    if cell.shape.len() <= 2 || cell.shape[2..].iter().all(|&dim| dim == 1) {
797        return Ok(());
798    }
799    Err(writecell_error_with(
800        &WRITECELL_ERROR_DATA_SHAPE,
801        "writecell: input cell array must be 2-D",
802    ))
803}
804
805fn cell_value_from_value(value: Value) -> BuiltinResult<CellValue> {
806    match value {
807        Value::Num(n) => Ok(CellValue::Number(n)),
808        Value::Int(i) => Ok(CellValue::Integer(i)),
809        Value::Bool(b) => Ok(CellValue::Boolean(b)),
810        Value::String(s) => Ok(CellValue::Text(s)),
811        Value::CharArray(ca) if ca.rows == 1 => Ok(CellValue::Text(ca.data.iter().collect())),
812        Value::StringArray(sa) if sa.data.len() == 1 => Ok(CellValue::Text(sa.data[0].clone())),
813        Value::StringArray(sa) if sa.data.is_empty() => Ok(CellValue::Empty),
814        Value::Tensor(tensor) if tensor::is_scalar_tensor(&tensor) => {
815            scalar_tensor_cell_value(&tensor)
816        }
817        Value::Tensor(tensor) if tensor::tensor_element_len(&tensor) == 0 => Ok(CellValue::Empty),
818        Value::LogicalArray(logical) if logical.data.len() == 1 => {
819            Ok(CellValue::Boolean(logical.data[0] != 0))
820        }
821        Value::LogicalArray(logical) if logical.data.is_empty() => Ok(CellValue::Empty),
822        Value::Complex(_, _) | Value::ComplexTensor(_) => Err(writecell_error_with(
823            &WRITECELL_ERROR_DATA,
824            "writecell: complex values are not supported; split real and imaginary parts first",
825        )),
826        Value::Cell(_) => Err(writecell_error_with(
827            &WRITECELL_ERROR_DATA,
828            "writecell: nested cell arrays are not supported",
829        )),
830        other => Err(writecell_error_with(
831            &WRITECELL_ERROR_DATA,
832            format!("writecell: unsupported cell value {other:?}"),
833        )),
834    }
835}
836
837pub(super) fn scalar_tensor_cell_value(tensor: &runmat_value::Tensor) -> BuiltinResult<CellValue> {
838    if let Some(storage) = tensor.integer_storage() {
839        let value = storage.value_at(0).expect("one-element integer storage");
840        Ok(CellValue::Integer(value))
841    } else {
842        Ok(CellValue::Number(tensor::tensor_value_f64(tensor, 0)))
843    }
844}
845
846async fn write_delimited_cells(
847    path: &Path,
848    table: &CellTable,
849    options: &WriteCellOptions,
850) -> BuiltinResult<usize> {
851    let delimiter = options.resolve_delimiter(path);
852    let line_ending = options.line_ending.as_str();
853    let payload = build_delimited_payload(table, options, &delimiter, line_ending);
854    let write_lock = write_lock_for_path(path).await;
855    let _write_guard = write_lock.lock().await;
856
857    if options.write_mode == WriteMode::Overwrite {
858        safe_replace_file(path, &payload, "delimited text").await?;
859        return Ok(payload.len());
860    }
861
862    let mut open_options = OpenOptions::new();
863    open_options.create(true).write(true).append(true);
864
865    let mut file = open_options.open_async(path).await.map_err(|err| {
866        writecell_error_with_source(
867            &WRITECELL_ERROR_IO,
868            format!(
869                "writecell: unable to open \"{}\" for writing ({err})",
870                path.display()
871            ),
872            err,
873        )
874    })?;
875
876    let mut bytes_written = 0usize;
877    if append_needs_line_ending(path).await? {
878        file.write_all(line_ending.as_bytes()).map_err(|err| {
879            writecell_error_with_source(
880                &WRITECELL_ERROR_IO,
881                format!("writecell: failed to write append line ending ({err})"),
882                err,
883            )
884        })?;
885        bytes_written += line_ending.len();
886    }
887    file.write_all(&payload).map_err(|err| {
888        writecell_error_with_source(
889            &WRITECELL_ERROR_IO,
890            format!("writecell: failed to write delimited text ({err})"),
891            err,
892        )
893    })?;
894    bytes_written += payload.len();
895    file.flush_async().await.map_err(|err| {
896        writecell_error_with_source(
897            &WRITECELL_ERROR_IO,
898            format!("writecell: failed to flush output ({err})"),
899            err,
900        )
901    })?;
902    Ok(bytes_written)
903}
904
905pub(super) async fn write_lock_for_path(path: &Path) -> WriteLock {
906    let key = write_lock_key(path).await;
907    let locks = WRITE_LOCKS.get_or_init(|| StdMutex::new(HashMap::new()));
908    let mut locks = locks
909        .lock()
910        .expect("writecell write lock registry poisoned");
911    if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
912        return lock;
913    }
914    locks.retain(|_, lock| lock.strong_count() > 0);
915    let lock = Arc::new(AsyncMutex::new(()));
916    locks.insert(key, Arc::downgrade(&lock));
917    lock
918}
919
920async fn write_lock_key(path: &Path) -> String {
921    if let Ok(canonical) = runmat_filesystem::canonicalize_async(path).await {
922        return canonical.to_string_lossy().into_owned();
923    }
924
925    let absolute = lexical_absolute_path(path);
926    let mut candidate = absolute.as_path();
927    let mut suffix = PathBuf::new();
928    loop {
929        if let Ok(canonical) = runmat_filesystem::canonicalize_async(candidate).await {
930            let keyed = if suffix.as_os_str().is_empty() {
931                canonical
932            } else {
933                canonical.join(&suffix)
934            };
935            return keyed.to_string_lossy().into_owned();
936        }
937        let Some(name) = candidate.file_name() else {
938            break;
939        };
940        let mut next_suffix = PathBuf::from(name);
941        if !suffix.as_os_str().is_empty() {
942            next_suffix.push(&suffix);
943        }
944        suffix = next_suffix;
945        let Some(parent) = candidate.parent() else {
946            break;
947        };
948        if parent == candidate {
949            break;
950        }
951        candidate = parent;
952    }
953
954    lexical_normalize_path(absolute)
955        .to_string_lossy()
956        .into_owned()
957}
958
959fn lexical_absolute_path(path: &Path) -> PathBuf {
960    let absolute = if path.is_absolute() {
961        path.to_path_buf()
962    } else {
963        runmat_filesystem::current_dir()
964            .map(|cwd| cwd.join(path))
965            .unwrap_or_else(|_| path.to_path_buf())
966    };
967    lexical_normalize_path(absolute)
968}
969
970fn lexical_normalize_path(path: PathBuf) -> PathBuf {
971    let mut normalized = PathBuf::new();
972    for component in path.components() {
973        match component {
974            Component::CurDir => {}
975            Component::ParentDir => {
976                normalized.pop();
977            }
978            other => normalized.push(other.as_os_str()),
979        }
980    }
981    normalized
982}
983
984fn build_delimited_payload(
985    table: &CellTable,
986    options: &WriteCellOptions,
987    delimiter: &str,
988    line_ending: &str,
989) -> Vec<u8> {
990    let mut payload = Vec::new();
991    for row in 0..table.rows {
992        for col in 0..table.cols {
993            if col > 0 {
994                payload.extend_from_slice(delimiter.as_bytes());
995            }
996            let rendered = format_cell_for_text(table.get(row, col), options, delimiter);
997            if !rendered.is_empty() {
998                payload.extend_from_slice(rendered.as_bytes());
999            }
1000        }
1001        payload.extend_from_slice(line_ending.as_bytes());
1002    }
1003    payload
1004}
1005
1006async fn append_needs_line_ending(path: &Path) -> BuiltinResult<bool> {
1007    let metadata = match runmat_filesystem::metadata_async(path).await {
1008        Ok(metadata) => metadata,
1009        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1010        Err(err) => {
1011            return Err(writecell_error_with_source(
1012                &WRITECELL_ERROR_IO,
1013                format!(
1014                    "writecell: unable to inspect \"{}\" ({err})",
1015                    path.display()
1016                ),
1017                err,
1018            ));
1019        }
1020    };
1021    if metadata.is_empty() {
1022        return Ok(false);
1023    }
1024    let mut file = File::open_async(path).await.map_err(|err| {
1025        writecell_error_with_source(
1026            &WRITECELL_ERROR_IO,
1027            format!(
1028                "writecell: unable to inspect \"{}\" ({err})",
1029                path.display()
1030            ),
1031            err,
1032        )
1033    })?;
1034    file.seek(SeekFrom::End(-1)).map_err(|err| {
1035        writecell_error_with_source(
1036            &WRITECELL_ERROR_IO,
1037            format!("writecell: unable to inspect file ending ({err})"),
1038            err,
1039        )
1040    })?;
1041    let mut byte = [0u8; 1];
1042    file.read_exact(&mut byte).map_err(|err| {
1043        writecell_error_with_source(
1044            &WRITECELL_ERROR_IO,
1045            format!("writecell: unable to read file ending ({err})"),
1046            err,
1047        )
1048    })?;
1049    Ok(!matches!(byte[0], b'\n' | b'\r'))
1050}
1051
1052async fn write_spreadsheet_cells(
1053    path: &Path,
1054    table: &CellTable,
1055    options: &WriteCellOptions,
1056) -> BuiltinResult<usize> {
1057    if options.write_mode == WriteMode::Append {
1058        return Err(writecell_error_with(
1059            &WRITECELL_ERROR_OPTION,
1060            "writecell: WriteMode 'append' is not supported for spreadsheet files",
1061        ));
1062    }
1063    let range_start = options.range_start();
1064    let end_row = range_start.row.checked_add(table.rows).ok_or_else(|| {
1065        writecell_error_with(&WRITECELL_ERROR_OPTION, "writecell: Range row overflow")
1066    })?;
1067    let end_col = range_start.col.checked_add(table.cols).ok_or_else(|| {
1068        writecell_error_with(&WRITECELL_ERROR_OPTION, "writecell: Range column overflow")
1069    })?;
1070    if end_row > MAX_EXCEL_ROW_INDEX + 1 || end_col > MAX_EXCEL_COLUMN_INDEX + 1 {
1071        return Err(writecell_error_with(
1072            &WRITECELL_ERROR_OPTION,
1073            "writecell: Range exceeds Excel worksheet limits",
1074        ));
1075    }
1076
1077    let bytes = build_xlsx_workbook(table, &options.sheet_name(), range_start)?;
1078    safe_replace_file(path, &bytes, "spreadsheet").await?;
1079    Ok(bytes.len())
1080}
1081
1082pub(super) async fn safe_replace_file(path: &Path, bytes: &[u8], label: &str) -> BuiltinResult<()> {
1083    let temp_path = temporary_sibling_path(path);
1084    let mut open_options = OpenOptions::new();
1085    open_options.write(true).create_new(true);
1086    let mut file = open_options.open_async(&temp_path).await.map_err(|err| {
1087        writecell_error_with_source(
1088            &WRITECELL_ERROR_IO,
1089            format!(
1090                "writecell: unable to create temporary {label} file \"{}\" ({err})",
1091                temp_path.display()
1092            ),
1093            err,
1094        )
1095    })?;
1096    file.write_all(bytes).map_err(|err| {
1097        writecell_error_with_source(
1098            &WRITECELL_ERROR_IO,
1099            format!("writecell: failed to write spreadsheet ({err})"),
1100            err,
1101        )
1102    })?;
1103    file.flush_async().await.map_err(|err| {
1104        writecell_error_with_source(
1105            &WRITECELL_ERROR_IO,
1106            format!("writecell: failed to flush temporary {label} file ({err})"),
1107            err,
1108        )
1109    })?;
1110    file.sync_all_async().await.map_err(|err| {
1111        writecell_error_with_source(
1112            &WRITECELL_ERROR_IO,
1113            format!("writecell: failed to sync temporary {label} file ({err})"),
1114            err,
1115        )
1116    })?;
1117    drop(file);
1118    if let Err(err) = runmat_filesystem::rename_async(&temp_path, path).await {
1119        let _ = runmat_filesystem::remove_file_async(&temp_path).await;
1120        return Err(writecell_error_with_source(
1121            &WRITECELL_ERROR_IO,
1122            format!(
1123                "writecell: failed to replace \"{}\" with temporary {label} file ({err})",
1124                path.display()
1125            ),
1126            err,
1127        ));
1128    }
1129    Ok(())
1130}
1131
1132fn temporary_sibling_path(path: &Path) -> PathBuf {
1133    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1134    let name = path
1135        .file_name()
1136        .and_then(|value| value.to_str())
1137        .unwrap_or("writecell");
1138    let nanos = std::time::SystemTime::now()
1139        .duration_since(std::time::UNIX_EPOCH)
1140        .map(|duration| duration.as_nanos())
1141        .unwrap_or_default();
1142    parent.join(format!(".{name}.runmat-tmp-{}-{nanos}", std::process::id()))
1143}
1144
1145pub(super) fn build_xlsx_workbook(
1146    table: &CellTable,
1147    sheet_name: &str,
1148    start: RangeStart,
1149) -> BuiltinResult<Vec<u8>> {
1150    let cursor = Cursor::new(Vec::new());
1151    let mut zip = zip::ZipWriter::new(cursor);
1152    write_xlsx_part(
1153        &mut zip,
1154        "[Content_Types].xml",
1155        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1156<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
1157  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
1158  <Default Extension="xml" ContentType="application/xml"/>
1159  <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
1160  <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
1161  <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
1162</Types>"#,
1163    )?;
1164    write_xlsx_part(
1165        &mut zip,
1166        "_rels/.rels",
1167        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1168<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1169  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
1170</Relationships>"#,
1171    )?;
1172    write_xlsx_part(
1173        &mut zip,
1174        "xl/workbook.xml",
1175        &format!(
1176            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1177<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
1178  <sheets>
1179    <sheet name="{}" sheetId="1" r:id="rId1"/>
1180  </sheets>
1181</workbook>"#,
1182            xml_attr_escape(sheet_name)
1183        ),
1184    )?;
1185    write_xlsx_part(
1186        &mut zip,
1187        "xl/_rels/workbook.xml.rels",
1188        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1189<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1190  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
1191  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
1192</Relationships>"#,
1193    )?;
1194    write_xlsx_part(
1195        &mut zip,
1196        "xl/styles.xml",
1197        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1198<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1199  <fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
1200  <fills count="1"><fill><patternFill patternType="none"/></fill></fills>
1201  <borders count="1"><border/></borders>
1202  <cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
1203  <cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellXfs>
1204</styleSheet>"#,
1205    )?;
1206    write_xlsx_part(
1207        &mut zip,
1208        "xl/worksheets/sheet1.xml",
1209        &build_sheet_xml(table, start),
1210    )?;
1211    let cursor = zip.finish().map_err(|err| {
1212        writecell_error_with_source(
1213            &WRITECELL_ERROR_IO,
1214            format!("writecell: failed to finish spreadsheet package ({err})"),
1215            err,
1216        )
1217    })?;
1218    Ok(cursor.into_inner())
1219}
1220
1221pub(super) fn write_xlsx_part(
1222    zip: &mut zip::ZipWriter<Cursor<Vec<u8>>>,
1223    name: &str,
1224    contents: &str,
1225) -> BuiltinResult<()> {
1226    let options =
1227        zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
1228    zip.start_file(name, options).map_err(|err| {
1229        writecell_error_with_source(
1230            &WRITECELL_ERROR_IO,
1231            format!("writecell: failed to start spreadsheet part {name} ({err})"),
1232            err,
1233        )
1234    })?;
1235    zip.write_all(contents.as_bytes()).map_err(|err| {
1236        writecell_error_with_source(
1237            &WRITECELL_ERROR_IO,
1238            format!("writecell: failed to write spreadsheet part {name} ({err})"),
1239            err,
1240        )
1241    })?;
1242    Ok(())
1243}
1244
1245pub(super) fn build_sheet_xml(table: &CellTable, start: RangeStart) -> String {
1246    let mut xml = String::from(
1247        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1248<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1249  <sheetData>
1250"#,
1251    );
1252    for row in 0..table.rows {
1253        let excel_row = start.row + row + 1;
1254        xml.push_str(&format!(r#"    <row r="{excel_row}">"#));
1255        xml.push('\n');
1256        for col in 0..table.cols {
1257            let cell = table.get(row, col);
1258            if *cell == CellValue::Empty {
1259                continue;
1260            }
1261            let reference = cell_reference(start.row + row, start.col + col);
1262            match cell {
1263                CellValue::Empty => {}
1264                CellValue::Number(value) => {
1265                    xml.push_str(&format!(
1266                        "      <c r=\"{reference}\"><v>{}</v></c>\n",
1267                        format_numeric(*value)
1268                    ));
1269                }
1270                CellValue::Integer(value) => {
1271                    xml.push_str(&format!(
1272                        "      <c r=\"{reference}\"><v>{}</v></c>\n",
1273                        value.decimal_string()
1274                    ));
1275                }
1276                CellValue::Boolean(value) => {
1277                    xml.push_str(&format!(
1278                        "      <c r=\"{reference}\" t=\"b\"><v>{}</v></c>\n",
1279                        if *value { 1 } else { 0 }
1280                    ));
1281                }
1282                CellValue::Text(text) => {
1283                    xml.push_str(&format!(
1284                        "      <c r=\"{reference}\" t=\"inlineStr\"><is><t>{}</t></is></c>\n",
1285                        xml_text_escape(text)
1286                    ));
1287                }
1288            }
1289        }
1290        xml.push_str("    </row>\n");
1291    }
1292    xml.push_str("  </sheetData>\n</worksheet>");
1293    xml
1294}
1295
1296fn format_cell_for_text(cell: &CellValue, options: &WriteCellOptions, delimiter: &str) -> String {
1297    match cell {
1298        CellValue::Empty => String::new(),
1299        CellValue::Number(value) => format_numeric(*value),
1300        CellValue::Integer(value) => value.decimal_string(),
1301        CellValue::Boolean(value) => {
1302            if *value {
1303                "1".to_string()
1304            } else {
1305                "0".to_string()
1306            }
1307        }
1308        CellValue::Text(text) => format_string(text, options.quote_strings, delimiter),
1309    }
1310}
1311
1312fn format_numeric(value: f64) -> String {
1313    if value.is_nan() {
1314        return "NaN".to_string();
1315    }
1316    if value.is_infinite() {
1317        return if value.is_sign_negative() {
1318            "-Inf".to_string()
1319        } else {
1320            "Inf".to_string()
1321        };
1322    }
1323
1324    let abs = value.abs();
1325    let scientific = abs != 0.0 && !(1e-4..1e15).contains(&abs);
1326    let raw = if scientific {
1327        format!("{:.15e}", value)
1328    } else {
1329        format!("{:.15}", value)
1330    };
1331    trim_trailing_zeros(raw)
1332}
1333
1334fn trim_trailing_zeros(mut value: String) -> String {
1335    if let Some(exp_pos) = value.find(['e', 'E']) {
1336        let exponent = value.split_off(exp_pos);
1337        while value.ends_with('0') {
1338            value.pop();
1339        }
1340        if value.ends_with('.') {
1341            value.pop();
1342        }
1343        value.push_str(&exponent);
1344        value
1345    } else {
1346        if value.contains('.') {
1347            while value.ends_with('0') {
1348                value.pop();
1349            }
1350            if value.ends_with('.') {
1351                value.pop();
1352            }
1353        }
1354        if value == "-0" || value.is_empty() {
1355            "0".to_string()
1356        } else {
1357            value
1358        }
1359    }
1360}
1361
1362fn format_string(value: &str, quote: bool, _delimiter: &str) -> String {
1363    if !quote {
1364        return value.to_string();
1365    }
1366    let mut escaped = String::with_capacity(value.len() + 2);
1367    escaped.push('"');
1368    for ch in value.chars() {
1369        if ch == '"' {
1370            escaped.push('"');
1371            escaped.push('"');
1372        } else {
1373            escaped.push(ch);
1374        }
1375    }
1376    escaped.push('"');
1377    escaped
1378}
1379
1380fn string_scalar_from_value(value: &Value, context: &str) -> Result<String, String> {
1381    match value {
1382        Value::String(s) => Ok(s.clone()),
1383        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
1384        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
1385        _ => Err(format!(
1386            "writecell: expected {context} as a string scalar or character vector"
1387        )),
1388    }
1389}
1390
1391fn resolve_path(value: &Value) -> BuiltinResult<PathBuf> {
1392    match value {
1393        Value::String(s) => normalize_path(s),
1394        Value::CharArray(ca) if ca.rows == 1 => {
1395            let text: String = ca.data.iter().collect();
1396            normalize_path(&text)
1397        }
1398        Value::CharArray(_) => Err(writecell_error_with(
1399            &WRITECELL_ERROR_FILENAME,
1400            "writecell: expected a 1-by-N character vector for the filename",
1401        )),
1402        Value::StringArray(sa) if sa.data.len() == 1 => normalize_path(&sa.data[0]),
1403        Value::StringArray(_) => Err(writecell_error_with(
1404            &WRITECELL_ERROR_FILENAME,
1405            "writecell: filename string array inputs must be scalar",
1406        )),
1407        other => Err(writecell_error_with(
1408            &WRITECELL_ERROR_FILENAME,
1409            format!(
1410                "writecell: expected filename as string scalar or character vector, got {other:?}"
1411            ),
1412        )),
1413    }
1414}
1415
1416fn normalize_path(raw: &str) -> BuiltinResult<PathBuf> {
1417    if raw.trim().is_empty() {
1418        return Err(writecell_error_with(
1419            &WRITECELL_ERROR_FILENAME,
1420            "writecell: filename must not be empty",
1421        ));
1422    }
1423    let expanded = expand_user_path(raw, BUILTIN_NAME)
1424        .map_err(|msg| writecell_error_with(&WRITECELL_ERROR_FILENAME, msg))?;
1425    Ok(Path::new(&expanded).to_path_buf())
1426}
1427
1428fn default_delimiter_for_path(path: &Path) -> String {
1429    match path_extension_lower(path).as_deref() {
1430        Some("csv") => ",".to_string(),
1431        Some("tsv") | Some("tab") => "\t".to_string(),
1432        Some("txt") | Some("dat") | Some("dlm") => " ".to_string(),
1433        _ => ",".to_string(),
1434    }
1435}
1436
1437fn path_extension_lower(path: &Path) -> Option<String> {
1438    path.extension()
1439        .and_then(|s| s.to_str())
1440        .map(|s| s.to_ascii_lowercase())
1441}
1442
1443fn sanitize_sheet_name(value: &str) -> String {
1444    let mut name: String = value
1445        .chars()
1446        .map(|ch| match ch {
1447            ':' | '\\' | '/' | '?' | '*' | '[' | ']' => '_',
1448            _ => ch,
1449        })
1450        .take(31)
1451        .collect();
1452    if name.trim().is_empty() {
1453        name = "Sheet1".to_string();
1454    }
1455    name
1456}
1457
1458fn cell_reference(row: usize, col: usize) -> String {
1459    format!("{}{}", column_letters(col), row + 1)
1460}
1461
1462fn column_letters(mut col: usize) -> String {
1463    let mut letters = Vec::new();
1464    col += 1;
1465    while col > 0 {
1466        let rem = (col - 1) % 26;
1467        letters.push((b'A' + rem as u8) as char);
1468        col = (col - 1) / 26;
1469    }
1470    letters.iter().rev().collect()
1471}
1472
1473fn xml_text_escape(value: &str) -> String {
1474    value
1475        .chars()
1476        .map(|ch| match ch {
1477            '&' => "&amp;".to_string(),
1478            '<' => "&lt;".to_string(),
1479            '>' => "&gt;".to_string(),
1480            _ => ch.to_string(),
1481        })
1482        .collect()
1483}
1484
1485pub(super) fn xml_attr_escape(value: &str) -> String {
1486    value
1487        .chars()
1488        .map(|ch| match ch {
1489            '&' => "&amp;".to_string(),
1490            '<' => "&lt;".to_string(),
1491            '>' => "&gt;".to_string(),
1492            '"' => "&quot;".to_string(),
1493            '\'' => "&apos;".to_string(),
1494            _ => ch.to_string(),
1495        })
1496        .collect()
1497}
1498
1499#[cfg(test)]
1500mod tests {
1501    use super::*;
1502    use calamine::{open_workbook_auto, Data, Reader};
1503    use futures::executor::block_on;
1504    use runmat_time::unix_timestamp_ms;
1505    use std::fs;
1506    use std::sync::atomic::{AtomicU64, Ordering};
1507    #[cfg(not(target_arch = "wasm32"))]
1508    use std::sync::mpsc;
1509    #[cfg(not(target_arch = "wasm32"))]
1510    use std::sync::Barrier;
1511    #[cfg(not(target_arch = "wasm32"))]
1512    use std::thread;
1513    #[cfg(not(target_arch = "wasm32"))]
1514    use std::time::Duration;
1515
1516    use runmat_value::{CharArray, IntValue, IntegerStorage, LogicalArray, Tensor};
1517
1518    static NEXT_ID: AtomicU64 = AtomicU64::new(0);
1519
1520    fn temp_path(ext: &str) -> PathBuf {
1521        let millis = unix_timestamp_ms();
1522        let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
1523        let mut path = std::env::temp_dir();
1524        path.push(format!(
1525            "runmat_writecell_{}_{}_{}.{}",
1526            std::process::id(),
1527            millis,
1528            unique,
1529            ext
1530        ));
1531        path
1532    }
1533
1534    fn cell(values: Vec<Value>, rows: usize, cols: usize) -> Value {
1535        Value::Cell(CellArray::new(values, rows, cols).expect("cell array"))
1536    }
1537
1538    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1539    #[test]
1540    fn writecell_descriptor_signatures_cover_core_forms() {
1541        let labels: Vec<&str> = WRITECELL_DESCRIPTOR
1542            .signatures
1543            .iter()
1544            .map(|sig| sig.label)
1545            .collect();
1546        assert!(labels.contains(&"writecell(C, filename)"));
1547        assert!(labels.contains(&"writecell(C, filename, name, optionValue)"));
1548        assert!(labels.contains(&"writecell(C, filename, nameValuePairs...)"));
1549    }
1550
1551    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1552    #[test]
1553    fn writecell_writes_heterogeneous_csv() {
1554        let path = temp_path("csv");
1555        let filename = path.to_string_lossy().into_owned();
1556        let values = cell(
1557            vec![
1558                Value::Num(1.5),
1559                Value::from("alpha"),
1560                Value::Bool(true),
1561                Value::Tensor(Tensor::new(Vec::new(), vec![0, 0]).expect("empty tensor")),
1562            ],
1563            2,
1564            2,
1565        );
1566
1567        block_on(writecell_builtin(values, vec![Value::from(filename)])).expect("writecell");
1568
1569        let contents = fs::read_to_string(&path).expect("read contents");
1570        assert_eq!(contents, "1.5,\"alpha\"\n1,\n");
1571        let _ = fs::remove_file(path);
1572    }
1573
1574    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1575    #[test]
1576    fn writecell_honours_delimiter_quote_strings_and_append() {
1577        let path = temp_path("txt");
1578        let filename = path.to_string_lossy().into_owned();
1579        let first = cell(vec![Value::from("a,b"), Value::Num(2.0)], 1, 2);
1580        let second = cell(vec![Value::from("tail"), Value::Num(3.0)], 1, 2);
1581
1582        block_on(writecell_builtin(
1583            first,
1584            vec![
1585                Value::from(filename.clone()),
1586                Value::from("Delimiter"),
1587                Value::from("|"),
1588                Value::from("QuoteStrings"),
1589                Value::Bool(false),
1590            ],
1591        ))
1592        .expect("initial write");
1593        block_on(writecell_builtin(
1594            second,
1595            vec![
1596                Value::from(filename.clone()),
1597                Value::from("Delimiter"),
1598                Value::from("|"),
1599                Value::from("QuoteStrings"),
1600                Value::Bool(false),
1601                Value::from("WriteMode"),
1602                Value::from("append"),
1603            ],
1604        ))
1605        .expect("append write");
1606
1607        let contents = fs::read_to_string(&path).expect("read contents");
1608        assert_eq!(contents, "a,b|2\ntail|3\n");
1609        let _ = fs::remove_file(path);
1610    }
1611
1612    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1613    #[test]
1614    fn writecell_append_inserts_missing_row_boundary() {
1615        let path = temp_path("txt");
1616        fs::write(&path, "existing").expect("seed");
1617        let filename = path.to_string_lossy().into_owned();
1618        let values = cell(vec![Value::from("tail"), Value::Num(3.0)], 1, 2);
1619
1620        block_on(writecell_builtin(
1621            values,
1622            vec![
1623                Value::from(filename),
1624                Value::from("Delimiter"),
1625                Value::from("|"),
1626                Value::from("QuoteStrings"),
1627                Value::Bool(false),
1628                Value::from("WriteMode"),
1629                Value::from("append"),
1630            ],
1631        ))
1632        .expect("append write");
1633
1634        let contents = fs::read_to_string(&path).expect("read contents");
1635        assert_eq!(contents, "existing\ntail|3\n");
1636        let _ = fs::remove_file(path);
1637    }
1638
1639    #[cfg(not(target_arch = "wasm32"))]
1640    #[test]
1641    fn writecell_concurrent_appends_share_one_boundary_insertion() {
1642        let path = temp_path("txt");
1643        fs::write(&path, "existing").expect("seed");
1644        let filename = path.to_string_lossy().into_owned();
1645        let writers = 8usize;
1646        let barrier = Arc::new(Barrier::new(writers));
1647        let mut handles = Vec::new();
1648        for idx in 0..writers {
1649            let barrier = Arc::clone(&barrier);
1650            let filename = filename.clone();
1651            handles.push(thread::spawn(move || {
1652                barrier.wait();
1653                let values = cell(
1654                    vec![Value::from(format!("row{idx}")), Value::Num(idx as f64)],
1655                    1,
1656                    2,
1657                );
1658                block_on(writecell_builtin(
1659                    values,
1660                    vec![
1661                        Value::from(filename),
1662                        Value::from("Delimiter"),
1663                        Value::from("|"),
1664                        Value::from("QuoteStrings"),
1665                        Value::Bool(false),
1666                        Value::from("WriteMode"),
1667                        Value::from("append"),
1668                    ],
1669                ))
1670                .expect("append write");
1671            }));
1672        }
1673        for handle in handles {
1674            handle.join().expect("writer thread");
1675        }
1676
1677        let contents = fs::read_to_string(&path).expect("read contents");
1678        let lines = contents.lines().collect::<Vec<_>>();
1679        assert_eq!(lines.len(), writers + 1);
1680        assert_eq!(lines[0], "existing");
1681        assert!(lines.iter().all(|line| !line.is_empty()));
1682        for idx in 0..writers {
1683            let expected = format!("row{idx}|{idx}");
1684            assert!(lines.iter().any(|line| *line == expected));
1685        }
1686        let _ = fs::remove_file(path);
1687    }
1688
1689    #[cfg(not(target_arch = "wasm32"))]
1690    #[test]
1691    fn writecell_overwrite_uses_same_path_write_lock() {
1692        let path = temp_path("txt");
1693        fs::write(&path, "existing\n").expect("seed");
1694        let filename = path.to_string_lossy().into_owned();
1695        let lock = block_on(write_lock_for_path(&path));
1696        let guard = block_on(lock.lock());
1697        let (tx, rx) = mpsc::channel();
1698
1699        let handle = thread::spawn(move || {
1700            let values = cell(vec![Value::from("replacement")], 1, 1);
1701            block_on(writecell_builtin(values, vec![Value::from(filename)]))
1702                .expect("overwrite write");
1703            tx.send(()).expect("send completion");
1704        });
1705
1706        thread::sleep(Duration::from_millis(50));
1707        assert!(rx.try_recv().is_err());
1708        drop(guard);
1709        handle.join().expect("writer thread");
1710        rx.recv_timeout(Duration::from_secs(1))
1711            .expect("overwrite completion");
1712
1713        let contents = fs::read_to_string(&path).expect("read contents");
1714        assert_eq!(contents, "\"replacement\"\n");
1715        let _ = fs::remove_file(path);
1716    }
1717
1718    #[cfg(unix)]
1719    #[test]
1720    fn writecell_canonical_aliases_share_write_lock() {
1721        let path = temp_path("txt");
1722        fs::write(&path, "").expect("seed");
1723        let mut link = path.clone();
1724        link.set_extension("link.txt");
1725        std::os::unix::fs::symlink(&path, &link).expect("symlink");
1726
1727        let direct = block_on(write_lock_for_path(&path));
1728        let alias = block_on(write_lock_for_path(&link));
1729        assert!(Arc::ptr_eq(&direct, &alias));
1730
1731        let _ = fs::remove_file(link);
1732        let _ = fs::remove_file(path);
1733    }
1734
1735    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1736    #[test]
1737    fn writecell_accepts_scalar_char_tensor_and_logical_cells() {
1738        let path = temp_path("csv");
1739        let filename = path.to_string_lossy().into_owned();
1740        let typed = Tensor::new_integer(IntegerStorage::U16(vec![2026]), vec![1, 1])
1741            .expect("typed scalar tensor");
1742        let values = cell(
1743            vec![
1744                Value::CharArray(CharArray::new_row("name")),
1745                Value::Tensor(typed),
1746                Value::LogicalArray(LogicalArray::new(vec![0], vec![1, 1]).expect("logical")),
1747            ],
1748            1,
1749            3,
1750        );
1751
1752        block_on(writecell_builtin(values, vec![Value::from(filename)])).expect("writecell");
1753
1754        let contents = fs::read_to_string(&path).expect("read contents");
1755        assert_eq!(contents, "\"name\",2026,0\n");
1756        let _ = fs::remove_file(path);
1757    }
1758
1759    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1760    #[test]
1761    fn writecell_preserves_integer_cell_text_exactly() {
1762        let path = temp_path("csv");
1763        let filename = path.to_string_lossy().into_owned();
1764        let wide = (1_u64 << 53) + 1;
1765        let typed =
1766            Tensor::new_integer(IntegerStorage::U64(vec![wide]), vec![1, 1]).expect("wide tensor");
1767        let values = cell(
1768            vec![Value::Int(IntValue::U64(u64::MAX)), Value::Tensor(typed)],
1769            1,
1770            2,
1771        );
1772
1773        block_on(writecell_builtin(values, vec![Value::from(filename)])).expect("writecell");
1774
1775        let contents = fs::read_to_string(&path).expect("read contents");
1776        assert_eq!(contents, "18446744073709551615,9007199254740993\n");
1777        let _ = fs::remove_file(path);
1778    }
1779
1780    #[test]
1781    fn writecell_sheet_parser_rejects_unrepresentable_double_boundary() {
1782        assert!(parse_sheet(&Value::Num(usize::MAX as f64)).is_err());
1783        assert!(parse_sheet(&Value::Num((usize::MAX as f64) + 1.0)).is_err());
1784
1785        let typed = Tensor::new_integer(IntegerStorage::U64(vec![(1_u64 << 53) + 1]), vec![1, 1])
1786            .expect("typed sheet");
1787        let parsed = parse_sheet(&Value::Tensor(typed));
1788        match usize::try_from((1_u64 << 53) + 1) {
1789            Ok(expected) => {
1790                assert!(matches!(parsed, Ok(SheetSelector::Index(actual)) if actual == expected))
1791            }
1792            Err(_) => assert!(parsed.is_err()),
1793        }
1794    }
1795
1796    #[test]
1797    fn writecell_explicit_gpu_input_is_gated_before_filesystem_access() {
1798        let handle = runmat_accelerate_api::GpuTensorHandle {
1799            shape: vec![1, 1],
1800            device_id: u32::MAX,
1801            buffer_id: u64::MAX - 469,
1802            descriptor: Default::default(),
1803        };
1804        let handle = handle.with_provenance(runmat_accelerate_api::GpuHandleProvenance::Explicit);
1805        let _strict = crate::compatibility::push_runmat_extensions_enabled(false);
1806        let data = cell(vec![Value::GpuTensor(handle)], 1, 1);
1807
1808        let error = block_on(writecell_builtin(data, vec![Value::from("unused.csv")]))
1809            .expect_err("strict mode rejects explicit GPU input before gather or file access");
1810        assert_eq!(
1811            error.identifier(),
1812            WRITECELL_EXPLICIT_GPU_EXTENSION.error_identifier
1813        );
1814    }
1815
1816    #[test]
1817    fn writecell_bytes_output_is_gated_before_filesystem_access() {
1818        let _strict = crate::compatibility::push_runmat_extensions_enabled(false);
1819        let _outputs = crate::output_count::push_output_count(Some(1));
1820        let data = cell(vec![Value::Num(1.0)], 1, 1);
1821
1822        let error = block_on(writecell_builtin(
1823            data,
1824            vec![Value::from("definitely/missing/out.csv")],
1825        ))
1826        .expect_err("strict mode rejects the bytes-written output before file access");
1827        assert_eq!(
1828            error.identifier(),
1829            WRITECELL_BYTES_OUTPUT_EXTENSION.error_identifier
1830        );
1831    }
1832
1833    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1834    #[test]
1835    fn writecell_rejects_nested_cells_and_nonscalar_arrays() {
1836        let path = temp_path("csv");
1837        let filename = path.to_string_lossy().into_owned();
1838        let nested = cell(vec![cell(vec![Value::Num(1.0)], 1, 1)], 1, 1);
1839        let err = block_on(writecell_builtin(
1840            nested,
1841            vec![Value::from(filename.clone())],
1842        ))
1843        .expect_err("nested cell error");
1844        assert!(err.message().contains("nested cell arrays"));
1845
1846        let nonscalar = cell(
1847            vec![Value::Tensor(
1848                Tensor::new(vec![1.0, 2.0], vec![1, 2]).expect("tensor"),
1849            )],
1850            1,
1851            1,
1852        );
1853        let err = block_on(writecell_builtin(nonscalar, vec![Value::from(filename)]))
1854            .expect_err("nonscalar error");
1855        assert!(err.message().contains("unsupported cell value"));
1856    }
1857
1858    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1859    #[test]
1860    fn writecell_writes_xlsx_with_sheet_and_range() {
1861        let path = temp_path("xlsx");
1862        let filename = path.to_string_lossy().into_owned();
1863        let values = cell(
1864            vec![Value::from("Voltage"), Value::Num(1.5), Value::Bool(true)],
1865            1,
1866            3,
1867        );
1868
1869        block_on(writecell_builtin(
1870            values,
1871            vec![
1872                Value::from(filename),
1873                Value::from("Sheet"),
1874                Value::from("Measurements"),
1875                Value::from("Range"),
1876                Value::from("B2"),
1877            ],
1878        ))
1879        .expect("writecell xlsx");
1880
1881        let mut workbook = open_workbook_auto(&path).expect("open workbook");
1882        assert_eq!(workbook.sheet_names()[0], "Measurements");
1883        let range = workbook
1884            .worksheet_range("Measurements")
1885            .expect("worksheet range");
1886        assert_eq!(
1887            range.get((0, 0)),
1888            Some(&Data::String("Voltage".to_string()))
1889        );
1890        assert_eq!(range.get((0, 1)), Some(&Data::Float(1.5)));
1891        assert_eq!(range.get((0, 2)), Some(&Data::Bool(true)));
1892        let _ = fs::remove_file(path);
1893    }
1894
1895    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1896    #[test]
1897    fn writecell_rejects_unsupported_spreadsheet_extension() {
1898        let path = temp_path("xls");
1899        let filename = path.to_string_lossy().into_owned();
1900        let values = cell(vec![Value::from("A"), Value::Num(1.0)], 1, 2);
1901        let err = block_on(writecell_builtin(values, vec![Value::from(filename)]))
1902            .expect_err("unsupported extension");
1903        assert!(err
1904            .message()
1905            .contains("unsupported spreadsheet file extension"));
1906        let _ = fs::remove_file(path);
1907    }
1908}