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