Skip to main content

trx_rs/io/
zip.rs

1use std::collections::{BTreeSet, HashMap};
2use std::fs;
3use std::io::Write;
4use std::path::Path;
5use zip::write::SimpleFileOptions;
6
7use super::archive_edit::{self, ArchiveOp};
8use crate::dtype::TrxScalar;
9use crate::error::{Result, TrxError};
10use crate::header::Header;
11use crate::io::filename::TrxFilename;
12use crate::mmap_backing::vec_to_bytes;
13use crate::trx_file::{DataArray, DataPerGroup, TrxFile};
14
15/// On-disk width for the `offsets.*` array. The TRX spec accepts both
16/// `uint32` and `uint64`; we auto-pick at write time via [`pick_for`] so
17/// every-day tractograms (≤ 4 G vertices) stay compact and only genuinely
18/// huge files pay the doubled-width cost.
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub(crate) enum OffsetsDtype {
21    U32,
22    // Reserved for the day the in-memory offset representation widens past
23    // u32. The reader already accepts `offsets.uint64`; the writer will
24    // start emitting it once `pick_for` can return this variant.
25    #[allow(dead_code)]
26    U64,
27}
28
29impl OffsetsDtype {
30    /// Pick the narrowest dtype that fits every offset in the slice.
31    pub(crate) fn pick_for(offsets: &[u32]) -> Self {
32        // The in-memory representation is `&[u32]`, so by definition every
33        // value fits in `u32`. We still keep this function as the canonical
34        // place to widen the rule if the in-memory type ever changes.
35        let _ = offsets;
36        OffsetsDtype::U32
37    }
38
39    /// Filename suffix written for this dtype (e.g. `"uint64"`).
40    pub(crate) fn suffix(self) -> &'static str {
41        match self {
42            OffsetsDtype::U32 => "uint32",
43            OffsetsDtype::U64 => "uint64",
44        }
45    }
46
47    /// Serialise an in-memory `u32` offset slice to disk bytes at this width.
48    pub(crate) fn encode(self, offsets: &[u32]) -> Vec<u8> {
49        match self {
50            OffsetsDtype::U32 => crate::mmap_backing::vec_to_bytes(offsets.to_vec()),
51            OffsetsDtype::U64 => {
52                let widened: Vec<u64> = offsets.iter().map(|&o| o as u64).collect();
53                crate::mmap_backing::vec_to_bytes(widened)
54            }
55        }
56    }
57}
58
59#[derive(Debug, Default)]
60struct TrxArchiveIndex {
61    dps: HashMap<String, String>,
62    dpv: HashMap<String, String>,
63    groups: HashMap<String, String>,
64    dpg: HashMap<String, HashMap<String, String>>,
65}
66
67/// Load a TRX file from a `.trx` zip archive.
68///
69/// Extracts the archive to a temporary directory, then delegates to the
70/// directory loader. The `TempDir` handle is stored in the returned `TrxFile`
71/// so the temp files remain alive while mmaps reference them.
72pub fn load_from_zip<P: TrxScalar>(path: &Path) -> Result<TrxFile<P>> {
73    let file = fs::File::open(path)?;
74    let mut archive = zip::ZipArchive::new(file)?;
75
76    let tempdir = tempfile::TempDir::new()?;
77    let temp_path = tempdir.path().to_path_buf();
78
79    for i in 0..archive.len() {
80        let mut entry = archive.by_index(i)?;
81        let entry_path = temp_path.join(entry.name());
82
83        if entry.is_dir() {
84            fs::create_dir_all(&entry_path)?;
85        } else {
86            if let Some(parent) = entry_path.parent() {
87                fs::create_dir_all(parent)?;
88            }
89            let mut out_file = fs::File::create(&entry_path)?;
90            std::io::copy(&mut entry, &mut out_file)?;
91        }
92    }
93
94    crate::io::directory::load_from_directory(&temp_path, Some(tempdir))
95}
96
97/// Save a `TrxFile<P>` to a `.trx` zip archive.
98///
99/// All entries are written uncompressed (Stored). DEFLATE rarely pays off on
100/// the float-dominated payload of a TRX file: compression ratios are typically
101/// <15% and write time grows substantially. Callers who want to compress the
102/// `groups/` entries (uint32 streamline-index lists, which do tend to have
103/// runs) can use [`save_to_zip_with`].
104pub fn save_to_zip<P: TrxScalar>(trx: &TrxFile<P>, path: &Path) -> Result<()> {
105    save_to_zip_with(trx, path, zip::CompressionMethod::Stored)
106}
107
108/// Save a `TrxFile<P>` to a `.trx` zip archive, applying `groups_compression`
109/// to `groups/` entries only. All other entries (header, positions, offsets,
110/// dps, dpv, dpg) are always Stored.
111pub fn save_to_zip_with<P: TrxScalar>(
112    trx: &TrxFile<P>,
113    path: &Path,
114    groups_compression: zip::CompressionMethod,
115) -> Result<()> {
116    let offsets_dtype = OffsetsDtype::pick_for(trx.offsets());
117    let file = fs::File::create(path)?;
118    let mut zip = zip::ZipWriter::new(file);
119    let stored = SimpleFileOptions::default()
120        .compression_method(zip::CompressionMethod::Stored)
121        .large_file(true);
122    let groups_opts = SimpleFileOptions::default()
123        .compression_method(groups_compression)
124        .large_file(true);
125
126    // Header
127    let header_json = trx.header().to_json()?;
128    zip.start_file("header.json", stored)?;
129    zip.write_all(header_json.as_bytes())?;
130
131    // Positions
132    let pos_filename = format!("positions.3.{}", P::DTYPE.name());
133    zip.start_file(&pos_filename, stored)?;
134    zip.write_all(trx.positions_bytes())?;
135
136    // Offsets — written at `offsets_dtype`'s width.
137    let offsets_filename = format!("offsets.{}", offsets_dtype.suffix());
138    zip.start_file(&offsets_filename, stored)?;
139    let offsets_bytes = offsets_dtype.encode(trx.offsets());
140    zip.write_all(&offsets_bytes)?;
141
142    // DPS / DPV — float-heavy, Stored.
143    write_data_map(&mut zip, "dps", trx.dps_arrays(), stored)?;
144    write_data_map(&mut zip, "dpv", trx.dpv_arrays(), stored)?;
145
146    // Groups — uint32 membership lists; honor caller's compression choice.
147    write_data_map(&mut zip, "groups", trx.group_arrays(), groups_opts)?;
148
149    // DPG — tiny per-group scalars, Stored.
150    write_dpg_map(&mut zip, "dpg", trx.dpg_arrays(), stored)?;
151
152    zip.finish()?;
153    Ok(())
154}
155
156/// Append DPS arrays to a TRX zip archive, optionally overwriting existing entries.
157pub fn append_dps_to_zip(
158    path: &Path,
159    dps: &HashMap<String, DataArray>,
160    compression: zip::CompressionMethod,
161    overwrite: bool,
162) -> Result<()> {
163    let header = read_header_from_zip(path)?;
164    validate_row_count("DPS", dps, header.nb_streamlines as usize)?;
165    let index = build_archive_index(path)?;
166    let mut ops = Vec::new();
167    for (name, arr) in dps {
168        let target = data_entry_path("dps", name, arr);
169        plan_data_write(
170            &index.dps,
171            name,
172            target,
173            arr,
174            overwrite,
175            compression,
176            &mut ops,
177        )?;
178    }
179    archive_edit::apply_archive_ops(path, ops)
180}
181
182/// Append DPV arrays to a TRX zip archive, optionally overwriting existing entries.
183pub fn append_dpv_to_zip(
184    path: &Path,
185    dpv: &HashMap<String, DataArray>,
186    compression: zip::CompressionMethod,
187    overwrite: bool,
188) -> Result<()> {
189    let header = read_header_from_zip(path)?;
190    validate_row_count("DPV", dpv, header.nb_vertices as usize)?;
191    let index = build_archive_index(path)?;
192    let mut ops = Vec::new();
193    for (name, arr) in dpv {
194        let target = data_entry_path("dpv", name, arr);
195        plan_data_write(
196            &index.dpv,
197            name,
198            target,
199            arr,
200            overwrite,
201            compression,
202            &mut ops,
203        )?;
204    }
205    archive_edit::apply_archive_ops(path, ops)
206}
207
208/// Append group membership arrays to a TRX zip archive, optionally overwriting existing entries.
209pub fn append_groups_to_zip(
210    path: &Path,
211    groups: &HashMap<String, Vec<u32>>,
212    compression: zip::CompressionMethod,
213    overwrite: bool,
214) -> Result<()> {
215    let header = read_header_from_zip(path)?;
216    let index = build_archive_index(path)?;
217    let mut ops = Vec::new();
218    for (name, members) in groups {
219        validate_group_members(name, members, header.nb_streamlines as usize)?;
220        let target = format!("groups/{name}.uint32");
221        let bytes = vec_to_bytes(members.clone());
222        plan_bytes_write(
223            index.groups.get(name),
224            target,
225            bytes,
226            overwrite,
227            compression,
228            &mut ops,
229        )?;
230    }
231    archive_edit::apply_archive_ops(path, ops)
232}
233
234/// Append DPG (data-per-group) entries to a TRX zip archive, optionally overwriting existing entries.
235pub fn append_dpg_to_zip(
236    path: &Path,
237    dpg: &DataPerGroup,
238    compression: zip::CompressionMethod,
239    overwrite: bool,
240) -> Result<()> {
241    let index = build_archive_index(path)?;
242    let available_groups: BTreeSet<&str> = index.groups.keys().map(String::as_str).collect();
243    let mut ops = Vec::new();
244    for (group, arrays) in dpg {
245        if !available_groups.contains(group.as_str()) {
246            return Err(TrxError::Argument(format!(
247                "cannot add DPG entries for missing group '{group}'"
248            )));
249        }
250        let existing = index.dpg.get(group);
251        for (name, arr) in arrays {
252            let target = format!("dpg/{group}/{}", filename_for_array(name, arr));
253            let existing_path = existing.and_then(|entries| entries.get(name));
254            plan_bytes_write(
255                existing_path,
256                target,
257                arr.as_bytes().to_vec(),
258                overwrite,
259                compression,
260                &mut ops,
261            )?;
262        }
263    }
264    archive_edit::apply_archive_ops(path, ops)
265}
266
267/// Delete named DPS arrays from a TRX zip archive.
268pub fn delete_dps_from_zip(path: &Path, names: &[&str]) -> Result<()> {
269    let index = build_archive_index(path)?;
270    let mut ops = Vec::new();
271    for name in names {
272        if let Some(entry_path) = index.dps.get(*name) {
273            ops.push(ArchiveOp::Delete {
274                path: entry_path.clone(),
275            });
276        }
277    }
278    archive_edit::apply_archive_ops(path, ops)
279}
280
281/// Delete named DPV arrays from a TRX zip archive.
282pub fn delete_dpv_from_zip(path: &Path, names: &[&str]) -> Result<()> {
283    let index = build_archive_index(path)?;
284    let mut ops = Vec::new();
285    for name in names {
286        if let Some(entry_path) = index.dpv.get(*name) {
287            ops.push(ArchiveOp::Delete {
288                path: entry_path.clone(),
289            });
290        }
291    }
292    archive_edit::apply_archive_ops(path, ops)
293}
294
295/// Delete named groups (and their DPG entries) from a TRX zip archive.
296pub fn delete_groups_from_zip(path: &Path, names: &[&str]) -> Result<()> {
297    let index = build_archive_index(path)?;
298    let mut ops = Vec::new();
299    for name in names {
300        if let Some(entry_path) = index.groups.get(*name) {
301            ops.push(ArchiveOp::Delete {
302                path: entry_path.clone(),
303            });
304        }
305        ops.push(ArchiveOp::DeletePrefix {
306            prefix: format!("dpg/{name}"),
307        });
308    }
309    archive_edit::apply_archive_ops(path, ops)
310}
311
312/// Delete DPG entries for a specific group from a TRX zip archive.
313///
314/// When `names` is `None` or empty, the entire DPG prefix for the group is removed.
315/// When `names` lists specific fields, only those entries are deleted.
316pub fn delete_dpg_from_zip(path: &Path, group: &str, names: Option<&[&str]>) -> Result<()> {
317    let index = build_archive_index(path)?;
318    let mut ops = Vec::new();
319    match names {
320        None => ops.push(ArchiveOp::DeletePrefix {
321            prefix: format!("dpg/{group}"),
322        }),
323        Some([]) => ops.push(ArchiveOp::DeletePrefix {
324            prefix: format!("dpg/{group}"),
325        }),
326        Some(names) => {
327            if let Some(entries) = index.dpg.get(group) {
328                for name in names {
329                    if let Some(entry_path) = entries.get(*name) {
330                        ops.push(ArchiveOp::Delete {
331                            path: entry_path.clone(),
332                        });
333                    }
334                }
335            }
336        }
337    }
338    archive_edit::apply_archive_ops(path, ops)
339}
340
341fn read_header_from_zip(path: &Path) -> Result<Header> {
342    let bytes = archive_edit::read_archive_entry(path, "header.json")?;
343    Ok(serde_json::from_slice(&bytes)?)
344}
345
346fn build_archive_index(path: &Path) -> Result<TrxArchiveIndex> {
347    let entries = archive_edit::archive_entry_names(path)?;
348    let mut index = TrxArchiveIndex::default();
349
350    for entry in entries {
351        if let Some(rest) = entry.strip_prefix("dps/") {
352            index_entry(&mut index.dps, &entry, rest)?;
353        } else if let Some(rest) = entry.strip_prefix("dpv/") {
354            index_entry(&mut index.dpv, &entry, rest)?;
355        } else if let Some(rest) = entry.strip_prefix("groups/") {
356            index_entry(&mut index.groups, &entry, rest)?;
357        } else if let Some(rest) = entry.strip_prefix("dpg/") {
358            if let Some((group, file_name)) = rest.split_once('/') {
359                let parsed = TrxFilename::parse(file_name)?;
360                let group_entries = index.dpg.entry(group.to_string()).or_default();
361                if group_entries.insert(parsed.name, entry.clone()).is_some() {
362                    return Err(TrxError::Format(format!(
363                        "duplicate DPG entry path for group '{group}'"
364                    )));
365                }
366            }
367        }
368    }
369
370    Ok(index)
371}
372
373fn index_entry(
374    index: &mut HashMap<String, String>,
375    full_path: &str,
376    file_name: &str,
377) -> Result<()> {
378    if file_name.ends_with('/') {
379        return Ok(());
380    }
381    let parsed = TrxFilename::parse(file_name)?;
382    if index.insert(parsed.name, full_path.to_string()).is_some() {
383        return Err(TrxError::Format(format!(
384            "duplicate archive entry for '{full_path}'"
385        )));
386    }
387    Ok(())
388}
389
390fn validate_row_count(
391    kind: &str,
392    arrays: &HashMap<String, DataArray>,
393    expected_rows: usize,
394) -> Result<()> {
395    for (name, arr) in arrays {
396        if arr.nrows() != expected_rows {
397            return Err(TrxError::Format(format!(
398                "{kind} '{name}' has {} rows, expected {expected_rows}",
399                arr.nrows()
400            )));
401        }
402    }
403    Ok(())
404}
405
406fn validate_group_members(name: &str, members: &[u32], nb_streamlines: usize) -> Result<()> {
407    for &member in members {
408        if member as usize >= nb_streamlines {
409            return Err(TrxError::Format(format!(
410                "group '{name}' contains streamline index {member}, but NB_STREAMLINES is {nb_streamlines}"
411            )));
412        }
413    }
414    Ok(())
415}
416
417fn data_entry_path(prefix: &str, name: &str, arr: &DataArray) -> String {
418    format!("{prefix}/{}", filename_for_array(name, arr))
419}
420
421fn filename_for_array(name: &str, arr: &DataArray) -> String {
422    TrxFilename {
423        name: name.to_string(),
424        ncols: arr.ncols(),
425        dtype: arr.dtype(),
426    }
427    .to_filename()
428}
429
430fn plan_data_write(
431    existing: &HashMap<String, String>,
432    logical_name: &str,
433    target_path: String,
434    arr: &DataArray,
435    overwrite: bool,
436    compression: zip::CompressionMethod,
437    ops: &mut Vec<ArchiveOp>,
438) -> Result<()> {
439    plan_bytes_write(
440        existing.get(logical_name),
441        target_path,
442        arr.as_bytes().to_vec(),
443        overwrite,
444        compression,
445        ops,
446    )
447}
448
449fn plan_bytes_write(
450    existing_path: Option<&String>,
451    target_path: String,
452    bytes: Vec<u8>,
453    overwrite: bool,
454    compression: zip::CompressionMethod,
455    ops: &mut Vec<ArchiveOp>,
456) -> Result<()> {
457    match existing_path {
458        None => ops.push(ArchiveOp::Add {
459            path: target_path,
460            bytes,
461            compression,
462        }),
463        Some(_) if !overwrite => {}
464        Some(existing) if existing == &target_path => ops.push(ArchiveOp::Replace {
465            path: target_path,
466            bytes,
467            compression,
468        }),
469        Some(existing) => {
470            ops.push(ArchiveOp::Delete {
471                path: existing.clone(),
472            });
473            ops.push(ArchiveOp::Add {
474                path: target_path,
475                bytes,
476                compression,
477            });
478        }
479    }
480    Ok(())
481}
482
483fn write_data_map<W: Write + std::io::Seek>(
484    zip: &mut zip::ZipWriter<W>,
485    prefix: &str,
486    arrays: &HashMap<String, DataArray>,
487    options: SimpleFileOptions,
488) -> Result<()> {
489    for (name, arr) in arrays {
490        let filename = filename_for_array(name, arr);
491        let entry_name = format!("{prefix}/{filename}");
492        zip.start_file(&entry_name, options)?;
493        zip.write_all(arr.as_bytes())?;
494    }
495    Ok(())
496}
497
498fn write_dpg_map<W: Write + std::io::Seek>(
499    zip: &mut zip::ZipWriter<W>,
500    prefix: &str,
501    groups: &DataPerGroup,
502    options: SimpleFileOptions,
503) -> Result<()> {
504    for (group, arrays) in groups {
505        for (name, arr) in arrays {
506            let filename = filename_for_array(name, arr);
507            let entry_name = format!("{prefix}/{group}/{filename}");
508            zip.start_file(&entry_name, options)?;
509            zip.write_all(arr.as_bytes())?;
510        }
511    }
512    Ok(())
513}