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;
27pub(super) type 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)]
346pub(super) struct RangeStart {
347    pub(super) row: usize,
348    pub(super) 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)]
617pub(super) enum CellValue {
618    Empty,
619    Number(f64),
620    Boolean(bool),
621    Text(String),
622}
623
624pub(super) struct CellTable {
625    pub(super) rows: usize,
626    pub(super) cols: usize,
627    data: Vec<CellValue>,
628}
629
630impl CellTable {
631    pub(super) fn from_cells(
632        rows: usize,
633        cols: usize,
634        data: Vec<CellValue>,
635    ) -> BuiltinResult<Self> {
636        let expected = rows.checked_mul(cols).ok_or_else(|| {
637            writecell_error_with(&WRITECELL_ERROR_DATA, "writecell: cell table size overflow")
638        })?;
639        if data.len() != expected {
640            return Err(writecell_error_with(
641                &WRITECELL_ERROR_DATA,
642                format!(
643                    "writecell: cell table has {} values for {rows}-by-{cols} shape",
644                    data.len()
645                ),
646            ));
647        }
648        Ok(Self { rows, cols, data })
649    }
650
651    async fn from_value(value: Value) -> BuiltinResult<Self> {
652        let cell = match value {
653            Value::Cell(cell) => cell,
654            other => {
655                return Err(writecell_error_with(
656                    &WRITECELL_ERROR_DATA,
657                    format!("writecell: input must be a cell array, got {other:?}"),
658                ));
659            }
660        };
661        ensure_cell_shape(&cell)?;
662
663        let mut data = Vec::with_capacity(cell.data.len());
664        for row in 0..cell.rows {
665            for col in 0..cell.cols {
666                let value = cell.get(row, col).map_err(|message| {
667                    writecell_error_with(&WRITECELL_ERROR_DATA, format!("writecell: {message}"))
668                })?;
669                let gathered = gather_if_needed_async(&value)
670                    .await
671                    .map_err(map_control_flow)?;
672                data.push(cell_value_from_value(gathered)?);
673            }
674        }
675        Ok(Self {
676            rows: cell.rows,
677            cols: cell.cols,
678            data,
679        })
680    }
681
682    pub(super) fn get(&self, row: usize, col: usize) -> &CellValue {
683        &self.data[row * self.cols + col]
684    }
685}
686
687fn ensure_cell_shape(cell: &CellArray) -> BuiltinResult<()> {
688    if cell.shape.len() <= 2 || cell.shape[2..].iter().all(|&dim| dim == 1) {
689        return Ok(());
690    }
691    Err(writecell_error_with(
692        &WRITECELL_ERROR_DATA_SHAPE,
693        "writecell: input cell array must be 2-D",
694    ))
695}
696
697fn cell_value_from_value(value: Value) -> BuiltinResult<CellValue> {
698    match value {
699        Value::Num(n) => Ok(CellValue::Number(n)),
700        Value::Int(i) => Ok(CellValue::Number(i.to_f64())),
701        Value::Bool(b) => Ok(CellValue::Boolean(b)),
702        Value::String(s) => Ok(CellValue::Text(s)),
703        Value::CharArray(ca) if ca.rows == 1 => Ok(CellValue::Text(ca.data.iter().collect())),
704        Value::StringArray(sa) if sa.data.len() == 1 => Ok(CellValue::Text(sa.data[0].clone())),
705        Value::StringArray(sa) if sa.data.is_empty() => Ok(CellValue::Empty),
706        Value::Tensor(tensor) if tensor.data.len() == 1 => Ok(CellValue::Number(tensor.data[0])),
707        Value::Tensor(tensor) if tensor.data.is_empty() => Ok(CellValue::Empty),
708        Value::LogicalArray(logical) if logical.data.len() == 1 => {
709            Ok(CellValue::Boolean(logical.data[0] != 0))
710        }
711        Value::LogicalArray(logical) if logical.data.is_empty() => Ok(CellValue::Empty),
712        Value::Complex(_, _) | Value::ComplexTensor(_) => Err(writecell_error_with(
713            &WRITECELL_ERROR_DATA,
714            "writecell: complex values are not supported; split real and imaginary parts first",
715        )),
716        Value::Cell(_) => Err(writecell_error_with(
717            &WRITECELL_ERROR_DATA,
718            "writecell: nested cell arrays are not supported",
719        )),
720        other => Err(writecell_error_with(
721            &WRITECELL_ERROR_DATA,
722            format!("writecell: unsupported cell value {other:?}"),
723        )),
724    }
725}
726
727async fn write_delimited_cells(
728    path: &Path,
729    table: &CellTable,
730    options: &WriteCellOptions,
731) -> BuiltinResult<usize> {
732    let delimiter = options.resolve_delimiter(path);
733    let line_ending = options.line_ending.as_str();
734    let payload = build_delimited_payload(table, options, &delimiter, line_ending);
735    let write_lock = write_lock_for_path(path).await;
736    let _write_guard = write_lock.lock().await;
737
738    if options.write_mode == WriteMode::Overwrite {
739        safe_replace_file(path, &payload, "delimited text").await?;
740        return Ok(payload.len());
741    }
742
743    let mut open_options = OpenOptions::new();
744    open_options.create(true).write(true).append(true);
745
746    let mut file = open_options.open_async(path).await.map_err(|err| {
747        writecell_error_with_source(
748            &WRITECELL_ERROR_IO,
749            format!(
750                "writecell: unable to open \"{}\" for writing ({err})",
751                path.display()
752            ),
753            err,
754        )
755    })?;
756
757    let mut bytes_written = 0usize;
758    if append_needs_line_ending(path).await? {
759        file.write_all(line_ending.as_bytes()).map_err(|err| {
760            writecell_error_with_source(
761                &WRITECELL_ERROR_IO,
762                format!("writecell: failed to write append line ending ({err})"),
763                err,
764            )
765        })?;
766        bytes_written += line_ending.len();
767    }
768    file.write_all(&payload).map_err(|err| {
769        writecell_error_with_source(
770            &WRITECELL_ERROR_IO,
771            format!("writecell: failed to write delimited text ({err})"),
772            err,
773        )
774    })?;
775    bytes_written += payload.len();
776    file.flush_async().await.map_err(|err| {
777        writecell_error_with_source(
778            &WRITECELL_ERROR_IO,
779            format!("writecell: failed to flush output ({err})"),
780            err,
781        )
782    })?;
783    Ok(bytes_written)
784}
785
786pub(super) async fn write_lock_for_path(path: &Path) -> WriteLock {
787    let key = write_lock_key(path).await;
788    let locks = WRITE_LOCKS.get_or_init(|| StdMutex::new(HashMap::new()));
789    let mut locks = locks
790        .lock()
791        .expect("writecell write lock registry poisoned");
792    if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
793        return lock;
794    }
795    locks.retain(|_, lock| lock.strong_count() > 0);
796    let lock = Arc::new(AsyncMutex::new(()));
797    locks.insert(key, Arc::downgrade(&lock));
798    lock
799}
800
801async fn write_lock_key(path: &Path) -> String {
802    if let Ok(canonical) = runmat_filesystem::canonicalize_async(path).await {
803        return canonical.to_string_lossy().into_owned();
804    }
805
806    let absolute = lexical_absolute_path(path);
807    let mut candidate = absolute.as_path();
808    let mut suffix = PathBuf::new();
809    loop {
810        if let Ok(canonical) = runmat_filesystem::canonicalize_async(candidate).await {
811            let keyed = if suffix.as_os_str().is_empty() {
812                canonical
813            } else {
814                canonical.join(&suffix)
815            };
816            return keyed.to_string_lossy().into_owned();
817        }
818        let Some(name) = candidate.file_name() else {
819            break;
820        };
821        let mut next_suffix = PathBuf::from(name);
822        if !suffix.as_os_str().is_empty() {
823            next_suffix.push(&suffix);
824        }
825        suffix = next_suffix;
826        let Some(parent) = candidate.parent() else {
827            break;
828        };
829        if parent == candidate {
830            break;
831        }
832        candidate = parent;
833    }
834
835    lexical_normalize_path(absolute)
836        .to_string_lossy()
837        .into_owned()
838}
839
840fn lexical_absolute_path(path: &Path) -> PathBuf {
841    let absolute = if path.is_absolute() {
842        path.to_path_buf()
843    } else {
844        runmat_filesystem::current_dir()
845            .map(|cwd| cwd.join(path))
846            .unwrap_or_else(|_| path.to_path_buf())
847    };
848    lexical_normalize_path(absolute)
849}
850
851fn lexical_normalize_path(path: PathBuf) -> PathBuf {
852    let mut normalized = PathBuf::new();
853    for component in path.components() {
854        match component {
855            Component::CurDir => {}
856            Component::ParentDir => {
857                normalized.pop();
858            }
859            other => normalized.push(other.as_os_str()),
860        }
861    }
862    normalized
863}
864
865fn build_delimited_payload(
866    table: &CellTable,
867    options: &WriteCellOptions,
868    delimiter: &str,
869    line_ending: &str,
870) -> Vec<u8> {
871    let mut payload = Vec::new();
872    for row in 0..table.rows {
873        for col in 0..table.cols {
874            if col > 0 {
875                payload.extend_from_slice(delimiter.as_bytes());
876            }
877            let rendered = format_cell_for_text(table.get(row, col), options, delimiter);
878            if !rendered.is_empty() {
879                payload.extend_from_slice(rendered.as_bytes());
880            }
881        }
882        payload.extend_from_slice(line_ending.as_bytes());
883    }
884    payload
885}
886
887async fn append_needs_line_ending(path: &Path) -> BuiltinResult<bool> {
888    let metadata = match runmat_filesystem::metadata_async(path).await {
889        Ok(metadata) => metadata,
890        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
891        Err(err) => {
892            return Err(writecell_error_with_source(
893                &WRITECELL_ERROR_IO,
894                format!(
895                    "writecell: unable to inspect \"{}\" ({err})",
896                    path.display()
897                ),
898                err,
899            ));
900        }
901    };
902    if metadata.is_empty() {
903        return Ok(false);
904    }
905    let mut file = File::open_async(path).await.map_err(|err| {
906        writecell_error_with_source(
907            &WRITECELL_ERROR_IO,
908            format!(
909                "writecell: unable to inspect \"{}\" ({err})",
910                path.display()
911            ),
912            err,
913        )
914    })?;
915    file.seek(SeekFrom::End(-1)).map_err(|err| {
916        writecell_error_with_source(
917            &WRITECELL_ERROR_IO,
918            format!("writecell: unable to inspect file ending ({err})"),
919            err,
920        )
921    })?;
922    let mut byte = [0u8; 1];
923    file.read_exact(&mut byte).map_err(|err| {
924        writecell_error_with_source(
925            &WRITECELL_ERROR_IO,
926            format!("writecell: unable to read file ending ({err})"),
927            err,
928        )
929    })?;
930    Ok(!matches!(byte[0], b'\n' | b'\r'))
931}
932
933async fn write_spreadsheet_cells(
934    path: &Path,
935    table: &CellTable,
936    options: &WriteCellOptions,
937) -> BuiltinResult<usize> {
938    if options.write_mode == WriteMode::Append {
939        return Err(writecell_error_with(
940            &WRITECELL_ERROR_OPTION,
941            "writecell: WriteMode 'append' is not supported for spreadsheet files",
942        ));
943    }
944    let range_start = options.range_start();
945    let end_row = range_start.row.checked_add(table.rows).ok_or_else(|| {
946        writecell_error_with(&WRITECELL_ERROR_OPTION, "writecell: Range row overflow")
947    })?;
948    let end_col = range_start.col.checked_add(table.cols).ok_or_else(|| {
949        writecell_error_with(&WRITECELL_ERROR_OPTION, "writecell: Range column overflow")
950    })?;
951    if end_row > MAX_EXCEL_ROW_INDEX + 1 || end_col > MAX_EXCEL_COLUMN_INDEX + 1 {
952        return Err(writecell_error_with(
953            &WRITECELL_ERROR_OPTION,
954            "writecell: Range exceeds Excel worksheet limits",
955        ));
956    }
957
958    let bytes = build_xlsx_workbook(table, &options.sheet_name(), range_start)?;
959    safe_replace_file(path, &bytes, "spreadsheet").await?;
960    Ok(bytes.len())
961}
962
963pub(super) async fn safe_replace_file(path: &Path, bytes: &[u8], label: &str) -> BuiltinResult<()> {
964    let temp_path = temporary_sibling_path(path);
965    let mut open_options = OpenOptions::new();
966    open_options.write(true).create_new(true);
967    let mut file = open_options.open_async(&temp_path).await.map_err(|err| {
968        writecell_error_with_source(
969            &WRITECELL_ERROR_IO,
970            format!(
971                "writecell: unable to create temporary {label} file \"{}\" ({err})",
972                temp_path.display()
973            ),
974            err,
975        )
976    })?;
977    file.write_all(bytes).map_err(|err| {
978        writecell_error_with_source(
979            &WRITECELL_ERROR_IO,
980            format!("writecell: failed to write spreadsheet ({err})"),
981            err,
982        )
983    })?;
984    file.flush_async().await.map_err(|err| {
985        writecell_error_with_source(
986            &WRITECELL_ERROR_IO,
987            format!("writecell: failed to flush temporary {label} file ({err})"),
988            err,
989        )
990    })?;
991    file.sync_all_async().await.map_err(|err| {
992        writecell_error_with_source(
993            &WRITECELL_ERROR_IO,
994            format!("writecell: failed to sync temporary {label} file ({err})"),
995            err,
996        )
997    })?;
998    drop(file);
999    if let Err(err) = runmat_filesystem::rename_async(&temp_path, path).await {
1000        let _ = runmat_filesystem::remove_file_async(&temp_path).await;
1001        return Err(writecell_error_with_source(
1002            &WRITECELL_ERROR_IO,
1003            format!(
1004                "writecell: failed to replace \"{}\" with temporary {label} file ({err})",
1005                path.display()
1006            ),
1007            err,
1008        ));
1009    }
1010    Ok(())
1011}
1012
1013fn temporary_sibling_path(path: &Path) -> PathBuf {
1014    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1015    let name = path
1016        .file_name()
1017        .and_then(|value| value.to_str())
1018        .unwrap_or("writecell");
1019    let nanos = std::time::SystemTime::now()
1020        .duration_since(std::time::UNIX_EPOCH)
1021        .map(|duration| duration.as_nanos())
1022        .unwrap_or_default();
1023    parent.join(format!(".{name}.runmat-tmp-{}-{nanos}", std::process::id()))
1024}
1025
1026pub(super) fn build_xlsx_workbook(
1027    table: &CellTable,
1028    sheet_name: &str,
1029    start: RangeStart,
1030) -> BuiltinResult<Vec<u8>> {
1031    let cursor = Cursor::new(Vec::new());
1032    let mut zip = zip::ZipWriter::new(cursor);
1033    write_xlsx_part(
1034        &mut zip,
1035        "[Content_Types].xml",
1036        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1037<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
1038  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
1039  <Default Extension="xml" ContentType="application/xml"/>
1040  <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
1041  <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
1042  <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
1043</Types>"#,
1044    )?;
1045    write_xlsx_part(
1046        &mut zip,
1047        "_rels/.rels",
1048        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1049<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1050  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
1051</Relationships>"#,
1052    )?;
1053    write_xlsx_part(
1054        &mut zip,
1055        "xl/workbook.xml",
1056        &format!(
1057            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1058<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
1059  <sheets>
1060    <sheet name="{}" sheetId="1" r:id="rId1"/>
1061  </sheets>
1062</workbook>"#,
1063            xml_attr_escape(sheet_name)
1064        ),
1065    )?;
1066    write_xlsx_part(
1067        &mut zip,
1068        "xl/_rels/workbook.xml.rels",
1069        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1070<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1071  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
1072  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
1073</Relationships>"#,
1074    )?;
1075    write_xlsx_part(
1076        &mut zip,
1077        "xl/styles.xml",
1078        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1079<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1080  <fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
1081  <fills count="1"><fill><patternFill patternType="none"/></fill></fills>
1082  <borders count="1"><border/></borders>
1083  <cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
1084  <cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellXfs>
1085</styleSheet>"#,
1086    )?;
1087    write_xlsx_part(
1088        &mut zip,
1089        "xl/worksheets/sheet1.xml",
1090        &build_sheet_xml(table, start),
1091    )?;
1092    let cursor = zip.finish().map_err(|err| {
1093        writecell_error_with_source(
1094            &WRITECELL_ERROR_IO,
1095            format!("writecell: failed to finish spreadsheet package ({err})"),
1096            err,
1097        )
1098    })?;
1099    Ok(cursor.into_inner())
1100}
1101
1102pub(super) fn write_xlsx_part(
1103    zip: &mut zip::ZipWriter<Cursor<Vec<u8>>>,
1104    name: &str,
1105    contents: &str,
1106) -> BuiltinResult<()> {
1107    let options =
1108        zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
1109    zip.start_file(name, options).map_err(|err| {
1110        writecell_error_with_source(
1111            &WRITECELL_ERROR_IO,
1112            format!("writecell: failed to start spreadsheet part {name} ({err})"),
1113            err,
1114        )
1115    })?;
1116    zip.write_all(contents.as_bytes()).map_err(|err| {
1117        writecell_error_with_source(
1118            &WRITECELL_ERROR_IO,
1119            format!("writecell: failed to write spreadsheet part {name} ({err})"),
1120            err,
1121        )
1122    })?;
1123    Ok(())
1124}
1125
1126pub(super) fn build_sheet_xml(table: &CellTable, start: RangeStart) -> String {
1127    let mut xml = String::from(
1128        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1129<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1130  <sheetData>
1131"#,
1132    );
1133    for row in 0..table.rows {
1134        let excel_row = start.row + row + 1;
1135        xml.push_str(&format!(r#"    <row r="{excel_row}">"#));
1136        xml.push('\n');
1137        for col in 0..table.cols {
1138            let cell = table.get(row, col);
1139            if *cell == CellValue::Empty {
1140                continue;
1141            }
1142            let reference = cell_reference(start.row + row, start.col + col);
1143            match cell {
1144                CellValue::Empty => {}
1145                CellValue::Number(value) => {
1146                    xml.push_str(&format!(
1147                        "      <c r=\"{reference}\"><v>{}</v></c>\n",
1148                        format_numeric(*value)
1149                    ));
1150                }
1151                CellValue::Boolean(value) => {
1152                    xml.push_str(&format!(
1153                        "      <c r=\"{reference}\" t=\"b\"><v>{}</v></c>\n",
1154                        if *value { 1 } else { 0 }
1155                    ));
1156                }
1157                CellValue::Text(text) => {
1158                    xml.push_str(&format!(
1159                        "      <c r=\"{reference}\" t=\"inlineStr\"><is><t>{}</t></is></c>\n",
1160                        xml_text_escape(text)
1161                    ));
1162                }
1163            }
1164        }
1165        xml.push_str("    </row>\n");
1166    }
1167    xml.push_str("  </sheetData>\n</worksheet>");
1168    xml
1169}
1170
1171fn format_cell_for_text(cell: &CellValue, options: &WriteCellOptions, delimiter: &str) -> String {
1172    match cell {
1173        CellValue::Empty => String::new(),
1174        CellValue::Number(value) => format_numeric(*value),
1175        CellValue::Boolean(value) => {
1176            if *value {
1177                "1".to_string()
1178            } else {
1179                "0".to_string()
1180            }
1181        }
1182        CellValue::Text(text) => format_string(text, options.quote_strings, delimiter),
1183    }
1184}
1185
1186fn format_numeric(value: f64) -> String {
1187    if value.is_nan() {
1188        return "NaN".to_string();
1189    }
1190    if value.is_infinite() {
1191        return if value.is_sign_negative() {
1192            "-Inf".to_string()
1193        } else {
1194            "Inf".to_string()
1195        };
1196    }
1197
1198    let abs = value.abs();
1199    let scientific = abs != 0.0 && !(1e-4..1e15).contains(&abs);
1200    let raw = if scientific {
1201        format!("{:.15e}", value)
1202    } else {
1203        format!("{:.15}", value)
1204    };
1205    trim_trailing_zeros(raw)
1206}
1207
1208fn trim_trailing_zeros(mut value: String) -> String {
1209    if let Some(exp_pos) = value.find(['e', 'E']) {
1210        let exponent = value.split_off(exp_pos);
1211        while value.ends_with('0') {
1212            value.pop();
1213        }
1214        if value.ends_with('.') {
1215            value.pop();
1216        }
1217        value.push_str(&exponent);
1218        value
1219    } else {
1220        if value.contains('.') {
1221            while value.ends_with('0') {
1222                value.pop();
1223            }
1224            if value.ends_with('.') {
1225                value.pop();
1226            }
1227        }
1228        if value == "-0" || value.is_empty() {
1229            "0".to_string()
1230        } else {
1231            value
1232        }
1233    }
1234}
1235
1236fn format_string(value: &str, quote: bool, _delimiter: &str) -> String {
1237    if !quote {
1238        return value.to_string();
1239    }
1240    let mut escaped = String::with_capacity(value.len() + 2);
1241    escaped.push('"');
1242    for ch in value.chars() {
1243        if ch == '"' {
1244            escaped.push('"');
1245            escaped.push('"');
1246        } else {
1247            escaped.push(ch);
1248        }
1249    }
1250    escaped.push('"');
1251    escaped
1252}
1253
1254fn string_scalar_from_value(value: &Value, context: &str) -> Result<String, String> {
1255    match value {
1256        Value::String(s) => Ok(s.clone()),
1257        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
1258        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
1259        _ => Err(format!(
1260            "writecell: expected {context} as a string scalar or character vector"
1261        )),
1262    }
1263}
1264
1265fn resolve_path(value: &Value) -> BuiltinResult<PathBuf> {
1266    match value {
1267        Value::String(s) => normalize_path(s),
1268        Value::CharArray(ca) if ca.rows == 1 => {
1269            let text: String = ca.data.iter().collect();
1270            normalize_path(&text)
1271        }
1272        Value::CharArray(_) => Err(writecell_error_with(
1273            &WRITECELL_ERROR_FILENAME,
1274            "writecell: expected a 1-by-N character vector for the filename",
1275        )),
1276        Value::StringArray(sa) if sa.data.len() == 1 => normalize_path(&sa.data[0]),
1277        Value::StringArray(_) => Err(writecell_error_with(
1278            &WRITECELL_ERROR_FILENAME,
1279            "writecell: filename string array inputs must be scalar",
1280        )),
1281        other => Err(writecell_error_with(
1282            &WRITECELL_ERROR_FILENAME,
1283            format!(
1284                "writecell: expected filename as string scalar or character vector, got {other:?}"
1285            ),
1286        )),
1287    }
1288}
1289
1290fn normalize_path(raw: &str) -> BuiltinResult<PathBuf> {
1291    if raw.trim().is_empty() {
1292        return Err(writecell_error_with(
1293            &WRITECELL_ERROR_FILENAME,
1294            "writecell: filename must not be empty",
1295        ));
1296    }
1297    let expanded = expand_user_path(raw, BUILTIN_NAME)
1298        .map_err(|msg| writecell_error_with(&WRITECELL_ERROR_FILENAME, msg))?;
1299    Ok(Path::new(&expanded).to_path_buf())
1300}
1301
1302fn default_delimiter_for_path(path: &Path) -> String {
1303    match path_extension_lower(path).as_deref() {
1304        Some("csv") => ",".to_string(),
1305        Some("tsv") | Some("tab") => "\t".to_string(),
1306        Some("txt") | Some("dat") | Some("dlm") => " ".to_string(),
1307        _ => ",".to_string(),
1308    }
1309}
1310
1311fn path_extension_lower(path: &Path) -> Option<String> {
1312    path.extension()
1313        .and_then(|s| s.to_str())
1314        .map(|s| s.to_ascii_lowercase())
1315}
1316
1317fn sanitize_sheet_name(value: &str) -> String {
1318    let mut name: String = value
1319        .chars()
1320        .map(|ch| match ch {
1321            ':' | '\\' | '/' | '?' | '*' | '[' | ']' => '_',
1322            _ => ch,
1323        })
1324        .take(31)
1325        .collect();
1326    if name.trim().is_empty() {
1327        name = "Sheet1".to_string();
1328    }
1329    name
1330}
1331
1332fn cell_reference(row: usize, col: usize) -> String {
1333    format!("{}{}", column_letters(col), row + 1)
1334}
1335
1336fn column_letters(mut col: usize) -> String {
1337    let mut letters = Vec::new();
1338    col += 1;
1339    while col > 0 {
1340        let rem = (col - 1) % 26;
1341        letters.push((b'A' + rem as u8) as char);
1342        col = (col - 1) / 26;
1343    }
1344    letters.iter().rev().collect()
1345}
1346
1347fn xml_text_escape(value: &str) -> String {
1348    value
1349        .chars()
1350        .map(|ch| match ch {
1351            '&' => "&amp;".to_string(),
1352            '<' => "&lt;".to_string(),
1353            '>' => "&gt;".to_string(),
1354            _ => ch.to_string(),
1355        })
1356        .collect()
1357}
1358
1359pub(super) fn xml_attr_escape(value: &str) -> String {
1360    value
1361        .chars()
1362        .map(|ch| match ch {
1363            '&' => "&amp;".to_string(),
1364            '<' => "&lt;".to_string(),
1365            '>' => "&gt;".to_string(),
1366            '"' => "&quot;".to_string(),
1367            '\'' => "&apos;".to_string(),
1368            _ => ch.to_string(),
1369        })
1370        .collect()
1371}
1372
1373#[cfg(test)]
1374mod tests {
1375    use super::*;
1376    use calamine::{open_workbook_auto, Data, Reader};
1377    use futures::executor::block_on;
1378    use runmat_time::unix_timestamp_ms;
1379    use std::fs;
1380    use std::sync::atomic::{AtomicU64, Ordering};
1381    #[cfg(not(target_arch = "wasm32"))]
1382    use std::sync::mpsc;
1383    #[cfg(not(target_arch = "wasm32"))]
1384    use std::sync::Barrier;
1385    #[cfg(not(target_arch = "wasm32"))]
1386    use std::thread;
1387    #[cfg(not(target_arch = "wasm32"))]
1388    use std::time::Duration;
1389
1390    use runmat_builtins::{CharArray, LogicalArray, Tensor};
1391
1392    static NEXT_ID: AtomicU64 = AtomicU64::new(0);
1393
1394    fn temp_path(ext: &str) -> PathBuf {
1395        let millis = unix_timestamp_ms();
1396        let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
1397        let mut path = std::env::temp_dir();
1398        path.push(format!(
1399            "runmat_writecell_{}_{}_{}.{}",
1400            std::process::id(),
1401            millis,
1402            unique,
1403            ext
1404        ));
1405        path
1406    }
1407
1408    fn cell(values: Vec<Value>, rows: usize, cols: usize) -> Value {
1409        Value::Cell(CellArray::new(values, rows, cols).expect("cell array"))
1410    }
1411
1412    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1413    #[test]
1414    fn writecell_descriptor_signatures_cover_core_forms() {
1415        let labels: Vec<&str> = WRITECELL_DESCRIPTOR
1416            .signatures
1417            .iter()
1418            .map(|sig| sig.label)
1419            .collect();
1420        assert!(labels.contains(&"bytesWritten = writecell(C, filename)"));
1421        assert!(labels.contains(&"bytesWritten = writecell(C, filename, name, optionValue)"));
1422        assert!(labels.contains(&"bytesWritten = writecell(C, filename, nameValuePairs...)"));
1423    }
1424
1425    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1426    #[test]
1427    fn writecell_writes_heterogeneous_csv() {
1428        let path = temp_path("csv");
1429        let filename = path.to_string_lossy().into_owned();
1430        let values = cell(
1431            vec![
1432                Value::Num(1.5),
1433                Value::from("alpha"),
1434                Value::Bool(true),
1435                Value::Tensor(Tensor::new(Vec::new(), vec![0, 0]).expect("empty tensor")),
1436            ],
1437            2,
1438            2,
1439        );
1440
1441        block_on(writecell_builtin(values, vec![Value::from(filename)])).expect("writecell");
1442
1443        let contents = fs::read_to_string(&path).expect("read contents");
1444        assert_eq!(contents, "1.5,\"alpha\"\n1,\n");
1445        let _ = fs::remove_file(path);
1446    }
1447
1448    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1449    #[test]
1450    fn writecell_honours_delimiter_quote_strings_and_append() {
1451        let path = temp_path("txt");
1452        let filename = path.to_string_lossy().into_owned();
1453        let first = cell(vec![Value::from("a,b"), Value::Num(2.0)], 1, 2);
1454        let second = cell(vec![Value::from("tail"), Value::Num(3.0)], 1, 2);
1455
1456        block_on(writecell_builtin(
1457            first,
1458            vec![
1459                Value::from(filename.clone()),
1460                Value::from("Delimiter"),
1461                Value::from("|"),
1462                Value::from("QuoteStrings"),
1463                Value::Bool(false),
1464            ],
1465        ))
1466        .expect("initial write");
1467        block_on(writecell_builtin(
1468            second,
1469            vec![
1470                Value::from(filename.clone()),
1471                Value::from("Delimiter"),
1472                Value::from("|"),
1473                Value::from("QuoteStrings"),
1474                Value::Bool(false),
1475                Value::from("WriteMode"),
1476                Value::from("append"),
1477            ],
1478        ))
1479        .expect("append write");
1480
1481        let contents = fs::read_to_string(&path).expect("read contents");
1482        assert_eq!(contents, "a,b|2\ntail|3\n");
1483        let _ = fs::remove_file(path);
1484    }
1485
1486    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1487    #[test]
1488    fn writecell_append_inserts_missing_row_boundary() {
1489        let path = temp_path("txt");
1490        fs::write(&path, "existing").expect("seed");
1491        let filename = path.to_string_lossy().into_owned();
1492        let values = cell(vec![Value::from("tail"), Value::Num(3.0)], 1, 2);
1493
1494        block_on(writecell_builtin(
1495            values,
1496            vec![
1497                Value::from(filename),
1498                Value::from("Delimiter"),
1499                Value::from("|"),
1500                Value::from("QuoteStrings"),
1501                Value::Bool(false),
1502                Value::from("WriteMode"),
1503                Value::from("append"),
1504            ],
1505        ))
1506        .expect("append write");
1507
1508        let contents = fs::read_to_string(&path).expect("read contents");
1509        assert_eq!(contents, "existing\ntail|3\n");
1510        let _ = fs::remove_file(path);
1511    }
1512
1513    #[cfg(not(target_arch = "wasm32"))]
1514    #[test]
1515    fn writecell_concurrent_appends_share_one_boundary_insertion() {
1516        let path = temp_path("txt");
1517        fs::write(&path, "existing").expect("seed");
1518        let filename = path.to_string_lossy().into_owned();
1519        let writers = 8usize;
1520        let barrier = Arc::new(Barrier::new(writers));
1521        let mut handles = Vec::new();
1522        for idx in 0..writers {
1523            let barrier = Arc::clone(&barrier);
1524            let filename = filename.clone();
1525            handles.push(thread::spawn(move || {
1526                barrier.wait();
1527                let values = cell(
1528                    vec![Value::from(format!("row{idx}")), Value::Num(idx as f64)],
1529                    1,
1530                    2,
1531                );
1532                block_on(writecell_builtin(
1533                    values,
1534                    vec![
1535                        Value::from(filename),
1536                        Value::from("Delimiter"),
1537                        Value::from("|"),
1538                        Value::from("QuoteStrings"),
1539                        Value::Bool(false),
1540                        Value::from("WriteMode"),
1541                        Value::from("append"),
1542                    ],
1543                ))
1544                .expect("append write");
1545            }));
1546        }
1547        for handle in handles {
1548            handle.join().expect("writer thread");
1549        }
1550
1551        let contents = fs::read_to_string(&path).expect("read contents");
1552        let lines = contents.lines().collect::<Vec<_>>();
1553        assert_eq!(lines.len(), writers + 1);
1554        assert_eq!(lines[0], "existing");
1555        assert!(lines.iter().all(|line| !line.is_empty()));
1556        for idx in 0..writers {
1557            let expected = format!("row{idx}|{idx}");
1558            assert!(lines.iter().any(|line| *line == expected));
1559        }
1560        let _ = fs::remove_file(path);
1561    }
1562
1563    #[cfg(not(target_arch = "wasm32"))]
1564    #[test]
1565    fn writecell_overwrite_uses_same_path_write_lock() {
1566        let path = temp_path("txt");
1567        fs::write(&path, "existing\n").expect("seed");
1568        let filename = path.to_string_lossy().into_owned();
1569        let lock = block_on(write_lock_for_path(&path));
1570        let guard = block_on(lock.lock());
1571        let (tx, rx) = mpsc::channel();
1572
1573        let handle = thread::spawn(move || {
1574            let values = cell(vec![Value::from("replacement")], 1, 1);
1575            block_on(writecell_builtin(values, vec![Value::from(filename)]))
1576                .expect("overwrite write");
1577            tx.send(()).expect("send completion");
1578        });
1579
1580        thread::sleep(Duration::from_millis(50));
1581        assert!(rx.try_recv().is_err());
1582        drop(guard);
1583        handle.join().expect("writer thread");
1584        rx.recv_timeout(Duration::from_secs(1))
1585            .expect("overwrite completion");
1586
1587        let contents = fs::read_to_string(&path).expect("read contents");
1588        assert_eq!(contents, "\"replacement\"\n");
1589        let _ = fs::remove_file(path);
1590    }
1591
1592    #[cfg(unix)]
1593    #[test]
1594    fn writecell_canonical_aliases_share_write_lock() {
1595        let path = temp_path("txt");
1596        fs::write(&path, "").expect("seed");
1597        let mut link = path.clone();
1598        link.set_extension("link.txt");
1599        std::os::unix::fs::symlink(&path, &link).expect("symlink");
1600
1601        let direct = block_on(write_lock_for_path(&path));
1602        let alias = block_on(write_lock_for_path(&link));
1603        assert!(Arc::ptr_eq(&direct, &alias));
1604
1605        let _ = fs::remove_file(link);
1606        let _ = fs::remove_file(path);
1607    }
1608
1609    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1610    #[test]
1611    fn writecell_accepts_scalar_char_tensor_and_logical_cells() {
1612        let path = temp_path("csv");
1613        let filename = path.to_string_lossy().into_owned();
1614        let values = cell(
1615            vec![
1616                Value::CharArray(CharArray::new_row("name")),
1617                Value::Tensor(Tensor::new(vec![42.0], vec![1, 1]).expect("scalar tensor")),
1618                Value::LogicalArray(LogicalArray::new(vec![0], vec![1, 1]).expect("logical")),
1619            ],
1620            1,
1621            3,
1622        );
1623
1624        block_on(writecell_builtin(values, vec![Value::from(filename)])).expect("writecell");
1625
1626        let contents = fs::read_to_string(&path).expect("read contents");
1627        assert_eq!(contents, "\"name\",42,0\n");
1628        let _ = fs::remove_file(path);
1629    }
1630
1631    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1632    #[test]
1633    fn writecell_rejects_nested_cells_and_nonscalar_arrays() {
1634        let path = temp_path("csv");
1635        let filename = path.to_string_lossy().into_owned();
1636        let nested = cell(vec![cell(vec![Value::Num(1.0)], 1, 1)], 1, 1);
1637        let err = block_on(writecell_builtin(
1638            nested,
1639            vec![Value::from(filename.clone())],
1640        ))
1641        .expect_err("nested cell error");
1642        assert!(err.message().contains("nested cell arrays"));
1643
1644        let nonscalar = cell(
1645            vec![Value::Tensor(
1646                Tensor::new(vec![1.0, 2.0], vec![1, 2]).expect("tensor"),
1647            )],
1648            1,
1649            1,
1650        );
1651        let err = block_on(writecell_builtin(nonscalar, vec![Value::from(filename)]))
1652            .expect_err("nonscalar error");
1653        assert!(err.message().contains("unsupported cell value"));
1654    }
1655
1656    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1657    #[test]
1658    fn writecell_writes_xlsx_with_sheet_and_range() {
1659        let path = temp_path("xlsx");
1660        let filename = path.to_string_lossy().into_owned();
1661        let values = cell(
1662            vec![Value::from("Voltage"), Value::Num(1.5), Value::Bool(true)],
1663            1,
1664            3,
1665        );
1666
1667        block_on(writecell_builtin(
1668            values,
1669            vec![
1670                Value::from(filename),
1671                Value::from("Sheet"),
1672                Value::from("Measurements"),
1673                Value::from("Range"),
1674                Value::from("B2"),
1675            ],
1676        ))
1677        .expect("writecell xlsx");
1678
1679        let mut workbook = open_workbook_auto(&path).expect("open workbook");
1680        assert_eq!(workbook.sheet_names()[0], "Measurements");
1681        let range = workbook
1682            .worksheet_range("Measurements")
1683            .expect("worksheet range");
1684        assert_eq!(
1685            range.get((0, 0)),
1686            Some(&Data::String("Voltage".to_string()))
1687        );
1688        assert_eq!(range.get((0, 1)), Some(&Data::Float(1.5)));
1689        assert_eq!(range.get((0, 2)), Some(&Data::Bool(true)));
1690        let _ = fs::remove_file(path);
1691    }
1692
1693    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1694    #[test]
1695    fn writecell_rejects_unsupported_spreadsheet_extension() {
1696        let path = temp_path("xls");
1697        let filename = path.to_string_lossy().into_owned();
1698        let values = cell(vec![Value::from("A"), Value::Num(1.0)], 1, 2);
1699        let err = block_on(writecell_builtin(values, vec![Value::from(filename)]))
1700            .expect_err("unsupported extension");
1701        assert!(err
1702            .message()
1703            .contains("unsupported spreadsheet file extension"));
1704        let _ = fs::remove_file(path);
1705    }
1706}