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 output_path(output: &str, zip: bool, backend: &SparseIoBackend) -> String {
114 let effective = apply_zip_flag(output, zip, backend);
115 if effective.ends_with(".zarr.zip") {
116 return effective.to_string();
117 }
118 crate::hdf5_io::resolve_backend_file(&effective, Some(backend.clone()))
119 .map_or_else(|_| effective.to_string(), |(_, f)| f.to_string())
120}
121
122pub fn apply_zip_flag(output: &str, zip: bool, backend: &SparseIoBackend) -> Box<str> {
131 let target_is_hdf5 = matches!(backend, SparseIoBackend::HDF5)
136 || output.ends_with(".h5")
137 || output.ends_with(".hdf5");
138 if zip && !target_is_hdf5 && !output.ends_with(".zarr.zip") {
139 let base = crate::hdf5_io::strip_backend_suffix(output);
140 format!("{}.zarr.zip", base).into()
141 } else {
142 output.into()
143 }
144}
145
146pub struct OutputTarget {
148 pub effective: Box<str>,
150 pub backend: SparseIoBackend,
151 pub working: Box<str>,
154 pub found: Box<str>,
157}
158
159pub fn output_target(
161 output: &str,
162 backend: SparseIoBackend,
163 zip: bool,
164) -> anyhow::Result<OutputTarget> {
165 let effective = apply_zip_flag(output, zip, &backend);
166 let (backend, working) = crate::hdf5_io::resolve_backend_file(&effective, Some(backend))?;
167 let found = if effective.ends_with(".zarr.zip") {
168 effective.clone()
169 } else {
170 working.clone()
171 };
172 Ok(OutputTarget {
173 effective,
174 backend,
175 working,
176 found,
177 })
178}
179
180pub fn prepare_output(
186 output: &str,
187 backend: SparseIoBackend,
188 zip: bool,
189) -> anyhow::Result<(Box<str>, SparseIoBackend, Box<str>)> {
190 use legume_numeric::matrix::common_io::remove_file;
191
192 let t = output_target(output, backend, zip)?;
193 if std::path::Path::new(t.working.as_ref()).exists() {
194 remove_file(&t.working)?;
195 }
196 Ok((t.effective, t.backend, t.working))
197}
198
199pub fn finalize_output<'a>(
203 working_file: &'a str,
204 effective_output: &'a str,
205) -> anyhow::Result<&'a str> {
206 finalize_zarr_output(working_file, effective_output)?;
207 Ok(if effective_output.ends_with(".zarr.zip") {
208 effective_output
209 } else {
210 working_file
211 })
212}
213
214pub fn extract_zarr_zip(zip_path: &str, target_dir: &str) -> anyhow::Result<()> {
218 let root = zip_zarr_root(std::path::Path::new(zip_path))?;
219 legume_numeric::matrix::common_io::unzip_subdir(zip_path, &root, target_dir)
220}
221
222pub fn materialize_writable_backend(src: &str, dst: &str) -> anyhow::Result<()> {
226 if src.ends_with(".zarr.zip") {
227 info!("extracting {} → {}", src, dst);
228 extract_zarr_zip(src, dst)
229 } else {
230 legume_numeric::matrix::common_io::recursive_copy(src, dst)
231 }
232}
233
234pub fn finalize_zarr_output(zarr_dir: &str, target_path: &str) -> anyhow::Result<()> {
244 if !target_path.ends_with(".zarr.zip") {
245 return Ok(());
246 }
247
248 let zarr_path = std::path::Path::new(zarr_dir);
249 let stem = zarr_path
250 .file_name()
251 .and_then(|s| s.to_str())
252 .map(|s| s.strip_suffix(".zarr").unwrap_or(s))
253 .ok_or_else(|| anyhow::anyhow!("invalid zarr_dir: {}", zarr_dir))?
254 .to_string();
255
256 let tmp_target = format!("{}.tmp", target_path);
261 info!("Zipping zarr output: {} → {}", zarr_dir, target_path);
262 if let Err(e) =
263 legume_numeric::matrix::common_io::zip_dir_as(zarr_dir, &tmp_target, Some(&stem))
264 {
265 let _ = std::fs::remove_file(&tmp_target);
266 return Err(e);
267 }
268 if let Err(e) = std::fs::rename(&tmp_target, target_path) {
269 let _ = std::fs::remove_file(&tmp_target);
270 return Err(e.into());
271 }
272 std::fs::remove_dir_all(zarr_dir)?;
273 Ok(())
274}
275
276pub fn read_zarr_array_attr<V: serde::de::DeserializeOwned>(
284 store: Arc<dyn ZReadStorageTraits>,
285 array_path: &str,
286 attr_name: &str,
287) -> anyhow::Result<V> {
288 let arr = ZArray::open_opt(store, array_path, &MetadataRetrieveVersion::Default)?;
289 let attr = arr.attributes().get(attr_name).ok_or_else(|| {
290 anyhow::anyhow!(
291 "attribute '{}' not found on array '{}'",
292 attr_name,
293 array_path
294 )
295 })?;
296 Ok(serde_json::from_value(attr.clone())?)
297}
298
299pub fn read_zarr_flat_f32(
305 store: Arc<dyn ZReadStorageTraits>,
306 key: &str,
307) -> anyhow::Result<(Vec<f32>, Vec<u64>)> {
308 let arr = ZArray::open_opt(store, key, &MetadataRetrieveVersion::Default)?;
309 let shape = arr.shape().to_vec();
310 let subset = arr.subset_all();
311
312 let dt = arr.data_type();
313 let data = if *dt == data_type::float32() {
314 arr.retrieve_array_subset::<Vec<f32>>(&subset)?
315 } else if *dt == data_type::float64() {
316 arr.retrieve_array_subset::<Vec<f64>>(&subset)?
317 .into_iter()
318 .map(|x| x as f32)
319 .collect()
320 } else if *dt == data_type::uint32() {
321 arr.retrieve_array_subset::<Vec<u32>>(&subset)?
322 .into_iter()
323 .map(|x| x as f32)
324 .collect()
325 } else if *dt == data_type::uint64() {
326 arr.retrieve_array_subset::<Vec<u64>>(&subset)?
327 .into_iter()
328 .map(|x| x as f32)
329 .collect()
330 } else {
331 anyhow::bail!("unsupported zarr data type: {:?}", dt)
332 };
333
334 Ok((data, shape))
335}
336
337pub fn read_zarr_flat_u32(
339 store: Arc<dyn ZReadStorageTraits>,
340 key: &str,
341) -> anyhow::Result<(Vec<u32>, Vec<u64>)> {
342 let arr = ZArray::open_opt(store, key, &MetadataRetrieveVersion::Default)?;
343 let shape = arr.shape().to_vec();
344 let subset = arr.subset_all();
345
346 let dt = arr.data_type();
347 let data = if *dt == data_type::uint32() {
348 arr.retrieve_array_subset::<Vec<u32>>(&subset)?
349 } else if *dt == data_type::uint64() {
350 arr.retrieve_array_subset::<Vec<u64>>(&subset)?
351 .into_iter()
352 .map(|x| x as u32)
353 .collect()
354 } else {
355 anyhow::bail!("unsupported zarr data type for u32: {:?}", dt)
356 };
357
358 Ok((data, shape))
359}
360
361#[cfg(feature = "ndarray")]
364pub fn read_zarr_ndarray<T>(
366 store: Arc<dyn ZReadStorageTraits>,
367 key_name: &str,
368) -> anyhow::Result<ndarray::ArrayD<T>>
369where
370 T: zarrs::array::ElementOwned + FromPrimitive,
371{
372 let arr = ZArray::open_opt(store, key_name, &MetadataRetrieveVersion::Default)?;
373
374 let dt = arr.data_type();
375 if *dt == data_type::float32() {
376 let array: ndarray::ArrayD<f32> =
377 arr.retrieve_array_subset::<ndarray::ArrayD<f32>>(&arr.subset_all())?;
378 Ok(array.mapv(|x| T::from_f32(x).unwrap()))
379 } else if *dt == data_type::float64() {
380 let array: ndarray::ArrayD<f64> =
381 arr.retrieve_array_subset::<ndarray::ArrayD<f64>>(&arr.subset_all())?;
382 Ok(array.mapv(|x| T::from_f64(x).unwrap()))
383 } else if *dt == data_type::uint32() {
384 let array: ndarray::ArrayD<u32> =
385 arr.retrieve_array_subset::<ndarray::ArrayD<u32>>(&arr.subset_all())?;
386 Ok(array.mapv(|x| T::from_u32(x).unwrap()))
387 } else if *dt == data_type::uint64() {
388 let array: ndarray::ArrayD<u64> =
389 arr.retrieve_array_subset::<ndarray::ArrayD<u64>>(&arr.subset_all())?;
390 Ok(array.mapv(|x| T::from_u64(x).unwrap()))
391 } else {
392 anyhow::bail!("unsupported zarr data type: {:?}", dt)
393 }
394}
395
396pub fn read_zarr_numerics<T>(
398 store: Arc<dyn ZReadStorageTraits>,
399 key_name: &str,
400) -> anyhow::Result<Vec<T>>
401where
402 T: zarrs::array::ElementOwned + FromPrimitive,
403{
404 let arr = ZArray::open_opt(store, key_name, &MetadataRetrieveVersion::Default)?;
405
406 let dt = arr.data_type();
407 let ret = if *dt == data_type::float32() {
408 arr.retrieve_array_subset::<Vec<f32>>(&arr.subset_all())?
409 .into_iter()
410 .map(|x| T::from_f32(x).unwrap())
411 .collect()
412 } else if *dt == data_type::float64() {
413 arr.retrieve_array_subset::<Vec<f64>>(&arr.subset_all())?
414 .into_iter()
415 .map(|x| T::from_f64(x).unwrap())
416 .collect()
417 } else if *dt == data_type::uint32() {
418 arr.retrieve_array_subset::<Vec<u32>>(&arr.subset_all())?
419 .into_iter()
420 .map(|x| T::from_u32(x).unwrap())
421 .collect()
422 } else if *dt == data_type::uint64() {
423 arr.retrieve_array_subset::<Vec<u64>>(&arr.subset_all())?
424 .into_iter()
425 .map(|x| T::from_u64(x).unwrap())
426 .collect()
427 } else {
428 anyhow::bail!("unsupported zarr data type: {:?}", dt);
429 };
430
431 Ok(ret)
432}
433
434pub fn read_zarr_group_attr<V>(
439 store: Arc<dyn ZReadStorageTraits>,
440 key_name: &str,
441) -> anyhow::Result<V>
442where
443 V: serde::de::DeserializeOwned,
444{
445 use anyhow::Context;
446
447 fn parse_key_name(key_name: &str) -> (Box<str>, Box<str>) {
448 let trimmed = key_name.strip_prefix('/').unwrap_or(key_name);
449 match trimmed.rsplit_once('/') {
450 Some((left, right)) => (
451 format!("/{}", left).into_boxed_str(),
452 right.to_string().into_boxed_str(),
453 ),
454 None => (
455 "/".to_string().into_boxed_str(),
456 trimmed.to_string().into_boxed_str(),
457 ),
458 }
459 }
460
461 let (group_name, attr_name) = parse_key_name(key_name);
462
463 let group = zarrs::group::Group::open_opt(
464 store,
465 group_name.as_ref(),
466 &MetadataRetrieveVersion::Default,
467 )
468 .with_context(|| format!("Failed to open group '{}'", group_name))?;
469
470 let attr_value = group
471 .attributes()
472 .get(attr_name.as_ref())
473 .with_context(|| {
474 format!(
475 "Attribute '{}' not found in group '{}'",
476 attr_name, group_name
477 )
478 })?;
479
480 Ok(serde_json::from_value(attr_value.clone())?)
481}
482
483pub fn read_zarr_strings(
485 store: Arc<dyn ZReadStorageTraits>,
486 key_name: &str,
487) -> anyhow::Result<Vec<Box<str>>> {
488 let arr = ZArray::open_opt(store, key_name, &MetadataRetrieveVersion::Default)?;
489
490 Ok(arr
491 .retrieve_array_subset::<Vec<String>>(&arr.subset_all())?
492 .into_iter()
493 .map(|x| x.into_boxed_str())
494 .collect())
495}
496
497fn hex_to_shifted_lookup() -> [Option<char>; 256] {
501 let mut lookup = [None; 256];
502 for (i, ch) in "0123456789abcdef".chars().enumerate() {
503 lookup[ch as usize] = Some(
504 [
505 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p',
506 ][i],
507 );
508 }
509 lookup
510}
511
512fn encode_10x_cell_id(
514 lookup: &[Option<char>; 256],
515 barcode: u32,
516 suffix: u32,
517) -> anyhow::Result<Box<str>> {
518 let barcode: String = format!("{:08x}", barcode)
519 .chars()
520 .map(|ch| lookup[ch as usize].ok_or_else(|| anyhow::anyhow!("invalid hex char: {}", ch)))
521 .collect::<anyhow::Result<String>>()?;
522 Ok(format!("{}-{}", barcode, suffix).into_boxed_str())
523}
524
525#[cfg(feature = "ndarray")]
526pub fn parse_10x_cell_id(
530 input: ndarray::ArrayView<u32, ndarray::IxDyn>,
531) -> anyhow::Result<Vec<Box<str>>> {
532 anyhow::ensure!(
533 input.ndim() == 2 && input.shape()[1] == 2,
534 "Must be 2D with shape [N, 2]"
535 );
536 let lookup = hex_to_shifted_lookup();
537 input
538 .outer_iter()
539 .map(|row| encode_10x_cell_id(&lookup, row[0], row[1]))
540 .collect()
541}
542
543pub fn parse_10x_cell_id_flat(data: &[u32], nrows: usize) -> anyhow::Result<Vec<Box<str>>> {
547 anyhow::ensure!(data.len() == nrows * 2, "cell_id buffer size mismatch");
548 let lookup = hex_to_shifted_lookup();
549 (0..nrows)
550 .map(|i| encode_10x_cell_id(&lookup, data[i * 2], data[i * 2 + 1]))
551 .collect()
552}
553
554fn select_columns_to_dmatrix(
559 data: &[f32],
560 nrows: usize,
561 ncols_total: usize,
562 selected: &[usize],
563) -> anyhow::Result<DMatrix<f32>> {
564 let k = selected.len();
565 let mut col_major = vec![0.0f32; nrows * k];
566 for (col_out, &col_in) in selected.iter().enumerate() {
567 anyhow::ensure!(col_in < ncols_total, "column index {} out of range", col_in);
568 for row in 0..nrows {
569 col_major[row + col_out * nrows] = data[row * ncols_total + col_in];
570 }
571 }
572 Ok(DMatrix::from_vec(nrows, k, col_major))
573}
574
575fn resolve_columns(
577 column_indices: &[usize],
578 column_names: &[Box<str>],
579 all_col_names: &[Box<str>],
580 default_cols: &[usize],
581) -> anyhow::Result<Vec<usize>> {
582 if !column_indices.is_empty() {
583 Ok(column_indices.to_vec())
584 } else if !column_names.is_empty() {
585 let matched: Vec<usize> = column_names
588 .iter()
589 .filter_map(|name| all_col_names.iter().position(|c| c == name))
590 .collect();
591 if matched.is_empty() {
592 anyhow::bail!(
593 "none of the requested columns {:?} found (available: {:?})",
594 column_names,
595 all_col_names
596 );
597 }
598 Ok(matched)
599 } else {
600 Ok(default_cols.to_vec())
601 }
602}
603
604pub fn read_zarr_matrix(
617 file_path: &str,
618 data_array: &str,
619 col_names_attr: Option<&str>,
620 row_names_array: Option<&str>,
621 row_names_10x: bool,
622 column_indices: &[usize],
623 column_names: &[Box<str>],
624) -> anyhow::Result<MatWithNames<DMatrix<f32>>> {
625 let store = open_zarr_store(file_path)?;
626
627 let (flat, shape) = read_zarr_flat_f32(store.clone(), data_array)?;
629 let nrows = shape[0] as usize;
630 let ncols_total = if shape.len() > 1 {
631 shape[1] as usize
632 } else {
633 1
634 };
635
636 let all_col_names: Vec<Box<str>> = if let Some(attr) = col_names_attr {
638 read_zarr_array_attr(store.clone(), data_array, attr).unwrap_or_else(|_| {
639 (0..ncols_total)
640 .map(|i| i.to_string().into_boxed_str())
641 .collect()
642 })
643 } else {
644 (0..ncols_total)
645 .map(|i| i.to_string().into_boxed_str())
646 .collect()
647 };
648
649 let selected = resolve_columns(column_indices, column_names, &all_col_names, &[0, 1])?;
651 let sel_names: Vec<Box<str>> = selected
652 .iter()
653 .map(|&i| {
654 all_col_names
655 .get(i)
656 .cloned()
657 .unwrap_or_else(|| i.to_string().into_boxed_str())
658 })
659 .collect();
660
661 let mat = select_columns_to_dmatrix(&flat, nrows, ncols_total, &selected)?;
663
664 let row_names = if let Some(rn_path) = row_names_array {
666 if row_names_10x {
667 let (id_data, id_shape) = read_zarr_flat_u32(store.clone(), rn_path)?;
668 anyhow::ensure!(
669 id_shape[0] as usize == nrows,
670 "row names array rows ({}) != data rows ({})",
671 id_shape[0],
672 nrows
673 );
674 parse_10x_cell_id_flat(&id_data, nrows)?
675 } else {
676 read_zarr_strings(store.clone(), rn_path)?
677 }
678 } else {
679 (0..nrows).map(|i| i.to_string().into_boxed_str()).collect()
680 };
681
682 info!(
683 "Read {} x {} from zarr {}{}: {:?}",
684 nrows,
685 selected.len(),
686 file_path,
687 data_array,
688 sel_names
689 );
690
691 Ok(MatWithNames {
692 rows: row_names,
693 cols: sel_names,
694 mat,
695 })
696}
697
698pub fn read_zarr_coordinates(
707 file_path: &str,
708 column_indices: &[usize],
709 column_names: &[Box<str>],
710) -> anyhow::Result<MatWithNames<DMatrix<f32>>> {
711 read_zarr_matrix(
712 file_path,
713 "/cell_summary",
714 Some("column_names"),
715 Some("/cell_id"),
716 true,
717 column_indices,
718 column_names,
719 )
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725
726 fn xenium_path() -> Option<std::path::PathBuf> {
727 let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
728 .parent()
729 .unwrap()
730 .join("docs/temp/cells.zarr.zip");
731 p.exists().then_some(p)
732 }
733
734 #[test]
735 fn test_parse_10x_cell_id_flat() {
736 let data = vec![16844u32, 1, 22527, 1];
737 let ids = parse_10x_cell_id_flat(&data, 2).unwrap();
738 assert_eq!(ids.len(), 2);
739 assert!(ids[0].ends_with("-1"), "got: {}", ids[0]);
740 assert!(ids[1].ends_with("-1"), "got: {}", ids[1]);
741 assert_eq!(ids[0].len(), 10);
742 }
743
744 #[test]
745 fn test_parse_10x_cell_id_flat_mismatch() {
746 assert!(parse_10x_cell_id_flat(&[1, 2, 3], 2).is_err());
747 }
748
749 #[test]
750 fn test_read_zarr_coordinates_by_name() {
751 let Some(p) = xenium_path() else { return };
752 let result = read_zarr_coordinates(
753 p.to_str().unwrap(),
754 &[],
755 &["cell_centroid_x".into(), "cell_centroid_y".into()],
756 )
757 .unwrap();
758
759 assert_eq!(result.mat.ncols(), 2);
760 assert!(result.mat.nrows() > 0);
761 assert_eq!(result.rows.len(), result.mat.nrows());
762 assert_eq!(result.cols[0].as_ref(), "cell_centroid_x");
763 assert_eq!(result.cols[1].as_ref(), "cell_centroid_y");
764 assert!(result.mat.min() >= 0.0);
765 }
766
767 #[test]
768 fn test_read_zarr_coordinates_by_index() {
769 let Some(p) = xenium_path() else { return };
770 let result = read_zarr_coordinates(p.to_str().unwrap(), &[0, 1], &[]).unwrap();
771 assert_eq!(result.mat.ncols(), 2);
772 assert_eq!(result.cols[0].as_ref(), "cell_centroid_x");
773 }
774
775 #[test]
776 fn test_read_zarr_coordinates_default() {
777 let Some(p) = xenium_path() else { return };
778 let result = read_zarr_coordinates(p.to_str().unwrap(), &[], &[]).unwrap();
779 assert_eq!(result.mat.ncols(), 2);
780 }
781
782 #[test]
783 fn test_read_zarr_coordinates_bad_column() {
784 let Some(p) = xenium_path() else { return };
785 let result = read_zarr_coordinates(
786 p.to_str().unwrap(),
787 &[],
788 &["nonexistent".to_string().into_boxed_str()],
789 );
790 assert!(result.is_err());
791 }
792
793 #[test]
794 fn test_read_zarr_coordinates_mixed_defaults() {
795 let Some(p) = xenium_path() else { return };
798 let result = read_zarr_coordinates(
799 p.to_str().unwrap(),
800 &[],
801 &[
802 "pxl_row_in_fullres".into(),
803 "pxl_col_in_fullres".into(),
804 "cell_centroid_x".into(),
805 "cell_centroid_y".into(),
806 ],
807 )
808 .unwrap();
809 assert_eq!(result.mat.ncols(), 2);
810 assert_eq!(result.cols[0].as_ref(), "cell_centroid_x");
811 assert_eq!(result.cols[1].as_ref(), "cell_centroid_y");
812 assert!(result.mat.nrows() > 0);
813 }
814
815 #[test]
816 fn test_read_zarr_array_attr() {
817 let Some(p) = xenium_path() else { return };
818 let store = open_zarr_store(p.to_str().unwrap()).unwrap();
819 let names: Vec<Box<str>> =
820 read_zarr_array_attr(store, "/cell_summary", "column_names").unwrap();
821 assert!(!names.is_empty());
822 assert!(names.contains(&"cell_centroid_x".to_string().into_boxed_str()));
823 }
824
825 #[test]
826 fn test_read_zarr_matrix_generic() {
827 let Some(p) = xenium_path() else { return };
828 let result = read_zarr_matrix(
830 p.to_str().unwrap(),
831 "/cell_summary",
832 Some("column_names"),
833 Some("/cell_id"),
834 true,
835 &[2, 3],
836 &[],
837 )
838 .unwrap();
839 assert_eq!(result.mat.ncols(), 2);
840 assert_eq!(result.cols[0].as_ref(), "cell_area");
841 assert_eq!(result.cols[1].as_ref(), "nucleus_centroid_x");
842 }
843
844 type Entries<'a> = &'a [(&'a str, &'a [u8])];
846
847 fn zip_of(dir: &tempfile::TempDir, name: &str, entries: Entries) -> std::path::PathBuf {
849 use std::io::Write;
850 let path = dir.path().join(name);
851 let mut w = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
852 let opts = zip::write::SimpleFileOptions::default();
853 for (entry, bytes) in entries {
854 w.start_file(*entry, opts).unwrap();
855 w.write_all(bytes).unwrap();
856 }
857 w.finish().unwrap();
858 path
859 }
860
861 fn extract(zip: &std::path::Path, out: &std::path::Path) -> anyhow::Result<()> {
862 extract_zarr_zip(zip.to_str().unwrap(), out.to_str().unwrap())
863 }
864
865 #[test]
869 fn extracting_refuses_an_entry_that_leaves_the_target() {
870 let dir = tempfile::tempdir().unwrap();
871 let zip = zip_of(
872 &dir,
873 "run.zarr.zip",
874 &[
875 ("run/zarr.json", b"{}"),
876 ("run/../../escape.txt", b"escaped"),
877 ],
878 );
879 let target = dir.path().join("out").join("run.zarr");
880 let err = extract(&zip, &target).unwrap_err().to_string();
881 assert!(err.contains("no safe path"), "{err}");
882 assert!(
883 !dir.path().join("out").join("escape.txt").exists()
884 && !dir.path().join("escape.txt").exists(),
885 "an entry was written outside the target"
886 );
887 assert!(
888 !target.join("zarr.json").exists(),
889 "a refused archive left files behind"
890 );
891 }
892
893 #[test]
896 fn a_zipped_store_extracts_back_to_its_files() {
897 for root in [Some("run"), None] {
898 let dir = tempfile::tempdir().unwrap();
899 let src = dir.path().join("run.zarr");
900 std::fs::create_dir_all(src.join("X/c/0")).unwrap();
901 std::fs::write(src.join("zarr.json"), b"{}").unwrap();
902 std::fs::write(src.join("X/c/0/1"), b"chunk").unwrap();
903 let zip = dir.path().join("run.zarr.zip");
904 legume_numeric::matrix::common_io::zip_dir_as(
905 src.to_str().unwrap(),
906 zip.to_str().unwrap(),
907 root,
908 )
909 .unwrap();
910 let out = dir.path().join("back.zarr");
911 extract(&zip, &out).unwrap();
912 assert_eq!(
913 std::fs::read(out.join("zarr.json")).unwrap(),
914 b"{}",
915 "{root:?}"
916 );
917 assert_eq!(
918 std::fs::read(out.join("X/c/0/1")).unwrap(),
919 b"chunk",
920 "{root:?}"
921 );
922 }
923 }
924
925 #[test]
932 fn the_store_prefix_comes_from_the_entries() {
933 let dir = tempfile::tempdir().unwrap();
934 let cases: [(&str, Entries, &str); 7] = [
935 (
936 "nested.zarr.zip",
937 &[("run/m/X/zarr.json", b"{}"), ("run/m/X/c/0", b"chunk")],
938 "run/",
939 ),
940 (
941 "run.zarr.zip",
942 &[("run/zarr.json", b"{}"), ("run.zarr.bak/zarr.json", b"{}")],
943 "run/",
944 ),
945 (
946 "renamed.zarr.zip",
947 &[("run/zarr.json", b"{}"), ("run/X/zarr.json", b"{}")],
948 "run/",
949 ),
950 (
951 "no_group.zarr.zip",
952 &[("run/X/zarr.json", b"{}"), ("run/rows/zarr.json", b"{}")],
953 "run/",
954 ),
955 (
956 "stray.zarr.zip",
957 &[
958 ("README.txt", b""),
959 ("__MACOSX/run/._zarr.json", b""),
960 ("run/zarr.json", b"{}"),
961 ],
962 "run/",
963 ),
964 (
965 "v2.zarr.zip",
966 &[("old.zarr/.zgroup", b"{}"), ("old.zarr/X/.zarray", b"{}")],
967 "old.zarr/",
968 ),
969 (
970 "at_root.zarr.zip",
971 &[("zarr.json", b"{}"), ("X/zarr.json", b"{}")],
972 "",
973 ),
974 ];
975 for (name, entries, prefix) in cases {
976 let zip = zip_of(&dir, name, entries);
977 assert_eq!(&*detect_zip_zarr_prefix(&zip).unwrap(), prefix, "{name}");
978 }
979 for (name, kept, dropped) in [
980 ("stray.zarr.zip", "zarr.json", "README.txt"),
981 ("no_group.zarr.zip", "rows/zarr.json", "run"),
982 ] {
983 let out = dir.path().join(name).with_extension("out");
984 extract(&dir.path().join(name), &out).unwrap();
985 assert!(
986 out.join(kept).exists() && !out.join(dropped).exists(),
987 "{name}"
988 );
989 }
990 }
991}