use std::collections::HashMap;
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex, OnceLock, Weak};
use futures::lock::Mutex as AsyncMutex;
use runmat_builtins::{
BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
CellArray, Value,
};
use runmat_filesystem::{File, OpenOptions};
use runmat_macros::runtime_builtin;
use crate::builtins::common::fs::expand_user_path;
use crate::builtins::common::spec::{
BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
ReductionNaN, ResidencyPolicy, ShapeRequirements,
};
use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
const BUILTIN_NAME: &str = "writecell";
const MAX_EXCEL_ROW_INDEX: usize = 1_048_575;
const MAX_EXCEL_COLUMN_INDEX: usize = 16_383;
type WriteLock = Arc<AsyncMutex<()>>;
type WeakWriteLock = Weak<AsyncMutex<()>>;
static WRITE_LOCKS: OnceLock<StdMutex<HashMap<String, WeakWriteLock>>> = OnceLock::new();
const WRITECELL_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
name: "bytesWritten",
ty: BuiltinParamType::NumericScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "Number of bytes written to the destination file.",
}];
const WRITECELL_INPUTS_CELL_FILENAME: [BuiltinParamDescriptor; 2] = [
BuiltinParamDescriptor {
name: "C",
ty: BuiltinParamType::Any,
arity: BuiltinParamArity::Required,
default: None,
description: "Cell array to write.",
},
BuiltinParamDescriptor {
name: "filename",
ty: BuiltinParamType::StringScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "Output file path.",
},
];
const WRITECELL_INPUTS_NAME_VALUE: [BuiltinParamDescriptor; 4] = [
BuiltinParamDescriptor {
name: "C",
ty: BuiltinParamType::Any,
arity: BuiltinParamArity::Required,
default: None,
description: "Cell array to write.",
},
BuiltinParamDescriptor {
name: "filename",
ty: BuiltinParamType::StringScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "Output file path.",
},
BuiltinParamDescriptor {
name: "name",
ty: BuiltinParamType::StringScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "Option name.",
},
BuiltinParamDescriptor {
name: "optionValue",
ty: BuiltinParamType::Any,
arity: BuiltinParamArity::Required,
default: None,
description: "Value for the preceding option name.",
},
];
const WRITECELL_INPUTS_NAME_VALUE_PAIRS: [BuiltinParamDescriptor; 3] = [
BuiltinParamDescriptor {
name: "C",
ty: BuiltinParamType::Any,
arity: BuiltinParamArity::Required,
default: None,
description: "Cell array to write.",
},
BuiltinParamDescriptor {
name: "filename",
ty: BuiltinParamType::StringScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "Output file path.",
},
BuiltinParamDescriptor {
name: "nameValuePairs...",
ty: BuiltinParamType::Any,
arity: BuiltinParamArity::Variadic,
default: None,
description: "Name-value option pairs.",
},
];
const WRITECELL_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
BuiltinSignatureDescriptor {
label: "bytesWritten = writecell(C, filename)",
inputs: &WRITECELL_INPUTS_CELL_FILENAME,
outputs: &WRITECELL_OUTPUT,
},
BuiltinSignatureDescriptor {
label: "bytesWritten = writecell(C, filename, name, optionValue)",
inputs: &WRITECELL_INPUTS_NAME_VALUE,
outputs: &WRITECELL_OUTPUT,
},
BuiltinSignatureDescriptor {
label: "bytesWritten = writecell(C, filename, nameValuePairs...)",
inputs: &WRITECELL_INPUTS_NAME_VALUE_PAIRS,
outputs: &WRITECELL_OUTPUT,
},
];
const WRITECELL_ERROR_ARG_CONFIG: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.WRITECELL.ARG_CONFIG",
identifier: None,
when: "Filename argument is missing or name-value options are malformed.",
message: "writecell: invalid argument configuration",
};
const WRITECELL_ERROR_FILENAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.WRITECELL.FILENAME",
identifier: None,
when: "Filename is not a valid scalar path string.",
message: "writecell: invalid filename input",
};
const WRITECELL_ERROR_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.WRITECELL.OPTION",
identifier: None,
when: "A provided option value is invalid.",
message: "writecell: invalid option value",
};
const WRITECELL_ERROR_DATA: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.WRITECELL.DATA",
identifier: None,
when: "Input data cannot be converted into supported cell export rows.",
message: "writecell: invalid input data",
};
const WRITECELL_ERROR_DATA_SHAPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.WRITECELL.DATA_SHAPE",
identifier: None,
when: "Input cell array has unsupported dimensionality.",
message: "writecell: input must be 2-D",
};
const WRITECELL_ERROR_IO: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.WRITECELL.IO",
identifier: None,
when: "The destination file cannot be opened or written.",
message: "writecell: file write failed",
};
const WRITECELL_ERRORS: [BuiltinErrorDescriptor; 6] = [
WRITECELL_ERROR_ARG_CONFIG,
WRITECELL_ERROR_FILENAME,
WRITECELL_ERROR_OPTION,
WRITECELL_ERROR_DATA,
WRITECELL_ERROR_DATA_SHAPE,
WRITECELL_ERROR_IO,
];
pub const WRITECELL_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &WRITECELL_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &WRITECELL_ERRORS,
};
#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::tabular::writecell")]
pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
name: "writecell",
op_kind: GpuOpKind::Custom("io-writecell"),
supported_precisions: &[],
broadcast: BroadcastSemantics::None,
provider_hooks: &[],
constant_strategy: ConstantStrategy::InlineLiteral,
residency: ResidencyPolicy::GatherImmediately,
nan_mode: ReductionNaN::Include,
two_pass_threshold: None,
workgroup_size: None,
accepts_nan_mode: false,
notes:
"Runs entirely on the host; gpuArray values inside cells are gathered before serialisation.",
};
#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::tabular::writecell")]
pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
name: "writecell",
shape: ShapeRequirements::Any,
constant_strategy: ConstantStrategy::InlineLiteral,
elementwise: None,
reduction: None,
emits_nan: false,
notes: "Not eligible for fusion; performs host-side file I/O.",
};
fn writecell_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
writecell_error_with(error, error.message)
}
fn writecell_error_with(
error: &'static BuiltinErrorDescriptor,
message: impl Into<String>,
) -> RuntimeError {
let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
if let Some(identifier) = error.identifier {
builder = builder.with_identifier(identifier);
}
builder.build()
}
fn writecell_error_with_source<E>(
error: &'static BuiltinErrorDescriptor,
message: impl Into<String>,
source: E,
) -> RuntimeError
where
E: std::error::Error + Send + Sync + 'static,
{
let mut builder = build_runtime_error(message)
.with_builtin(BUILTIN_NAME)
.with_source(source);
if let Some(identifier) = error.identifier {
builder = builder.with_identifier(identifier);
}
builder.build()
}
fn map_control_flow(err: RuntimeError) -> RuntimeError {
let identifier = err.identifier().map(|value| value.to_string());
let message = err.message().to_string();
let mut builder = build_runtime_error(message)
.with_builtin(BUILTIN_NAME)
.with_source(err);
if let Some(identifier) = identifier {
builder = builder.with_identifier(identifier);
}
builder.build()
}
#[runtime_builtin(
name = "writecell",
category = "io/tabular",
summary = "Write heterogeneous cell arrays to delimited text or spreadsheet files.",
keywords = "writecell,csv,xlsx,xls,cell array,delimited text,spreadsheet,append,quote strings",
accel = "cpu",
type_resolver(crate::builtins::io::type_resolvers::num_type),
descriptor(crate::builtins::io::tabular::writecell::WRITECELL_DESCRIPTOR),
builtin_path = "crate::builtins::io::tabular::writecell"
)]
async fn writecell_builtin(data: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
if rest.is_empty() {
return Err(writecell_error(&WRITECELL_ERROR_ARG_CONFIG));
}
let filename_value = gather_if_needed_async(&rest[0])
.await
.map_err(map_control_flow)?;
let path = resolve_path(&filename_value)?;
let options = parse_options(&rest[1..]).await?;
let gathered = gather_if_needed_async(&data)
.await
.map_err(map_control_flow)?;
let table = CellTable::from_value(gathered).await?;
let bytes_written = match options.resolve_file_type(&path)? {
OutputFileType::DelimitedText => write_delimited_cells(&path, &table, &options).await?,
OutputFileType::Spreadsheet => write_spreadsheet_cells(&path, &table, &options).await?,
};
Ok(Value::Num(bytes_written as f64))
}
#[derive(Debug, Clone)]
struct WriteCellOptions {
delimiter: Option<String>,
write_mode: WriteMode,
quote_strings: bool,
line_ending: LineEnding,
file_type: Option<OutputFileType>,
sheet: SheetSelector,
range: Option<RangeStart>,
}
impl Default for WriteCellOptions {
fn default() -> Self {
Self {
delimiter: None,
write_mode: WriteMode::Overwrite,
quote_strings: true,
line_ending: LineEnding::Auto,
file_type: None,
sheet: SheetSelector::Default,
range: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WriteMode {
Overwrite,
Append,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LineEnding {
Auto,
Unix,
Windows,
Mac,
}
impl LineEnding {
fn as_str(self) -> &'static str {
match self {
LineEnding::Auto | LineEnding::Unix => "\n",
LineEnding::Windows => "\r\n",
LineEnding::Mac => "\r",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutputFileType {
DelimitedText,
Spreadsheet,
}
#[derive(Debug, Clone)]
enum SheetSelector {
Default,
Name(String),
Index(usize),
}
#[derive(Debug, Clone, Copy, Default)]
struct RangeStart {
row: usize,
col: usize,
}
impl WriteCellOptions {
fn resolve_file_type(&self, path: &Path) -> BuiltinResult<OutputFileType> {
if let Some(file_type) = self.file_type {
if file_type == OutputFileType::Spreadsheet {
ensure_supported_spreadsheet_extension(path)?;
}
return Ok(file_type);
}
match path_extension_lower(path).as_deref() {
Some("xlsx") | Some("xlsm") => Ok(OutputFileType::Spreadsheet),
Some(ext) if is_unsupported_spreadsheet_extension(ext) => Err(writecell_error_with(
&WRITECELL_ERROR_OPTION,
format!("writecell: unsupported spreadsheet file extension '.{ext}'"),
)),
_ => Ok(OutputFileType::DelimitedText),
}
}
fn resolve_delimiter(&self, path: &Path) -> String {
self.delimiter
.clone()
.unwrap_or_else(|| default_delimiter_for_path(path))
}
fn sheet_name(&self) -> String {
match &self.sheet {
SheetSelector::Default => "Sheet1".to_string(),
SheetSelector::Name(name) => sanitize_sheet_name(name),
SheetSelector::Index(index) => format!("Sheet{index}"),
}
}
fn range_start(&self) -> RangeStart {
self.range.unwrap_or_default()
}
}
fn ensure_supported_spreadsheet_extension(path: &Path) -> BuiltinResult<()> {
match path_extension_lower(path).as_deref() {
Some("xlsx") | Some("xlsm") => Ok(()),
Some(ext) => Err(writecell_error_with(
&WRITECELL_ERROR_OPTION,
format!("writecell: unsupported spreadsheet file extension '.{ext}'"),
)),
None => Err(writecell_error_with(
&WRITECELL_ERROR_OPTION,
"writecell: spreadsheet output requires an .xlsx or .xlsm extension",
)),
}
}
fn is_unsupported_spreadsheet_extension(ext: &str) -> bool {
matches!(ext, "xls" | "xlsb" | "ods")
}
async fn parse_options(args: &[Value]) -> BuiltinResult<WriteCellOptions> {
if args.is_empty() {
return Ok(WriteCellOptions::default());
}
if !args.len().is_multiple_of(2) {
return Err(writecell_error(&WRITECELL_ERROR_ARG_CONFIG));
}
let mut options = WriteCellOptions::default();
let mut index = 0usize;
while index < args.len() {
let name_value = gather_if_needed_async(&args[index])
.await
.map_err(map_control_flow)?;
let name = string_scalar_from_value(&name_value, "option name")
.map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
let value = gather_if_needed_async(&args[index + 1])
.await
.map_err(map_control_flow)?;
apply_option(&mut options, &name, &value)?;
index += 2;
}
Ok(options)
}
fn apply_option(options: &mut WriteCellOptions, name: &str, value: &Value) -> BuiltinResult<()> {
if name.eq_ignore_ascii_case("Delimiter") {
options.delimiter = Some(parse_delimiter(value)?);
return Ok(());
}
if name.eq_ignore_ascii_case("WriteMode") {
options.write_mode = parse_write_mode(value)?;
return Ok(());
}
if name.eq_ignore_ascii_case("QuoteStrings") {
options.quote_strings = parse_bool_like(value, "QuoteStrings")?;
return Ok(());
}
if name.eq_ignore_ascii_case("LineEnding") {
options.line_ending = parse_line_ending(value)?;
return Ok(());
}
if name.eq_ignore_ascii_case("FileType") {
options.file_type = Some(parse_file_type(value)?);
return Ok(());
}
if name.eq_ignore_ascii_case("Sheet") {
options.sheet = parse_sheet(value)?;
return Ok(());
}
if name.eq_ignore_ascii_case("Range") {
options.range = Some(parse_range_start(value)?);
return Ok(());
}
Ok(())
}
fn parse_delimiter(value: &Value) -> BuiltinResult<String> {
let text = string_scalar_from_value(value, "Delimiter")
.map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
if text.is_empty() {
return Err(writecell_error_with(
&WRITECELL_ERROR_OPTION,
"writecell: Delimiter cannot be empty",
));
}
let trimmed = text.trim();
match trimmed.to_ascii_lowercase().as_str() {
"tab" => Ok("\t".to_string()),
"space" | "whitespace" => Ok(" ".to_string()),
"comma" => Ok(",".to_string()),
"semicolon" => Ok(";".to_string()),
"pipe" => Ok("|".to_string()),
_ => Ok(trimmed.to_string()),
}
}
fn parse_write_mode(value: &Value) -> BuiltinResult<WriteMode> {
let text = string_scalar_from_value(value, "WriteMode")
.map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
match text.trim().to_ascii_lowercase().as_str() {
"overwrite" => Ok(WriteMode::Overwrite),
"append" => Ok(WriteMode::Append),
_ => Err(writecell_error_with(
&WRITECELL_ERROR_OPTION,
"writecell: WriteMode must be 'overwrite' or 'append'",
)),
}
}
fn parse_bool_like(value: &Value, context: &str) -> BuiltinResult<bool> {
match value {
Value::Bool(b) => Ok(*b),
Value::Int(i) => match i.to_i64() {
0 => Ok(false),
1 => Ok(true),
_ => Err(writecell_error_with(
&WRITECELL_ERROR_OPTION,
format!("writecell: {context} must be logical (0 or 1)"),
)),
},
Value::Num(n) if (*n - 0.0).abs() < f64::EPSILON => Ok(false),
Value::Num(n) if (*n - 1.0).abs() < f64::EPSILON => Ok(true),
_ => {
let text = string_scalar_from_value(value, context)
.map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
match text.trim().to_ascii_lowercase().as_str() {
"on" | "true" | "yes" | "1" => Ok(true),
"off" | "false" | "no" | "0" => Ok(false),
_ => Err(writecell_error_with(
&WRITECELL_ERROR_OPTION,
format!("writecell: {context} must be logical (true/on or false/off)"),
)),
}
}
}
}
fn parse_line_ending(value: &Value) -> BuiltinResult<LineEnding> {
let text = string_scalar_from_value(value, "LineEnding")
.map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
match text.trim().to_ascii_lowercase().as_str() {
"auto" => Ok(LineEnding::Auto),
"unix" => Ok(LineEnding::Unix),
"pc" | "windows" => Ok(LineEnding::Windows),
"mac" => Ok(LineEnding::Mac),
_ => Err(writecell_error_with(
&WRITECELL_ERROR_OPTION,
"writecell: LineEnding must be 'auto', 'unix', 'pc', or 'mac'",
)),
}
}
fn parse_file_type(value: &Value) -> BuiltinResult<OutputFileType> {
let text = string_scalar_from_value(value, "FileType")
.map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
match text.trim().to_ascii_lowercase().as_str() {
"text" | "delimitedtext" => Ok(OutputFileType::DelimitedText),
"spreadsheet" => Ok(OutputFileType::Spreadsheet),
_ => Err(writecell_error_with(
&WRITECELL_ERROR_OPTION,
"writecell: FileType must be 'text', 'delimitedtext', or 'spreadsheet'",
)),
}
}
fn parse_sheet(value: &Value) -> BuiltinResult<SheetSelector> {
match value {
Value::Num(n) if n.is_finite() && *n >= 1.0 && n.fract() == 0.0 => {
Ok(SheetSelector::Index(*n as usize))
}
Value::Int(i) if i.to_i64() >= 1 => Ok(SheetSelector::Index(i.to_i64() as usize)),
_ => {
let text = string_scalar_from_value(value, "Sheet")
.map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
if text.trim().is_empty() {
return Err(writecell_error_with(
&WRITECELL_ERROR_OPTION,
"writecell: Sheet name cannot be empty",
));
}
Ok(SheetSelector::Name(text))
}
}
}
fn parse_range_start(value: &Value) -> BuiltinResult<RangeStart> {
let text = string_scalar_from_value(value, "Range")
.map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
let start = text.split(':').next().unwrap_or("").trim();
parse_a1_cell(start).ok_or_else(|| {
writecell_error_with(
&WRITECELL_ERROR_OPTION,
"writecell: Range must start with an Excel A1 cell reference",
)
})
}
fn parse_a1_cell(value: &str) -> Option<RangeStart> {
if value.is_empty() {
return None;
}
let mut col = 0usize;
let mut letters = 0usize;
for ch in value.chars() {
if ch.is_ascii_alphabetic() {
if letters == 0 && col != 0 {
return None;
}
col = col.checked_mul(26)?;
col = col.checked_add((ch.to_ascii_uppercase() as u8 - b'A' + 1) as usize)?;
letters += 1;
} else {
break;
}
}
let row_text = &value[letters..];
if letters == 0 || row_text.is_empty() || !row_text.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
let row: usize = row_text.parse().ok()?;
if row == 0 || col == 0 {
return None;
}
Some(RangeStart {
row: row - 1,
col: col - 1,
})
}
#[derive(Debug, Clone, PartialEq)]
enum CellValue {
Empty,
Number(f64),
Boolean(bool),
Text(String),
}
struct CellTable {
rows: usize,
cols: usize,
data: Vec<CellValue>,
}
impl CellTable {
async fn from_value(value: Value) -> BuiltinResult<Self> {
let cell = match value {
Value::Cell(cell) => cell,
other => {
return Err(writecell_error_with(
&WRITECELL_ERROR_DATA,
format!("writecell: input must be a cell array, got {other:?}"),
));
}
};
ensure_cell_shape(&cell)?;
let mut data = Vec::with_capacity(cell.data.len());
for row in 0..cell.rows {
for col in 0..cell.cols {
let value = cell.get(row, col).map_err(|message| {
writecell_error_with(&WRITECELL_ERROR_DATA, format!("writecell: {message}"))
})?;
let gathered = gather_if_needed_async(&value)
.await
.map_err(map_control_flow)?;
data.push(cell_value_from_value(gathered)?);
}
}
Ok(Self {
rows: cell.rows,
cols: cell.cols,
data,
})
}
fn get(&self, row: usize, col: usize) -> &CellValue {
&self.data[row * self.cols + col]
}
}
fn ensure_cell_shape(cell: &CellArray) -> BuiltinResult<()> {
if cell.shape.len() <= 2 || cell.shape[2..].iter().all(|&dim| dim == 1) {
return Ok(());
}
Err(writecell_error_with(
&WRITECELL_ERROR_DATA_SHAPE,
"writecell: input cell array must be 2-D",
))
}
fn cell_value_from_value(value: Value) -> BuiltinResult<CellValue> {
match value {
Value::Num(n) => Ok(CellValue::Number(n)),
Value::Int(i) => Ok(CellValue::Number(i.to_f64())),
Value::Bool(b) => Ok(CellValue::Boolean(b)),
Value::String(s) => Ok(CellValue::Text(s)),
Value::CharArray(ca) if ca.rows == 1 => Ok(CellValue::Text(ca.data.iter().collect())),
Value::StringArray(sa) if sa.data.len() == 1 => Ok(CellValue::Text(sa.data[0].clone())),
Value::StringArray(sa) if sa.data.is_empty() => Ok(CellValue::Empty),
Value::Tensor(tensor) if tensor.data.len() == 1 => Ok(CellValue::Number(tensor.data[0])),
Value::Tensor(tensor) if tensor.data.is_empty() => Ok(CellValue::Empty),
Value::LogicalArray(logical) if logical.data.len() == 1 => {
Ok(CellValue::Boolean(logical.data[0] != 0))
}
Value::LogicalArray(logical) if logical.data.is_empty() => Ok(CellValue::Empty),
Value::Complex(_, _) | Value::ComplexTensor(_) => Err(writecell_error_with(
&WRITECELL_ERROR_DATA,
"writecell: complex values are not supported; split real and imaginary parts first",
)),
Value::Cell(_) => Err(writecell_error_with(
&WRITECELL_ERROR_DATA,
"writecell: nested cell arrays are not supported",
)),
other => Err(writecell_error_with(
&WRITECELL_ERROR_DATA,
format!("writecell: unsupported cell value {other:?}"),
)),
}
}
async fn write_delimited_cells(
path: &Path,
table: &CellTable,
options: &WriteCellOptions,
) -> BuiltinResult<usize> {
let delimiter = options.resolve_delimiter(path);
let line_ending = options.line_ending.as_str();
let payload = build_delimited_payload(table, options, &delimiter, line_ending);
let write_lock = write_lock_for_path(path).await;
let _write_guard = write_lock.lock().await;
if options.write_mode == WriteMode::Overwrite {
safe_replace_file(path, &payload, "delimited text").await?;
return Ok(payload.len());
}
let mut open_options = OpenOptions::new();
open_options.create(true).write(true).append(true);
let mut file = open_options.open_async(path).await.map_err(|err| {
writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!(
"writecell: unable to open \"{}\" for writing ({err})",
path.display()
),
err,
)
})?;
let mut bytes_written = 0usize;
if append_needs_line_ending(path).await? {
file.write_all(line_ending.as_bytes()).map_err(|err| {
writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!("writecell: failed to write append line ending ({err})"),
err,
)
})?;
bytes_written += line_ending.len();
}
file.write_all(&payload).map_err(|err| {
writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!("writecell: failed to write delimited text ({err})"),
err,
)
})?;
bytes_written += payload.len();
file.flush_async().await.map_err(|err| {
writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!("writecell: failed to flush output ({err})"),
err,
)
})?;
Ok(bytes_written)
}
async fn write_lock_for_path(path: &Path) -> WriteLock {
let key = write_lock_key(path).await;
let locks = WRITE_LOCKS.get_or_init(|| StdMutex::new(HashMap::new()));
let mut locks = locks
.lock()
.expect("writecell write lock registry poisoned");
if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
return lock;
}
locks.retain(|_, lock| lock.strong_count() > 0);
let lock = Arc::new(AsyncMutex::new(()));
locks.insert(key, Arc::downgrade(&lock));
lock
}
async fn write_lock_key(path: &Path) -> String {
if let Ok(canonical) = runmat_filesystem::canonicalize_async(path).await {
return canonical.to_string_lossy().into_owned();
}
let absolute = lexical_absolute_path(path);
let mut candidate = absolute.as_path();
let mut suffix = PathBuf::new();
loop {
if let Ok(canonical) = runmat_filesystem::canonicalize_async(candidate).await {
let keyed = if suffix.as_os_str().is_empty() {
canonical
} else {
canonical.join(&suffix)
};
return keyed.to_string_lossy().into_owned();
}
let Some(name) = candidate.file_name() else {
break;
};
let mut next_suffix = PathBuf::from(name);
if !suffix.as_os_str().is_empty() {
next_suffix.push(&suffix);
}
suffix = next_suffix;
let Some(parent) = candidate.parent() else {
break;
};
if parent == candidate {
break;
}
candidate = parent;
}
lexical_normalize_path(absolute)
.to_string_lossy()
.into_owned()
}
fn lexical_absolute_path(path: &Path) -> PathBuf {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
runmat_filesystem::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| path.to_path_buf())
};
lexical_normalize_path(absolute)
}
fn lexical_normalize_path(path: PathBuf) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
fn build_delimited_payload(
table: &CellTable,
options: &WriteCellOptions,
delimiter: &str,
line_ending: &str,
) -> Vec<u8> {
let mut payload = Vec::new();
for row in 0..table.rows {
for col in 0..table.cols {
if col > 0 {
payload.extend_from_slice(delimiter.as_bytes());
}
let rendered = format_cell_for_text(table.get(row, col), options, delimiter);
if !rendered.is_empty() {
payload.extend_from_slice(rendered.as_bytes());
}
}
payload.extend_from_slice(line_ending.as_bytes());
}
payload
}
async fn append_needs_line_ending(path: &Path) -> BuiltinResult<bool> {
let metadata = match runmat_filesystem::metadata_async(path).await {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(err) => {
return Err(writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!(
"writecell: unable to inspect \"{}\" ({err})",
path.display()
),
err,
));
}
};
if metadata.is_empty() {
return Ok(false);
}
let mut file = File::open_async(path).await.map_err(|err| {
writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!(
"writecell: unable to inspect \"{}\" ({err})",
path.display()
),
err,
)
})?;
file.seek(SeekFrom::End(-1)).map_err(|err| {
writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!("writecell: unable to inspect file ending ({err})"),
err,
)
})?;
let mut byte = [0u8; 1];
file.read_exact(&mut byte).map_err(|err| {
writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!("writecell: unable to read file ending ({err})"),
err,
)
})?;
Ok(!matches!(byte[0], b'\n' | b'\r'))
}
async fn write_spreadsheet_cells(
path: &Path,
table: &CellTable,
options: &WriteCellOptions,
) -> BuiltinResult<usize> {
if options.write_mode == WriteMode::Append {
return Err(writecell_error_with(
&WRITECELL_ERROR_OPTION,
"writecell: WriteMode 'append' is not supported for spreadsheet files",
));
}
let range_start = options.range_start();
let end_row = range_start.row.checked_add(table.rows).ok_or_else(|| {
writecell_error_with(&WRITECELL_ERROR_OPTION, "writecell: Range row overflow")
})?;
let end_col = range_start.col.checked_add(table.cols).ok_or_else(|| {
writecell_error_with(&WRITECELL_ERROR_OPTION, "writecell: Range column overflow")
})?;
if end_row > MAX_EXCEL_ROW_INDEX + 1 || end_col > MAX_EXCEL_COLUMN_INDEX + 1 {
return Err(writecell_error_with(
&WRITECELL_ERROR_OPTION,
"writecell: Range exceeds Excel worksheet limits",
));
}
let bytes = build_xlsx_workbook(table, &options.sheet_name(), range_start)?;
safe_replace_file(path, &bytes, "spreadsheet").await?;
Ok(bytes.len())
}
async fn safe_replace_file(path: &Path, bytes: &[u8], label: &str) -> BuiltinResult<()> {
let temp_path = temporary_sibling_path(path);
let mut open_options = OpenOptions::new();
open_options.write(true).create_new(true);
let mut file = open_options.open_async(&temp_path).await.map_err(|err| {
writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!(
"writecell: unable to create temporary {label} file \"{}\" ({err})",
temp_path.display()
),
err,
)
})?;
file.write_all(bytes).map_err(|err| {
writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!("writecell: failed to write spreadsheet ({err})"),
err,
)
})?;
file.flush_async().await.map_err(|err| {
writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!("writecell: failed to flush temporary {label} file ({err})"),
err,
)
})?;
file.sync_all_async().await.map_err(|err| {
writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!("writecell: failed to sync temporary {label} file ({err})"),
err,
)
})?;
drop(file);
if let Err(err) = runmat_filesystem::rename_async(&temp_path, path).await {
let _ = runmat_filesystem::remove_file_async(&temp_path).await;
return Err(writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!(
"writecell: failed to replace \"{}\" with temporary {label} file ({err})",
path.display()
),
err,
));
}
Ok(())
}
fn temporary_sibling_path(path: &Path) -> PathBuf {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("writecell");
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default();
parent.join(format!(".{name}.runmat-tmp-{}-{nanos}", std::process::id()))
}
fn build_xlsx_workbook(
table: &CellTable,
sheet_name: &str,
start: RangeStart,
) -> BuiltinResult<Vec<u8>> {
let cursor = Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(cursor);
write_xlsx_part(
&mut zip,
"[Content_Types].xml",
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
</Types>"#,
)?;
write_xlsx_part(
&mut zip,
"_rels/.rels",
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>"#,
)?;
write_xlsx_part(
&mut zip,
"xl/workbook.xml",
&format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="{}" sheetId="1" r:id="rId1"/>
</sheets>
</workbook>"#,
xml_attr_escape(sheet_name)
),
)?;
write_xlsx_part(
&mut zip,
"xl/_rels/workbook.xml.rels",
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>"#,
)?;
write_xlsx_part(
&mut zip,
"xl/styles.xml",
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
<borders count="1"><border/></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellXfs>
</styleSheet>"#,
)?;
write_xlsx_part(
&mut zip,
"xl/worksheets/sheet1.xml",
&build_sheet_xml(table, start),
)?;
let cursor = zip.finish().map_err(|err| {
writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!("writecell: failed to finish spreadsheet package ({err})"),
err,
)
})?;
Ok(cursor.into_inner())
}
fn write_xlsx_part(
zip: &mut zip::ZipWriter<Cursor<Vec<u8>>>,
name: &str,
contents: &str,
) -> BuiltinResult<()> {
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
zip.start_file(name, options).map_err(|err| {
writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!("writecell: failed to start spreadsheet part {name} ({err})"),
err,
)
})?;
zip.write_all(contents.as_bytes()).map_err(|err| {
writecell_error_with_source(
&WRITECELL_ERROR_IO,
format!("writecell: failed to write spreadsheet part {name} ({err})"),
err,
)
})?;
Ok(())
}
fn build_sheet_xml(table: &CellTable, start: RangeStart) -> String {
let mut xml = String::from(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
"#,
);
for row in 0..table.rows {
let excel_row = start.row + row + 1;
xml.push_str(&format!(r#" <row r="{excel_row}">"#));
xml.push('\n');
for col in 0..table.cols {
let cell = table.get(row, col);
if *cell == CellValue::Empty {
continue;
}
let reference = cell_reference(start.row + row, start.col + col);
match cell {
CellValue::Empty => {}
CellValue::Number(value) => {
xml.push_str(&format!(
" <c r=\"{reference}\"><v>{}</v></c>\n",
format_numeric(*value)
));
}
CellValue::Boolean(value) => {
xml.push_str(&format!(
" <c r=\"{reference}\" t=\"b\"><v>{}</v></c>\n",
if *value { 1 } else { 0 }
));
}
CellValue::Text(text) => {
xml.push_str(&format!(
" <c r=\"{reference}\" t=\"inlineStr\"><is><t>{}</t></is></c>\n",
xml_text_escape(text)
));
}
}
}
xml.push_str(" </row>\n");
}
xml.push_str(" </sheetData>\n</worksheet>");
xml
}
fn format_cell_for_text(cell: &CellValue, options: &WriteCellOptions, delimiter: &str) -> String {
match cell {
CellValue::Empty => String::new(),
CellValue::Number(value) => format_numeric(*value),
CellValue::Boolean(value) => {
if *value {
"1".to_string()
} else {
"0".to_string()
}
}
CellValue::Text(text) => format_string(text, options.quote_strings, delimiter),
}
}
fn format_numeric(value: f64) -> String {
if value.is_nan() {
return "NaN".to_string();
}
if value.is_infinite() {
return if value.is_sign_negative() {
"-Inf".to_string()
} else {
"Inf".to_string()
};
}
let abs = value.abs();
let scientific = abs != 0.0 && !(1e-4..1e15).contains(&abs);
let raw = if scientific {
format!("{:.15e}", value)
} else {
format!("{:.15}", value)
};
trim_trailing_zeros(raw)
}
fn trim_trailing_zeros(mut value: String) -> String {
if let Some(exp_pos) = value.find(['e', 'E']) {
let exponent = value.split_off(exp_pos);
while value.ends_with('0') {
value.pop();
}
if value.ends_with('.') {
value.pop();
}
value.push_str(&exponent);
value
} else {
if value.contains('.') {
while value.ends_with('0') {
value.pop();
}
if value.ends_with('.') {
value.pop();
}
}
if value == "-0" || value.is_empty() {
"0".to_string()
} else {
value
}
}
}
fn format_string(value: &str, quote: bool, _delimiter: &str) -> String {
if !quote {
return value.to_string();
}
let mut escaped = String::with_capacity(value.len() + 2);
escaped.push('"');
for ch in value.chars() {
if ch == '"' {
escaped.push('"');
escaped.push('"');
} else {
escaped.push(ch);
}
}
escaped.push('"');
escaped
}
fn string_scalar_from_value(value: &Value, context: &str) -> Result<String, String> {
match value {
Value::String(s) => Ok(s.clone()),
Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
_ => Err(format!(
"writecell: expected {context} as a string scalar or character vector"
)),
}
}
fn resolve_path(value: &Value) -> BuiltinResult<PathBuf> {
match value {
Value::String(s) => normalize_path(s),
Value::CharArray(ca) if ca.rows == 1 => {
let text: String = ca.data.iter().collect();
normalize_path(&text)
}
Value::CharArray(_) => Err(writecell_error_with(
&WRITECELL_ERROR_FILENAME,
"writecell: expected a 1-by-N character vector for the filename",
)),
Value::StringArray(sa) if sa.data.len() == 1 => normalize_path(&sa.data[0]),
Value::StringArray(_) => Err(writecell_error_with(
&WRITECELL_ERROR_FILENAME,
"writecell: filename string array inputs must be scalar",
)),
other => Err(writecell_error_with(
&WRITECELL_ERROR_FILENAME,
format!(
"writecell: expected filename as string scalar or character vector, got {other:?}"
),
)),
}
}
fn normalize_path(raw: &str) -> BuiltinResult<PathBuf> {
if raw.trim().is_empty() {
return Err(writecell_error_with(
&WRITECELL_ERROR_FILENAME,
"writecell: filename must not be empty",
));
}
let expanded = expand_user_path(raw, BUILTIN_NAME)
.map_err(|msg| writecell_error_with(&WRITECELL_ERROR_FILENAME, msg))?;
Ok(Path::new(&expanded).to_path_buf())
}
fn default_delimiter_for_path(path: &Path) -> String {
match path_extension_lower(path).as_deref() {
Some("csv") => ",".to_string(),
Some("tsv") | Some("tab") => "\t".to_string(),
Some("txt") | Some("dat") | Some("dlm") => " ".to_string(),
_ => ",".to_string(),
}
}
fn path_extension_lower(path: &Path) -> Option<String> {
path.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_ascii_lowercase())
}
fn sanitize_sheet_name(value: &str) -> String {
let mut name: String = value
.chars()
.map(|ch| match ch {
':' | '\\' | '/' | '?' | '*' | '[' | ']' => '_',
_ => ch,
})
.take(31)
.collect();
if name.trim().is_empty() {
name = "Sheet1".to_string();
}
name
}
fn cell_reference(row: usize, col: usize) -> String {
format!("{}{}", column_letters(col), row + 1)
}
fn column_letters(mut col: usize) -> String {
let mut letters = Vec::new();
col += 1;
while col > 0 {
let rem = (col - 1) % 26;
letters.push((b'A' + rem as u8) as char);
col = (col - 1) / 26;
}
letters.iter().rev().collect()
}
fn xml_text_escape(value: &str) -> String {
value
.chars()
.map(|ch| match ch {
'&' => "&".to_string(),
'<' => "<".to_string(),
'>' => ">".to_string(),
_ => ch.to_string(),
})
.collect()
}
fn xml_attr_escape(value: &str) -> String {
value
.chars()
.map(|ch| match ch {
'&' => "&".to_string(),
'<' => "<".to_string(),
'>' => ">".to_string(),
'"' => """.to_string(),
'\'' => "'".to_string(),
_ => ch.to_string(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use calamine::{open_workbook_auto, Data, Reader};
use futures::executor::block_on;
use runmat_time::unix_timestamp_ms;
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(not(target_arch = "wasm32"))]
use std::sync::mpsc;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Barrier;
#[cfg(not(target_arch = "wasm32"))]
use std::thread;
#[cfg(not(target_arch = "wasm32"))]
use std::time::Duration;
use runmat_builtins::{CharArray, LogicalArray, Tensor};
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
fn temp_path(ext: &str) -> PathBuf {
let millis = unix_timestamp_ms();
let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let mut path = std::env::temp_dir();
path.push(format!(
"runmat_writecell_{}_{}_{}.{}",
std::process::id(),
millis,
unique,
ext
));
path
}
fn cell(values: Vec<Value>, rows: usize, cols: usize) -> Value {
Value::Cell(CellArray::new(values, rows, cols).expect("cell array"))
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn writecell_descriptor_signatures_cover_core_forms() {
let labels: Vec<&str> = WRITECELL_DESCRIPTOR
.signatures
.iter()
.map(|sig| sig.label)
.collect();
assert!(labels.contains(&"bytesWritten = writecell(C, filename)"));
assert!(labels.contains(&"bytesWritten = writecell(C, filename, name, optionValue)"));
assert!(labels.contains(&"bytesWritten = writecell(C, filename, nameValuePairs...)"));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn writecell_writes_heterogeneous_csv() {
let path = temp_path("csv");
let filename = path.to_string_lossy().into_owned();
let values = cell(
vec![
Value::Num(1.5),
Value::from("alpha"),
Value::Bool(true),
Value::Tensor(Tensor::new(Vec::new(), vec![0, 0]).expect("empty tensor")),
],
2,
2,
);
block_on(writecell_builtin(values, vec![Value::from(filename)])).expect("writecell");
let contents = fs::read_to_string(&path).expect("read contents");
assert_eq!(contents, "1.5,\"alpha\"\n1,\n");
let _ = fs::remove_file(path);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn writecell_honours_delimiter_quote_strings_and_append() {
let path = temp_path("txt");
let filename = path.to_string_lossy().into_owned();
let first = cell(vec![Value::from("a,b"), Value::Num(2.0)], 1, 2);
let second = cell(vec![Value::from("tail"), Value::Num(3.0)], 1, 2);
block_on(writecell_builtin(
first,
vec![
Value::from(filename.clone()),
Value::from("Delimiter"),
Value::from("|"),
Value::from("QuoteStrings"),
Value::Bool(false),
],
))
.expect("initial write");
block_on(writecell_builtin(
second,
vec![
Value::from(filename.clone()),
Value::from("Delimiter"),
Value::from("|"),
Value::from("QuoteStrings"),
Value::Bool(false),
Value::from("WriteMode"),
Value::from("append"),
],
))
.expect("append write");
let contents = fs::read_to_string(&path).expect("read contents");
assert_eq!(contents, "a,b|2\ntail|3\n");
let _ = fs::remove_file(path);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn writecell_append_inserts_missing_row_boundary() {
let path = temp_path("txt");
fs::write(&path, "existing").expect("seed");
let filename = path.to_string_lossy().into_owned();
let values = cell(vec![Value::from("tail"), Value::Num(3.0)], 1, 2);
block_on(writecell_builtin(
values,
vec![
Value::from(filename),
Value::from("Delimiter"),
Value::from("|"),
Value::from("QuoteStrings"),
Value::Bool(false),
Value::from("WriteMode"),
Value::from("append"),
],
))
.expect("append write");
let contents = fs::read_to_string(&path).expect("read contents");
assert_eq!(contents, "existing\ntail|3\n");
let _ = fs::remove_file(path);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn writecell_concurrent_appends_share_one_boundary_insertion() {
let path = temp_path("txt");
fs::write(&path, "existing").expect("seed");
let filename = path.to_string_lossy().into_owned();
let writers = 8usize;
let barrier = Arc::new(Barrier::new(writers));
let mut handles = Vec::new();
for idx in 0..writers {
let barrier = Arc::clone(&barrier);
let filename = filename.clone();
handles.push(thread::spawn(move || {
barrier.wait();
let values = cell(
vec![Value::from(format!("row{idx}")), Value::Num(idx as f64)],
1,
2,
);
block_on(writecell_builtin(
values,
vec![
Value::from(filename),
Value::from("Delimiter"),
Value::from("|"),
Value::from("QuoteStrings"),
Value::Bool(false),
Value::from("WriteMode"),
Value::from("append"),
],
))
.expect("append write");
}));
}
for handle in handles {
handle.join().expect("writer thread");
}
let contents = fs::read_to_string(&path).expect("read contents");
let lines = contents.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), writers + 1);
assert_eq!(lines[0], "existing");
assert!(lines.iter().all(|line| !line.is_empty()));
for idx in 0..writers {
let expected = format!("row{idx}|{idx}");
assert!(lines.iter().any(|line| *line == expected));
}
let _ = fs::remove_file(path);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn writecell_overwrite_uses_same_path_write_lock() {
let path = temp_path("txt");
fs::write(&path, "existing\n").expect("seed");
let filename = path.to_string_lossy().into_owned();
let lock = block_on(write_lock_for_path(&path));
let guard = block_on(lock.lock());
let (tx, rx) = mpsc::channel();
let handle = thread::spawn(move || {
let values = cell(vec![Value::from("replacement")], 1, 1);
block_on(writecell_builtin(values, vec![Value::from(filename)]))
.expect("overwrite write");
tx.send(()).expect("send completion");
});
thread::sleep(Duration::from_millis(50));
assert!(rx.try_recv().is_err());
drop(guard);
handle.join().expect("writer thread");
rx.recv_timeout(Duration::from_secs(1))
.expect("overwrite completion");
let contents = fs::read_to_string(&path).expect("read contents");
assert_eq!(contents, "\"replacement\"\n");
let _ = fs::remove_file(path);
}
#[cfg(unix)]
#[test]
fn writecell_canonical_aliases_share_write_lock() {
let path = temp_path("txt");
fs::write(&path, "").expect("seed");
let mut link = path.clone();
link.set_extension("link.txt");
std::os::unix::fs::symlink(&path, &link).expect("symlink");
let direct = block_on(write_lock_for_path(&path));
let alias = block_on(write_lock_for_path(&link));
assert!(Arc::ptr_eq(&direct, &alias));
let _ = fs::remove_file(link);
let _ = fs::remove_file(path);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn writecell_accepts_scalar_char_tensor_and_logical_cells() {
let path = temp_path("csv");
let filename = path.to_string_lossy().into_owned();
let values = cell(
vec![
Value::CharArray(CharArray::new_row("name")),
Value::Tensor(Tensor::new(vec![42.0], vec![1, 1]).expect("scalar tensor")),
Value::LogicalArray(LogicalArray::new(vec![0], vec![1, 1]).expect("logical")),
],
1,
3,
);
block_on(writecell_builtin(values, vec![Value::from(filename)])).expect("writecell");
let contents = fs::read_to_string(&path).expect("read contents");
assert_eq!(contents, "\"name\",42,0\n");
let _ = fs::remove_file(path);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn writecell_rejects_nested_cells_and_nonscalar_arrays() {
let path = temp_path("csv");
let filename = path.to_string_lossy().into_owned();
let nested = cell(vec![cell(vec![Value::Num(1.0)], 1, 1)], 1, 1);
let err = block_on(writecell_builtin(
nested,
vec![Value::from(filename.clone())],
))
.expect_err("nested cell error");
assert!(err.message().contains("nested cell arrays"));
let nonscalar = cell(
vec![Value::Tensor(
Tensor::new(vec![1.0, 2.0], vec![1, 2]).expect("tensor"),
)],
1,
1,
);
let err = block_on(writecell_builtin(nonscalar, vec![Value::from(filename)]))
.expect_err("nonscalar error");
assert!(err.message().contains("unsupported cell value"));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn writecell_writes_xlsx_with_sheet_and_range() {
let path = temp_path("xlsx");
let filename = path.to_string_lossy().into_owned();
let values = cell(
vec![Value::from("Voltage"), Value::Num(1.5), Value::Bool(true)],
1,
3,
);
block_on(writecell_builtin(
values,
vec![
Value::from(filename),
Value::from("Sheet"),
Value::from("Measurements"),
Value::from("Range"),
Value::from("B2"),
],
))
.expect("writecell xlsx");
let mut workbook = open_workbook_auto(&path).expect("open workbook");
assert_eq!(workbook.sheet_names()[0], "Measurements");
let range = workbook
.worksheet_range("Measurements")
.expect("worksheet range");
assert_eq!(
range.get((0, 0)),
Some(&Data::String("Voltage".to_string()))
);
assert_eq!(range.get((0, 1)), Some(&Data::Float(1.5)));
assert_eq!(range.get((0, 2)), Some(&Data::Bool(true)));
let _ = fs::remove_file(path);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn writecell_rejects_unsupported_spreadsheet_extension() {
let path = temp_path("xls");
let filename = path.to_string_lossy().into_owned();
let values = cell(vec![Value::from("A"), Value::Num(1.0)], 1, 2);
let err = block_on(writecell_builtin(values, vec![Value::from(filename)]))
.expect_err("unsupported extension");
assert!(err
.message()
.contains("unsupported spreadsheet file extension"));
let _ = fs::remove_file(path);
}
}