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