Skip to main content

data_beans/
zarr_io.rs

1//! General-purpose zarr I/O for reading numeric arrays and attributes from
2//! zarr directories or `.zarr.zip` archives.
3//!
4//! Also provides a convenience function for reading coordinate-style data
5//! (row names + numeric matrix) that returns `MatWithNames<DMatrix<f32>>`,
6//! matching the parquet reader interface in legume_numeric::matrix.
7
8use crate::sparse_io::SparseIoBackend;
9use legume_numeric::matrix::traits::MatWithNames;
10use log::info;
11use nalgebra::DMatrix;
12use rand_distr::num_traits::FromPrimitive;
13use std::sync::Arc;
14use zarrs::array::{data_type, Array as ZArray};
15use zarrs::config::MetadataRetrieveVersion;
16use zarrs::filesystem::FilesystemStore;
17use zarrs::storage::ReadableListableStorageTraits as ZReadStorageTraits;
18use zarrs_zip::ZipStorageAdapter;
19
20// ── Store management ────────────────────────────────────────────────────
21
22/// A read store paired with an optional write store (present for directories, `None` for zips).
23pub type ZarrStoreRw = (Arc<dyn ZReadStorageTraits>, Option<Arc<FilesystemStore>>);
24
25/// The directory a zarr zip holds its store under, read from the stored
26/// names, which [`ZipStorageAdapter`] matches too: empty when a metadata file
27/// (`zarr.json`, or v2's `.zgroup` / `.zarray`) sits at the archive's root;
28/// else the single top-level directory (as
29/// [`legume_numeric::matrix::common_io::zip_dir`] writes, whatever the
30/// archive is named), ignoring loose files and `__MACOSX`; else, among
31/// several, the one named like the archive (`foo` for `foo.zarr.zip`, or the
32/// older `foo.zarr`); else empty.
33fn zip_zarr_root(zip_path: &std::path::Path) -> anyhow::Result<String> {
34    const METADATA: [&str; 3] = ["zarr.json", ".zgroup", ".zarray"];
35    let file = std::fs::File::open(zip_path)?;
36    let archive = zip::ZipArchive::new(std::io::BufReader::new(file))?;
37    let mut dirs = std::collections::BTreeSet::new();
38    for name in archive.file_names().flatten() {
39        match name.split_once('/') {
40            None if METADATA.contains(&&*name) => return Ok(String::new()),
41            None | Some(("__MACOSX", _)) => {}
42            Some((top, _)) => {
43                dirs.insert(top.to_owned());
44            }
45        }
46    }
47    if dirs.len() == 1 {
48        return Ok(dirs.pop_first().unwrap_or_default());
49    }
50    let file_name = zip_path.file_name().and_then(|f| f.to_str()).unwrap_or("");
51    let stem = file_name.strip_suffix(".zip").unwrap_or(file_name);
52    let named = [stem.strip_suffix(".zarr").unwrap_or(stem), stem];
53    Ok(named
54        .into_iter()
55        .find(|d| dirs.contains(*d))
56        .unwrap_or_default()
57        .to_owned())
58}
59
60/// The path prefix inside a zarr zip: its [`zip_zarr_root`] with a trailing
61/// `/`, or empty.
62fn detect_zip_zarr_prefix(zip_path: &std::path::Path) -> anyhow::Result<Box<str>> {
63    let root = zip_zarr_root(zip_path)?;
64    Ok(if root.is_empty() {
65        root.into()
66    } else {
67        format!("{root}/").into()
68    })
69}
70
71/// Open a zarr store, returning both a read store and an optional write store.
72///
73/// Supports both `.zarr` directories and `.zarr.zip` archives.
74/// For zip archives, uses [`ZipStorageAdapter`] for direct random-access
75/// reads without extracting to a temp directory; the write store is `None`.
76pub fn open_zarr_store_rw(path: &str) -> anyhow::Result<ZarrStoreRw> {
77    let p = std::path::Path::new(path);
78    let is_zip = p.extension().is_some_and(|e| e == "zip");
79
80    if is_zip {
81        let parent = p
82            .parent()
83            .ok_or_else(|| anyhow::anyhow!("no parent directory for {}", path))?;
84        let filename = p
85            .file_name()
86            .ok_or_else(|| anyhow::anyhow!("no filename for {}", path))?
87            .to_str()
88            .ok_or_else(|| anyhow::anyhow!("non-UTF8 filename in {}", path))?;
89        let zarr_prefix = detect_zip_zarr_prefix(p)?;
90        let fs = Arc::new(FilesystemStore::new(parent)?);
91        let key = zarrs::storage::StoreKey::new(filename)?;
92        info!(
93            "Opening zarr zip store: {} (prefix: {:?})",
94            path, zarr_prefix
95        );
96        Ok((
97            Arc::new(ZipStorageAdapter::new_with_path(fs, key, &*zarr_prefix)?),
98            None,
99        ))
100    } else {
101        let fs = Arc::new(FilesystemStore::new(path)?);
102        Ok((fs.clone(), Some(fs)))
103    }
104}
105
106/// Open a zarr store for reading (convenience wrapper around [`open_zarr_store_rw`]).
107pub fn open_zarr_store(path: &str) -> anyhow::Result<Arc<dyn ZReadStorageTraits>> {
108    open_zarr_store_rw(path).map(|(r, _)| r)
109}
110
111/// If `zip` is true and the target is zarr, ensure the output path ends with
112/// `.zarr.zip`; otherwise pass through unchanged.
113///
114/// The target is treated as HDF5 (and left alone) when `backend` is
115/// [`SparseIoBackend::HDF5`] or the name already carries a `.h5` / `.hdf5`
116/// suffix. Without the `backend` signal, a bare `-o foo` under `--backend hdf5`
117/// would be zarr-ified here and then flipped back to Zarr by
118/// `resolve_backend_file` — silently ignoring the requested backend.
119pub fn apply_zip_flag(output: &str, zip: bool, backend: &SparseIoBackend) -> Box<str> {
120    // The `.zarr.zip` suffix only makes sense for a zarr target. An HDF5 target
121    // — selected by `--backend hdf5` or an explicit `.h5`/`.hdf5` name — is left
122    // untouched, so a bare `-o foo` under `--backend hdf5` resolves to `foo.h5`
123    // rather than being silently zarr-ified by `resolve_backend_file`.
124    let target_is_hdf5 = matches!(backend, SparseIoBackend::HDF5)
125        || output.ends_with(".h5")
126        || output.ends_with(".hdf5");
127    if zip && !target_is_hdf5 && !output.ends_with(".zarr.zip") {
128        let base = crate::hdf5_io::strip_backend_suffix(output);
129        format!("{}.zarr.zip", base).into()
130    } else {
131        output.into()
132    }
133}
134
135/// Where a handler given `-o output` writes, before it writes anything.
136pub struct OutputTarget {
137    /// The output as the handler takes it: `.zarr.zip` added under zip.
138    pub effective: Box<str>,
139    pub backend: SparseIoBackend,
140    /// The file the backend is written to (the un-zipped `.zarr` directory
141    /// for a `.zarr.zip` target).
142    pub working: Box<str>,
143    /// What the user finds once [`finalize_output`] has run: the archive for
144    /// a `.zarr.zip` target, else the working file.
145    pub found: Box<str>,
146}
147
148/// [`OutputTarget`] for `output`, touching nothing.
149pub fn output_target(
150    output: &str,
151    backend: SparseIoBackend,
152    zip: bool,
153) -> anyhow::Result<OutputTarget> {
154    let effective = apply_zip_flag(output, zip, &backend);
155    let (backend, working) = crate::hdf5_io::resolve_backend_file(&effective, Some(backend))?;
156    let found = if effective.ends_with(".zarr.zip") {
157        effective.clone()
158    } else {
159        working.clone()
160    };
161    Ok(OutputTarget {
162        effective,
163        backend,
164        working,
165        found,
166    })
167}
168
169/// Resolve the output target for a handler that writes a fresh backend, and
170/// clear any pre-existing file at that path. Returns
171/// `(effective_output, backend, working_file)`, where `working_file` is the
172/// path to write (the un-zipped `.zarr` directory for a `.zarr.zip` target) —
173/// pair it with [`finalize_output`] once the backend is populated.
174pub fn prepare_output(
175    output: &str,
176    backend: SparseIoBackend,
177    zip: bool,
178) -> anyhow::Result<(Box<str>, SparseIoBackend, Box<str>)> {
179    use legume_numeric::matrix::common_io::remove_file;
180
181    let t = output_target(output, backend, zip)?;
182    if std::path::Path::new(t.working.as_ref()).exists() {
183        remove_file(&t.working)?;
184    }
185    Ok((t.effective, t.backend, t.working))
186}
187
188/// Finalize a handler's output: re-zip the working `.zarr` directory when the
189/// target is `.zarr.zip`, and return the path the user will actually find (the
190/// archive for a `.zarr.zip` target, otherwise the working file).
191pub fn finalize_output<'a>(
192    working_file: &'a str,
193    effective_output: &'a str,
194) -> anyhow::Result<&'a str> {
195    finalize_zarr_output(working_file, effective_output)?;
196    Ok(if effective_output.ends_with(".zarr.zip") {
197        effective_output
198    } else {
199        working_file
200    })
201}
202
203/// Extract a `.zarr.zip` archive into `target_dir` as a flat `.zarr`
204/// directory: the store under its root directory, with legume-numeric's
205/// checks against entries that would leave the target.
206pub fn extract_zarr_zip(zip_path: &str, target_dir: &str) -> anyhow::Result<()> {
207    let root = zip_zarr_root(std::path::Path::new(zip_path))?;
208    legume_numeric::matrix::common_io::unzip_subdir(zip_path, &root, target_dir)
209}
210
211/// Copy input backend to a writable output location. Transparently extracts
212/// `.zarr.zip` archives into a `.zarr` directory so the output can be opened
213/// read/write.
214pub fn materialize_writable_backend(src: &str, dst: &str) -> anyhow::Result<()> {
215    if src.ends_with(".zarr.zip") {
216        info!("extracting {} → {}", src, dst);
217        extract_zarr_zip(src, dst)
218    } else {
219        legume_numeric::matrix::common_io::recursive_copy(src, dst)
220    }
221}
222
223/// If `target_path` ends with `.zarr.zip`, zip the `zarr_dir` into it and
224/// remove the directory. Otherwise this is a no-op (the directory IS the target).
225///
226/// To keep in-zip entry names compact, the directory is staged into a temp
227/// directory under its short stem (e.g. `atac.zarr` → `<tmp>/atac`) before
228/// zipping, so entries are prefixed `foo/...` rather than `foo.zarr/...`.
229/// Staging in a tempdir avoids collisions when a sibling file or directory
230/// already uses the short stem (e.g. an input dir named `atac/` next to
231/// `atac.zarr`).
232pub fn finalize_zarr_output(zarr_dir: &str, target_path: &str) -> anyhow::Result<()> {
233    if !target_path.ends_with(".zarr.zip") {
234        return Ok(());
235    }
236
237    let zarr_path = std::path::Path::new(zarr_dir);
238    let stem = zarr_path
239        .file_name()
240        .and_then(|s| s.to_str())
241        .map(|s| s.strip_suffix(".zarr").unwrap_or(s))
242        .ok_or_else(|| anyhow::anyhow!("invalid zarr_dir: {}", zarr_dir))?
243        .to_string();
244
245    // Zip to a sibling `.tmp` first so the source .zarr survives any failure
246    // (disk full, etc.). Only after the zip succeeds do we atomically rename
247    // into place and remove the source — so a crash leaves either the source
248    // directory or the final archive, never neither.
249    let tmp_target = format!("{}.tmp", target_path);
250    info!("Zipping zarr output: {} → {}", zarr_dir, target_path);
251    if let Err(e) =
252        legume_numeric::matrix::common_io::zip_dir_as(zarr_dir, &tmp_target, Some(&stem))
253    {
254        let _ = std::fs::remove_file(&tmp_target);
255        return Err(e);
256    }
257    if let Err(e) = std::fs::rename(&tmp_target, target_path) {
258        let _ = std::fs::remove_file(&tmp_target);
259        return Err(e.into());
260    }
261    std::fs::remove_dir_all(zarr_dir)?;
262    Ok(())
263}
264
265// ── Attribute reading ───────────────────────────────────────────────────
266
267/// Read an attribute from a zarr array node.
268///
269/// ```text
270/// read_zarr_array_attr::<Vec<String>>(store, "/cell_summary", "column_names")
271/// ```
272pub fn read_zarr_array_attr<V: serde::de::DeserializeOwned>(
273    store: Arc<dyn ZReadStorageTraits>,
274    array_path: &str,
275    attr_name: &str,
276) -> anyhow::Result<V> {
277    let arr = ZArray::open_opt(store, array_path, &MetadataRetrieveVersion::Default)?;
278    let attr = arr.attributes().get(attr_name).ok_or_else(|| {
279        anyhow::anyhow!(
280            "attribute '{}' not found on array '{}'",
281            attr_name,
282            array_path
283        )
284    })?;
285    Ok(serde_json::from_value(attr.clone())?)
286}
287
288// ── Flat array readers ──────────────────────────────────────────────────
289
290/// Retrieve a zarr array as a flat `Vec<f32>` (row-major) and its shape.
291///
292/// Handles f32, f64, u32, u64 source types with automatic conversion.
293pub fn read_zarr_flat_f32(
294    store: Arc<dyn ZReadStorageTraits>,
295    key: &str,
296) -> anyhow::Result<(Vec<f32>, Vec<u64>)> {
297    let arr = ZArray::open_opt(store, key, &MetadataRetrieveVersion::Default)?;
298    let shape = arr.shape().to_vec();
299    let subset = arr.subset_all();
300
301    let dt = arr.data_type();
302    let data = if *dt == data_type::float32() {
303        arr.retrieve_array_subset::<Vec<f32>>(&subset)?
304    } else if *dt == data_type::float64() {
305        arr.retrieve_array_subset::<Vec<f64>>(&subset)?
306            .into_iter()
307            .map(|x| x as f32)
308            .collect()
309    } else if *dt == data_type::uint32() {
310        arr.retrieve_array_subset::<Vec<u32>>(&subset)?
311            .into_iter()
312            .map(|x| x as f32)
313            .collect()
314    } else if *dt == data_type::uint64() {
315        arr.retrieve_array_subset::<Vec<u64>>(&subset)?
316            .into_iter()
317            .map(|x| x as f32)
318            .collect()
319    } else {
320        anyhow::bail!("unsupported zarr data type: {:?}", dt)
321    };
322
323    Ok((data, shape))
324}
325
326/// Retrieve a zarr array as a flat `Vec<u32>` and its shape.
327pub fn read_zarr_flat_u32(
328    store: Arc<dyn ZReadStorageTraits>,
329    key: &str,
330) -> anyhow::Result<(Vec<u32>, Vec<u64>)> {
331    let arr = ZArray::open_opt(store, key, &MetadataRetrieveVersion::Default)?;
332    let shape = arr.shape().to_vec();
333    let subset = arr.subset_all();
334
335    let dt = arr.data_type();
336    let data = if *dt == data_type::uint32() {
337        arr.retrieve_array_subset::<Vec<u32>>(&subset)?
338    } else if *dt == data_type::uint64() {
339        arr.retrieve_array_subset::<Vec<u64>>(&subset)?
340            .into_iter()
341            .map(|x| x as u32)
342            .collect()
343    } else {
344        anyhow::bail!("unsupported zarr data type for u32: {:?}", dt)
345    };
346
347    Ok((data, shape))
348}
349
350// ── Generic array/attribute readers (moved from misc.rs) ────────────────
351
352#[cfg(feature = "ndarray")]
353/// Read a full ndarray from zarr storage.
354pub fn read_zarr_ndarray<T>(
355    store: Arc<dyn ZReadStorageTraits>,
356    key_name: &str,
357) -> anyhow::Result<ndarray::ArrayD<T>>
358where
359    T: zarrs::array::ElementOwned + FromPrimitive,
360{
361    let arr = ZArray::open_opt(store, key_name, &MetadataRetrieveVersion::Default)?;
362
363    let dt = arr.data_type();
364    if *dt == data_type::float32() {
365        let array: ndarray::ArrayD<f32> =
366            arr.retrieve_array_subset::<ndarray::ArrayD<f32>>(&arr.subset_all())?;
367        Ok(array.mapv(|x| T::from_f32(x).unwrap()))
368    } else if *dt == data_type::float64() {
369        let array: ndarray::ArrayD<f64> =
370            arr.retrieve_array_subset::<ndarray::ArrayD<f64>>(&arr.subset_all())?;
371        Ok(array.mapv(|x| T::from_f64(x).unwrap()))
372    } else if *dt == data_type::uint32() {
373        let array: ndarray::ArrayD<u32> =
374            arr.retrieve_array_subset::<ndarray::ArrayD<u32>>(&arr.subset_all())?;
375        Ok(array.mapv(|x| T::from_u32(x).unwrap()))
376    } else if *dt == data_type::uint64() {
377        let array: ndarray::ArrayD<u64> =
378            arr.retrieve_array_subset::<ndarray::ArrayD<u64>>(&arr.subset_all())?;
379        Ok(array.mapv(|x| T::from_u64(x).unwrap()))
380    } else {
381        anyhow::bail!("unsupported zarr data type: {:?}", dt)
382    }
383}
384
385/// Read a numeric vector from zarr storage.
386pub fn read_zarr_numerics<T>(
387    store: Arc<dyn ZReadStorageTraits>,
388    key_name: &str,
389) -> anyhow::Result<Vec<T>>
390where
391    T: zarrs::array::ElementOwned + FromPrimitive,
392{
393    let arr = ZArray::open_opt(store, key_name, &MetadataRetrieveVersion::Default)?;
394
395    let dt = arr.data_type();
396    let ret = if *dt == data_type::float32() {
397        arr.retrieve_array_subset::<Vec<f32>>(&arr.subset_all())?
398            .into_iter()
399            .map(|x| T::from_f32(x).unwrap())
400            .collect()
401    } else if *dt == data_type::float64() {
402        arr.retrieve_array_subset::<Vec<f64>>(&arr.subset_all())?
403            .into_iter()
404            .map(|x| T::from_f64(x).unwrap())
405            .collect()
406    } else if *dt == data_type::uint32() {
407        arr.retrieve_array_subset::<Vec<u32>>(&arr.subset_all())?
408            .into_iter()
409            .map(|x| T::from_u32(x).unwrap())
410            .collect()
411    } else if *dt == data_type::uint64() {
412        arr.retrieve_array_subset::<Vec<u64>>(&arr.subset_all())?
413            .into_iter()
414            .map(|x| T::from_u64(x).unwrap())
415            .collect()
416    } else {
417        anyhow::bail!("unsupported zarr data type: {:?}", dt);
418    };
419
420    Ok(ret)
421}
422
423/// Extract an attribute from a zarr group node.
424///
425/// `key_name` is parsed as `"group_path/attr_name"`, e.g.
426/// `"/cell_features/features/id"` → group `"/cell_features/features"`, attr `"id"`.
427pub fn read_zarr_group_attr<V>(
428    store: Arc<dyn ZReadStorageTraits>,
429    key_name: &str,
430) -> anyhow::Result<V>
431where
432    V: serde::de::DeserializeOwned,
433{
434    use anyhow::Context;
435
436    fn parse_key_name(key_name: &str) -> (Box<str>, Box<str>) {
437        let trimmed = key_name.strip_prefix('/').unwrap_or(key_name);
438        match trimmed.rsplit_once('/') {
439            Some((left, right)) => (
440                format!("/{}", left).into_boxed_str(),
441                right.to_string().into_boxed_str(),
442            ),
443            None => (
444                "/".to_string().into_boxed_str(),
445                trimmed.to_string().into_boxed_str(),
446            ),
447        }
448    }
449
450    let (group_name, attr_name) = parse_key_name(key_name);
451
452    let group = zarrs::group::Group::open_opt(
453        store,
454        group_name.as_ref(),
455        &MetadataRetrieveVersion::Default,
456    )
457    .with_context(|| format!("Failed to open group '{}'", group_name))?;
458
459    let attr_value = group
460        .attributes()
461        .get(attr_name.as_ref())
462        .with_context(|| {
463            format!(
464                "Attribute '{}' not found in group '{}'",
465                attr_name, group_name
466            )
467        })?;
468
469    Ok(serde_json::from_value(attr_value.clone())?)
470}
471
472/// Read a string array from zarr storage.
473pub fn read_zarr_strings(
474    store: Arc<dyn ZReadStorageTraits>,
475    key_name: &str,
476) -> anyhow::Result<Vec<Box<str>>> {
477    let arr = ZArray::open_opt(store, key_name, &MetadataRetrieveVersion::Default)?;
478
479    Ok(arr
480        .retrieve_array_subset::<Vec<String>>(&arr.subset_all())?
481        .into_iter()
482        .map(|x| x.into_boxed_str())
483        .collect())
484}
485
486// ── 10x cell ID encoding ────────────────────────────────────────────────
487
488/// Hex-digit → shifted-alpha lookup table for 10x cell ID encoding.
489fn hex_to_shifted_lookup() -> [Option<char>; 256] {
490    let mut lookup = [None; 256];
491    for (i, ch) in "0123456789abcdef".chars().enumerate() {
492        lookup[ch as usize] = Some(
493            [
494                'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p',
495            ][i],
496        );
497    }
498    lookup
499}
500
501/// Encode a single `(barcode_u32, suffix_u32)` pair into a 10x cell-ID string.
502fn encode_10x_cell_id(
503    lookup: &[Option<char>; 256],
504    barcode: u32,
505    suffix: u32,
506) -> anyhow::Result<Box<str>> {
507    let barcode: String = format!("{:08x}", barcode)
508        .chars()
509        .map(|ch| lookup[ch as usize].ok_or_else(|| anyhow::anyhow!("invalid hex char: {}", ch)))
510        .collect::<anyhow::Result<String>>()?;
511    Ok(format!("{}-{}", barcode, suffix).into_boxed_str())
512}
513
514#[cfg(feature = "ndarray")]
515/// Parse a 10x Xenium `[N, 2]` u32 cell_id ndarray into string barcodes.
516///
517/// See [10x docs](https://www.10xgenomics.com/support/software/xenium-onboard-analysis/3.4/advanced/xoa-output-zarr#cellID).
518pub fn parse_10x_cell_id(
519    input: ndarray::ArrayView<u32, ndarray::IxDyn>,
520) -> anyhow::Result<Vec<Box<str>>> {
521    anyhow::ensure!(
522        input.ndim() == 2 && input.shape()[1] == 2,
523        "Must be 2D with shape [N, 2]"
524    );
525    let lookup = hex_to_shifted_lookup();
526    input
527        .outer_iter()
528        .map(|row| encode_10x_cell_id(&lookup, row[0], row[1]))
529        .collect()
530}
531
532/// Parse a flat row-major `[N, 2]` u32 buffer into 10x Xenium cell-ID strings.
533///
534/// Same encoding as `parse_10x_cell_id` but works on a flat slice.
535pub fn parse_10x_cell_id_flat(data: &[u32], nrows: usize) -> anyhow::Result<Vec<Box<str>>> {
536    anyhow::ensure!(data.len() == nrows * 2, "cell_id buffer size mismatch");
537    let lookup = hex_to_shifted_lookup();
538    (0..nrows)
539        .map(|i| encode_10x_cell_id(&lookup, data[i * 2], data[i * 2 + 1]))
540        .collect()
541}
542
543// ── Matrix reading (generic) ────────────────────────────────────────────
544
545/// Build a column-major `DMatrix<f32>` by selecting columns from a flat
546/// row-major `[nrows, ncols_total]` buffer.
547fn select_columns_to_dmatrix(
548    data: &[f32],
549    nrows: usize,
550    ncols_total: usize,
551    selected: &[usize],
552) -> anyhow::Result<DMatrix<f32>> {
553    let k = selected.len();
554    let mut col_major = vec![0.0f32; nrows * k];
555    for (col_out, &col_in) in selected.iter().enumerate() {
556        anyhow::ensure!(col_in < ncols_total, "column index {} out of range", col_in);
557        for row in 0..nrows {
558            col_major[row + col_out * nrows] = data[row * ncols_total + col_in];
559        }
560    }
561    Ok(DMatrix::from_vec(nrows, k, col_major))
562}
563
564/// Resolve column selection: explicit indices, name lookup, or default.
565fn resolve_columns(
566    column_indices: &[usize],
567    column_names: &[Box<str>],
568    all_col_names: &[Box<str>],
569    default_cols: &[usize],
570) -> anyhow::Result<Vec<usize>> {
571    if !column_indices.is_empty() {
572        Ok(column_indices.to_vec())
573    } else if !column_names.is_empty() {
574        // Keep only names that exist — allows generous defaults
575        // covering multiple platforms (e.g. Visium + Xenium).
576        let matched: Vec<usize> = column_names
577            .iter()
578            .filter_map(|name| all_col_names.iter().position(|c| c == name))
579            .collect();
580        if matched.is_empty() {
581            anyhow::bail!(
582                "none of the requested columns {:?} found (available: {:?})",
583                column_names,
584                all_col_names
585            );
586        }
587        Ok(matched)
588    } else {
589        Ok(default_cols.to_vec())
590    }
591}
592
593/// Read a numeric matrix from a zarr array, selecting columns by index or
594/// name (looked up via an attribute on the array).
595///
596/// * `file_path` — zarr directory or `.zarr.zip`
597/// * `data_array` — path to the numeric `[N, C]` array (e.g. `/cell_summary`)
598/// * `col_names_attr` — attribute name on `data_array` that holds column
599///   names (e.g. `"column_names"`); pass `None` to skip name lookup
600/// * `row_names_array` — path to the row-name array (e.g. `/cell_id`);
601///   `None` generates numeric row names `"0", "1", …`
602/// * `row_names_10x` — if `true`, parse `row_names_array` as 10x u32 `[N,2]`
603///   cell IDs; if `false`, read as string array
604/// * `column_indices` / `column_names` — which columns to select
605pub fn read_zarr_matrix(
606    file_path: &str,
607    data_array: &str,
608    col_names_attr: Option<&str>,
609    row_names_array: Option<&str>,
610    row_names_10x: bool,
611    column_indices: &[usize],
612    column_names: &[Box<str>],
613) -> anyhow::Result<MatWithNames<DMatrix<f32>>> {
614    let store = open_zarr_store(file_path)?;
615
616    // Read the data array
617    let (flat, shape) = read_zarr_flat_f32(store.clone(), data_array)?;
618    let nrows = shape[0] as usize;
619    let ncols_total = if shape.len() > 1 {
620        shape[1] as usize
621    } else {
622        1
623    };
624
625    // Read column names from attribute (if available)
626    let all_col_names: Vec<Box<str>> = if let Some(attr) = col_names_attr {
627        read_zarr_array_attr(store.clone(), data_array, attr).unwrap_or_else(|_| {
628            (0..ncols_total)
629                .map(|i| i.to_string().into_boxed_str())
630                .collect()
631        })
632    } else {
633        (0..ncols_total)
634            .map(|i| i.to_string().into_boxed_str())
635            .collect()
636    };
637
638    // Resolve column selection
639    let selected = resolve_columns(column_indices, column_names, &all_col_names, &[0, 1])?;
640    let sel_names: Vec<Box<str>> = selected
641        .iter()
642        .map(|&i| {
643            all_col_names
644                .get(i)
645                .cloned()
646                .unwrap_or_else(|| i.to_string().into_boxed_str())
647        })
648        .collect();
649
650    // Build the matrix
651    let mat = select_columns_to_dmatrix(&flat, nrows, ncols_total, &selected)?;
652
653    // Read row names
654    let row_names = if let Some(rn_path) = row_names_array {
655        if row_names_10x {
656            let (id_data, id_shape) = read_zarr_flat_u32(store.clone(), rn_path)?;
657            anyhow::ensure!(
658                id_shape[0] as usize == nrows,
659                "row names array rows ({}) != data rows ({})",
660                id_shape[0],
661                nrows
662            );
663            parse_10x_cell_id_flat(&id_data, nrows)?
664        } else {
665            read_zarr_strings(store.clone(), rn_path)?
666        }
667    } else {
668        (0..nrows).map(|i| i.to_string().into_boxed_str()).collect()
669    };
670
671    info!(
672        "Read {} x {} from zarr {}{}: {:?}",
673        nrows,
674        selected.len(),
675        file_path,
676        data_array,
677        sel_names
678    );
679
680    Ok(MatWithNames {
681        rows: row_names,
682        cols: sel_names,
683        mat,
684    })
685}
686
687// ── Xenium convenience ──────────────────────────────────────────────────
688
689/// Read cell coordinates from a Xenium-style zarr file.
690///
691/// Shorthand for [`read_zarr_matrix`] with Xenium defaults:
692///   - data array: `/cell_summary`
693///   - column names attribute: `column_names`
694///   - row names: `/cell_id` (10x encoded)
695pub fn read_zarr_coordinates(
696    file_path: &str,
697    column_indices: &[usize],
698    column_names: &[Box<str>],
699) -> anyhow::Result<MatWithNames<DMatrix<f32>>> {
700    read_zarr_matrix(
701        file_path,
702        "/cell_summary",
703        Some("column_names"),
704        Some("/cell_id"),
705        true,
706        column_indices,
707        column_names,
708    )
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    fn xenium_path() -> Option<std::path::PathBuf> {
716        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
717            .parent()
718            .unwrap()
719            .join("docs/temp/cells.zarr.zip");
720        p.exists().then_some(p)
721    }
722
723    #[test]
724    fn test_parse_10x_cell_id_flat() {
725        let data = vec![16844u32, 1, 22527, 1];
726        let ids = parse_10x_cell_id_flat(&data, 2).unwrap();
727        assert_eq!(ids.len(), 2);
728        assert!(ids[0].ends_with("-1"), "got: {}", ids[0]);
729        assert!(ids[1].ends_with("-1"), "got: {}", ids[1]);
730        assert_eq!(ids[0].len(), 10);
731    }
732
733    #[test]
734    fn test_parse_10x_cell_id_flat_mismatch() {
735        assert!(parse_10x_cell_id_flat(&[1, 2, 3], 2).is_err());
736    }
737
738    #[test]
739    fn test_read_zarr_coordinates_by_name() {
740        let Some(p) = xenium_path() else { return };
741        let result = read_zarr_coordinates(
742            p.to_str().unwrap(),
743            &[],
744            &["cell_centroid_x".into(), "cell_centroid_y".into()],
745        )
746        .unwrap();
747
748        assert_eq!(result.mat.ncols(), 2);
749        assert!(result.mat.nrows() > 0);
750        assert_eq!(result.rows.len(), result.mat.nrows());
751        assert_eq!(result.cols[0].as_ref(), "cell_centroid_x");
752        assert_eq!(result.cols[1].as_ref(), "cell_centroid_y");
753        assert!(result.mat.min() >= 0.0);
754    }
755
756    #[test]
757    fn test_read_zarr_coordinates_by_index() {
758        let Some(p) = xenium_path() else { return };
759        let result = read_zarr_coordinates(p.to_str().unwrap(), &[0, 1], &[]).unwrap();
760        assert_eq!(result.mat.ncols(), 2);
761        assert_eq!(result.cols[0].as_ref(), "cell_centroid_x");
762    }
763
764    #[test]
765    fn test_read_zarr_coordinates_default() {
766        let Some(p) = xenium_path() else { return };
767        let result = read_zarr_coordinates(p.to_str().unwrap(), &[], &[]).unwrap();
768        assert_eq!(result.mat.ncols(), 2);
769    }
770
771    #[test]
772    fn test_read_zarr_coordinates_bad_column() {
773        let Some(p) = xenium_path() else { return };
774        let result = read_zarr_coordinates(
775            p.to_str().unwrap(),
776            &[],
777            &["nonexistent".to_string().into_boxed_str()],
778        );
779        assert!(result.is_err());
780    }
781
782    #[test]
783    fn test_read_zarr_coordinates_mixed_defaults() {
784        // Simulates pinto's generous defaults: Visium names that won't match
785        // plus Xenium names that will — only matching names should be kept.
786        let Some(p) = xenium_path() else { return };
787        let result = read_zarr_coordinates(
788            p.to_str().unwrap(),
789            &[],
790            &[
791                "pxl_row_in_fullres".into(),
792                "pxl_col_in_fullres".into(),
793                "cell_centroid_x".into(),
794                "cell_centroid_y".into(),
795            ],
796        )
797        .unwrap();
798        assert_eq!(result.mat.ncols(), 2);
799        assert_eq!(result.cols[0].as_ref(), "cell_centroid_x");
800        assert_eq!(result.cols[1].as_ref(), "cell_centroid_y");
801        assert!(result.mat.nrows() > 0);
802    }
803
804    #[test]
805    fn test_read_zarr_array_attr() {
806        let Some(p) = xenium_path() else { return };
807        let store = open_zarr_store(p.to_str().unwrap()).unwrap();
808        let names: Vec<Box<str>> =
809            read_zarr_array_attr(store, "/cell_summary", "column_names").unwrap();
810        assert!(!names.is_empty());
811        assert!(names.contains(&"cell_centroid_x".to_string().into_boxed_str()));
812    }
813
814    #[test]
815    fn test_read_zarr_matrix_generic() {
816        let Some(p) = xenium_path() else { return };
817        // Read cell_summary selecting columns 2,3 (cell_area, nucleus_centroid_x)
818        let result = read_zarr_matrix(
819            p.to_str().unwrap(),
820            "/cell_summary",
821            Some("column_names"),
822            Some("/cell_id"),
823            true,
824            &[2, 3],
825            &[],
826        )
827        .unwrap();
828        assert_eq!(result.mat.ncols(), 2);
829        assert_eq!(result.cols[0].as_ref(), "cell_area");
830        assert_eq!(result.cols[1].as_ref(), "nucleus_centroid_x");
831    }
832
833    /// (entry name, contents) pairs for a test zip.
834    type Entries<'a> = &'a [(&'a str, &'a [u8])];
835
836    /// A zip `name` in `dir` of the given entries, written as is.
837    fn zip_of(dir: &tempfile::TempDir, name: &str, entries: Entries) -> std::path::PathBuf {
838        use std::io::Write;
839        let path = dir.path().join(name);
840        let mut w = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
841        let opts = zip::write::SimpleFileOptions::default();
842        for (entry, bytes) in entries {
843            w.start_file(*entry, opts).unwrap();
844            w.write_all(bytes).unwrap();
845        }
846        w.finish().unwrap();
847        path
848    }
849
850    fn extract(zip: &std::path::Path, out: &std::path::Path) -> anyhow::Result<()> {
851        extract_zarr_zip(zip.to_str().unwrap(), out.to_str().unwrap())
852    }
853
854    /// An entry that climbs out of the target (`run/../../x`, behind the
855    /// store's own prefix) refuses the whole archive before anything is
856    /// written.
857    #[test]
858    fn extracting_refuses_an_entry_that_leaves_the_target() {
859        let dir = tempfile::tempdir().unwrap();
860        let zip = zip_of(
861            &dir,
862            "run.zarr.zip",
863            &[
864                ("run/zarr.json", b"{}"),
865                ("run/../../escape.txt", b"escaped"),
866            ],
867        );
868        let target = dir.path().join("out").join("run.zarr");
869        let err = extract(&zip, &target).unwrap_err().to_string();
870        assert!(err.contains("no safe path"), "{err}");
871        assert!(
872            !dir.path().join("out").join("escape.txt").exists()
873                && !dir.path().join("escape.txt").exists(),
874            "an entry was written outside the target"
875        );
876        assert!(
877            !target.join("zarr.json").exists(),
878            "a refused archive left files behind"
879        );
880    }
881
882    /// A store zipped by `zip_dir_as` behind its `run/` root, or by `zip_dir`
883    /// behind the older `run.zarr/` one, extracts back to the same files.
884    #[test]
885    fn a_zipped_store_extracts_back_to_its_files() {
886        for root in [Some("run"), None] {
887            let dir = tempfile::tempdir().unwrap();
888            let src = dir.path().join("run.zarr");
889            std::fs::create_dir_all(src.join("X/c/0")).unwrap();
890            std::fs::write(src.join("zarr.json"), b"{}").unwrap();
891            std::fs::write(src.join("X/c/0/1"), b"chunk").unwrap();
892            let zip = dir.path().join("run.zarr.zip");
893            legume_numeric::matrix::common_io::zip_dir_as(
894                src.to_str().unwrap(),
895                zip.to_str().unwrap(),
896                root,
897            )
898            .unwrap();
899            let out = dir.path().join("back.zarr");
900            extract(&zip, &out).unwrap();
901            assert_eq!(
902                std::fs::read(out.join("zarr.json")).unwrap(),
903                b"{}",
904                "{root:?}"
905            );
906            assert_eq!(
907                std::fs::read(out.join("X/c/0/1")).unwrap(),
908                b"chunk",
909                "{root:?}"
910            );
911        }
912    }
913
914    /// The store's prefix is read from the entries: the archive's single
915    /// top-level directory, so a renamed archive keeps it and a store without
916    /// a root group (a nested array) still gets its root, with loose files
917    /// and an OS's metadata beside it ignored and left out of the extracted
918    /// store; beside other directories (a backup), the one named like the
919    /// archive; and none for a store at the archive's root.
920    #[test]
921    fn the_store_prefix_comes_from_the_entries() {
922        let dir = tempfile::tempdir().unwrap();
923        let cases: [(&str, Entries, &str); 7] = [
924            (
925                "nested.zarr.zip",
926                &[("run/m/X/zarr.json", b"{}"), ("run/m/X/c/0", b"chunk")],
927                "run/",
928            ),
929            (
930                "run.zarr.zip",
931                &[("run/zarr.json", b"{}"), ("run.zarr.bak/zarr.json", b"{}")],
932                "run/",
933            ),
934            (
935                "renamed.zarr.zip",
936                &[("run/zarr.json", b"{}"), ("run/X/zarr.json", b"{}")],
937                "run/",
938            ),
939            (
940                "no_group.zarr.zip",
941                &[("run/X/zarr.json", b"{}"), ("run/rows/zarr.json", b"{}")],
942                "run/",
943            ),
944            (
945                "stray.zarr.zip",
946                &[
947                    ("README.txt", b""),
948                    ("__MACOSX/run/._zarr.json", b""),
949                    ("run/zarr.json", b"{}"),
950                ],
951                "run/",
952            ),
953            (
954                "v2.zarr.zip",
955                &[("old.zarr/.zgroup", b"{}"), ("old.zarr/X/.zarray", b"{}")],
956                "old.zarr/",
957            ),
958            (
959                "at_root.zarr.zip",
960                &[("zarr.json", b"{}"), ("X/zarr.json", b"{}")],
961                "",
962            ),
963        ];
964        for (name, entries, prefix) in cases {
965            let zip = zip_of(&dir, name, entries);
966            assert_eq!(&*detect_zip_zarr_prefix(&zip).unwrap(), prefix, "{name}");
967        }
968        for (name, kept, dropped) in [
969            ("stray.zarr.zip", "zarr.json", "README.txt"),
970            ("no_group.zarr.zip", "rows/zarr.json", "run"),
971        ] {
972            let out = dir.path().join(name).with_extension("out");
973            extract(&dir.path().join(name), &out).unwrap();
974            assert!(
975                out.join(kept).exists() && !out.join(dropped).exists(),
976                "{name}"
977            );
978        }
979    }
980}