1use 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
20pub type ZarrStoreRw = (Arc<dyn ZReadStorageTraits>, Option<Arc<FilesystemStore>>);
24
25fn 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
60fn 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
71pub 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
106pub fn open_zarr_store(path: &str) -> anyhow::Result<Arc<dyn ZReadStorageTraits>> {
108 open_zarr_store_rw(path).map(|(r, _)| r)
109}
110
111pub fn apply_zip_flag(output: &str, zip: bool, backend: &SparseIoBackend) -> Box<str> {
120 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
135pub struct OutputTarget {
137 pub effective: Box<str>,
139 pub backend: SparseIoBackend,
140 pub working: Box<str>,
143 pub found: Box<str>,
146}
147
148pub 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
169pub 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
188pub 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
203pub 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
211pub 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
223pub 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 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
265pub 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
288pub 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
326pub 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#[cfg(feature = "ndarray")]
353pub 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
385pub 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
423pub 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
472pub 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
486fn 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
501fn 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")]
515pub 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
532pub 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
543fn 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
564fn 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 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
593pub 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 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 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 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 let mat = select_columns_to_dmatrix(&flat, nrows, ncols_total, &selected)?;
652
653 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
687pub 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 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 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 type Entries<'a> = &'a [(&'a str, &'a [u8])];
835
836 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 #[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 #[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 #[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}