Skip to main content

znippy_common/
views.rs

1//! Typed specialized package views — the READ side of the plugin contract.
2//!
3//! A znippy plugin writes per-ecosystem metadata into Arrow columns at compress
4//! time (maven: `group_id`/`artifact_id`/`version`; python: `name`/`version`;
5//! rust: `crate_name`/`version`), discriminated by `pkg_type`. This module is the
6//! symmetric READ side: it turns those columns back into a typed, coord-addressed
7//! view so consumers never re-derive coordinates by parsing file paths.
8//!
9//! ## Performance contract (HARD)
10//!
11//! 1. **The coord index is built ONCE**, at view construction, via
12//!    [`read_znippy_index_filtered`](crate::read_znippy_index_filtered) with an
13//!    [`IndexFilter`](crate::IndexFilter) pinned to the plugin's `pkg_type` — so
14//!    only that one sub-index stream is read and parsed. The result is a
15//!    `HashMap<Key, FileLoc>` interned into the view; the holder ([`ZnippyArchive`])
16//!    caches it behind a `OnceLock` per `pkg_type`, so repeated `as_maven()` is free.
17//! 2. **`get(coords)` is an O(1) map lookup** returning a lightweight handle
18//!    ([`Package`]): coords + a borrowed `FileLoc` + the shared fd. No scan, no
19//!    Arrow re-parse, no decompression, no bytes copied.
20//! 3. **Bytes are LAZY** — [`Package::bytes`] is the only thing that preads the
21//!    blob(s) and decompresses (the same loop as [`crate::ZnippyArchive::extract_file`]),
22//!    and only when called. A `fetch` that only needs existence/size never decompresses.
23
24use std::collections::HashMap;
25use std::fs::File;
26use std::os::unix::fs::FileExt;
27use std::path::Path;
28use std::sync::Arc;
29
30use anyhow::{anyhow, Result};
31use arrow::array::{Array, StringArray};
32use arrow::record_batch::RecordBatch;
33
34use crate::codec;
35use crate::index::{read_znippy_index_filtered, IndexFilter};
36
37// ─── pkg_type discriminants (single source of truth: each plugin's `type_id()`) ──
38//
39// These mirror the discriminant each plugin returns from `ArchiveTypePlugin::type_id()`:
40//   CargoPlugin::type_id()        == 1   (znippy-common/src/plugins/native/cargo_native.rs)
41//   NativePythonPlugin::type_id() == 2   (znippy-plugin-python)
42//   NativeMavenPlugin::type_id()  == 3   (znippy-plugin-maven)
43// They live here because `ZnippyArchive` (in this crate) builds the filtered index
44// keyed on them, and the maven/python plugin crates depend on this crate (not the
45// other way round), so the constant cannot live in those crates without a cycle.
46
47/// `pkg_type` discriminant written by the rust/cargo plugin.
48pub const RUST_PKG_TYPE: i8 = 1;
49/// `pkg_type` discriminant written by the python plugin.
50pub const PYTHON_PKG_TYPE: i8 = 2;
51/// `pkg_type` discriminant written by the maven plugin.
52pub const MAVEN_PKG_TYPE: i8 = 3;
53/// `pkg_type` discriminant written by the npm plugin (`plugins::npm_native`).
54pub const NPM_PKG_TYPE: i8 = 6;
55/// `pkg_type` discriminant written by the gem plugin (`plugins::gem_native`).
56pub const GEM_PKG_TYPE: i8 = 11;
57/// `pkg_type` discriminant written by the rpm plugin (`plugins::rpm_native`).
58pub const RPM_PKG_TYPE: i8 = 8;
59/// `pkg_type` discriminant written by the deb plugin (`plugins::deb_native`).
60pub const DEB_PKG_TYPE: i8 = 9;
61/// `pkg_type` discriminant written by the conda plugin (`plugins::conda_native`).
62pub const CONDA_PKG_TYPE: i8 = 14;
63
64/// One file's chunk locations within the archive blob region — everything needed
65/// to pread + decompress its bytes, with **no** path involved. Built from the base
66/// index columns on the same rows that carried the coord match.
67#[derive(Debug, Clone)]
68pub struct FileLoc {
69    /// The file's chunks, ordered by `fdata_offset` (concatenation order).
70    pub chunks: Vec<ChunkRef>,
71    /// Total uncompressed size across all chunks.
72    pub uncompressed_size: u64,
73}
74
75/// One chunk's blob location.
76#[derive(Debug, Clone, Copy)]
77pub struct ChunkRef {
78    pub blob_offset: u64,
79    pub blob_size: u64,
80    pub fdata_offset: u64,
81    pub compressed: bool,
82}
83
84impl FileLoc {
85    /// Read + decompress this file's bytes — the lone I/O of the read API.
86    /// Reuses the exact pread/decompress loop of [`ZnippyArchive::extract_file`].
87    fn read_bytes(&self, archive: &File) -> Result<Vec<u8>> {
88        let mut result = Vec::with_capacity(self.uncompressed_size as usize);
89        let mut blob = Vec::new();
90        let mut decomp = Vec::new();
91        for chunk in &self.chunks {
92            blob.resize(chunk.blob_size as usize, 0);
93            archive.read_exact_at(&mut blob, chunk.blob_offset)?;
94            if chunk.compressed {
95                codec::decompress_into(&blob, &mut decomp)?;
96                result.extend_from_slice(&decomp);
97            } else {
98                result.extend_from_slice(&blob);
99            }
100        }
101        Ok(result)
102    }
103}
104
105/// Project the base location columns of a row into a [`ChunkRef`], appending to a
106/// per-file [`FileLoc`] keyed by `relative_path` (so chunked files group correctly).
107fn group_rows_by_file(batch: &RecordBatch) -> Result<HashMap<String, FileLoc>> {
108    use arrow::array::{BooleanArray, UInt64Array};
109
110    let col = |n: &str| {
111        batch
112            .column_by_name(n)
113            .ok_or_else(|| anyhow!("index missing column {n}"))
114    };
115    let paths = col("relative_path")?
116        .as_any()
117        .downcast_ref::<StringArray>()
118        .ok_or_else(|| anyhow!("relative_path not StringArray"))?;
119    let compressed = col("compressed")?
120        .as_any()
121        .downcast_ref::<BooleanArray>()
122        .ok_or_else(|| anyhow!("compressed not BooleanArray"))?;
123    let sizes = col("uncompressed_size")?
124        .as_any()
125        .downcast_ref::<UInt64Array>()
126        .ok_or_else(|| anyhow!("uncompressed_size not UInt64Array"))?;
127    let blob_offset = col("blob_offset")?
128        .as_any()
129        .downcast_ref::<UInt64Array>()
130        .ok_or_else(|| anyhow!("blob_offset not UInt64Array"))?;
131    let blob_size = col("blob_size")?
132        .as_any()
133        .downcast_ref::<UInt64Array>()
134        .ok_or_else(|| anyhow!("blob_size not UInt64Array"))?;
135    let fdata = col("fdata_offset")?
136        .as_any()
137        .downcast_ref::<UInt64Array>()
138        .ok_or_else(|| anyhow!("fdata_offset not UInt64Array"))?;
139
140    let mut by_path: HashMap<String, FileLoc> = HashMap::new();
141    for i in 0..batch.num_rows() {
142        let path = paths.value(i);
143        let entry = by_path.entry(path.to_string()).or_insert_with(|| FileLoc {
144            chunks: Vec::new(),
145            uncompressed_size: 0,
146        });
147        entry.uncompressed_size += sizes.value(i);
148        entry.chunks.push(ChunkRef {
149            blob_offset: blob_offset.value(i),
150            blob_size: blob_size.value(i),
151            fdata_offset: fdata.value(i),
152            compressed: compressed.value(i),
153        });
154    }
155    for f in by_path.values_mut() {
156        f.chunks.sort_by_key(|c| c.fdata_offset);
157    }
158    Ok(by_path)
159}
160
161/// A trailing-path-segment helper: the artifact filename of a matched row, used
162/// internally to disambiguate multi-artifact coords (maven jar vs pom, classifier).
163/// **Never** exposed to callers.
164fn file_name(rel_path: &str) -> &str {
165    rel_path.rsplit('/').next().unwrap_or(rel_path)
166}
167
168// ════════════════════════════════════════════════════════════════════════════
169// RUST view
170// ════════════════════════════════════════════════════════════════════════════
171
172/// `(name, version)` key for the rust coord index.
173#[derive(Debug, Clone, PartialEq, Eq, Hash)]
174struct RustKey {
175    name: String,
176    version: String,
177}
178
179/// Typed view over the rust (cargo) sub-index. Built once; `get` is O(1).
180pub struct RustView {
181    archive: Arc<File>,
182    coords: HashMap<RustKey, FileLoc>,
183}
184
185/// A handle to one crate. Coords are authoritative (read from the columns).
186/// Bytes are lazy — call [`RustPackage::bytes`].
187pub struct RustPackage {
188    archive: Arc<File>,
189    loc: FileLoc,
190    name: String,
191    version: String,
192}
193
194impl RustView {
195    fn build(path: &Path, archive: Arc<File>) -> Result<Self> {
196        let (_schema, batches) =
197            read_znippy_index_filtered(path, &IndexFilter { pkg_type: Some(RUST_PKG_TYPE), repo: None })?;
198        let mut coords = HashMap::new();
199        for batch in &batches {
200            let name = batch
201                .column_by_name("crate_name")
202                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
203            let version = batch
204                .column_by_name("version")
205                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
206            let (Some(name), Some(version)) = (name, version) else {
207                continue;
208            };
209            let locs = group_rows_by_file(batch)?;
210            // The grouped FileLocs are keyed by path; re-key by (name, version)
211            // using the first row of each path.
212            let paths = batch
213                .column_by_name("relative_path")
214                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
215                .ok_or_else(|| anyhow!("missing relative_path"))?;
216            let mut seen = std::collections::HashSet::new();
217            for i in 0..batch.num_rows() {
218                let p = paths.value(i);
219                if !seen.insert(p) {
220                    continue;
221                }
222                if name.is_null(i) || version.is_null(i) {
223                    continue;
224                }
225                if let Some(loc) = locs.get(p) {
226                    coords.insert(
227                        RustKey { name: name.value(i).to_string(), version: version.value(i).to_string() },
228                        loc.clone(),
229                    );
230                }
231            }
232        }
233        Ok(Self { archive, coords })
234    }
235
236    /// O(1) lookup → handle. `None` if the crate is not in the archive.
237    pub fn get(&self, name: &str, version: &str) -> Option<RustPackage> {
238        let loc = self
239            .coords
240            .get(&RustKey { name: name.to_string(), version: version.to_string() })?;
241        Some(RustPackage {
242            archive: Arc::clone(&self.archive),
243            loc: loc.clone(),
244            name: name.to_string(),
245            version: version.to_string(),
246        })
247    }
248
249    /// Authoritative `(name, version)` coords of every crate in the view.
250    pub fn list(&self) -> Vec<(String, String)> {
251        self.coords.keys().map(|k| (k.name.clone(), k.version.clone())).collect()
252    }
253
254    /// Number of crates indexed.
255    pub fn len(&self) -> usize {
256        self.coords.len()
257    }
258    pub fn is_empty(&self) -> bool {
259        self.coords.is_empty()
260    }
261}
262
263impl RustPackage {
264    /// Authoritative crate name (from the `crate_name` column, not a path parse).
265    pub fn name(&self) -> &str {
266        &self.name
267    }
268    /// Authoritative version (from the `version` column).
269    pub fn version(&self) -> &str {
270        &self.version
271    }
272    /// The crate's uncompressed size in bytes (no decompression).
273    pub fn size(&self) -> u64 {
274        self.loc.uncompressed_size
275    }
276    /// LAZY: pread + decompress the crate bytes. The only I/O of the read API.
277    pub fn bytes(&self) -> Result<Vec<u8>> {
278        self.loc.read_bytes(&self.archive)
279    }
280    /// Consume into the crate bytes.
281    pub fn into_bytes(self) -> Result<Vec<u8>> {
282        self.loc.read_bytes(&self.archive)
283    }
284}
285
286// ════════════════════════════════════════════════════════════════════════════
287// MAVEN view
288// ════════════════════════════════════════════════════════════════════════════
289
290/// `(group, artifact, version, classifier?)` key for the maven coord index. The
291/// classifier is part of the key so `-sources`/`-javadoc` resolve distinctly.
292#[derive(Debug, Clone, PartialEq, Eq, Hash)]
293struct MavenKey {
294    group: String,
295    artifact: String,
296    version: String,
297    classifier: Option<String>,
298}
299
300/// Typed view over the maven sub-index. Built once; `get` is O(1).
301pub struct MavenView {
302    archive: Arc<File>,
303    coords: HashMap<MavenKey, FileLoc>,
304}
305
306/// A handle to one maven artifact. Coords authoritative (from `group_id`/
307/// `artifact_id`/`version` columns). Bytes lazy via [`MavenPackage::bytes`].
308pub struct MavenPackage {
309    archive: Arc<File>,
310    loc: FileLoc,
311    group: String,
312    artifact: String,
313    version: String,
314    classifier: Option<String>,
315}
316
317impl MavenView {
318    fn build(path: &Path, archive: Arc<File>) -> Result<Self> {
319        let (_schema, batches) =
320            read_znippy_index_filtered(path, &IndexFilter { pkg_type: Some(MAVEN_PKG_TYPE), repo: None })?;
321        let mut coords = HashMap::new();
322        for batch in &batches {
323            let group = batch
324                .column_by_name("group_id")
325                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
326            let artifact = batch
327                .column_by_name("artifact_id")
328                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
329            let version = batch
330                .column_by_name("version")
331                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
332            let (Some(group), Some(artifact), Some(version)) = (group, artifact, version) else {
333                continue;
334            };
335            // `classifier` column is optional (only present when the plugin emits it).
336            let classifier_col = batch
337                .column_by_name("classifier")
338                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
339            let locs = group_rows_by_file(batch)?;
340            let paths = batch
341                .column_by_name("relative_path")
342                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
343                .ok_or_else(|| anyhow!("missing relative_path"))?;
344            let mut seen = std::collections::HashSet::new();
345            for i in 0..batch.num_rows() {
346                let p = paths.value(i);
347                if !seen.insert(p) {
348                    continue;
349                }
350                if group.is_null(i) || artifact.is_null(i) || version.is_null(i) {
351                    continue;
352                }
353                // Classifier: prefer the column; fall back to deriving from the
354                // filename when the column is absent/null (older archives).
355                let classifier = match classifier_col {
356                    Some(c) if !c.is_null(i) && !c.value(i).is_empty() => Some(c.value(i).to_string()),
357                    _ => derive_classifier(file_name(p), artifact.value(i), version.value(i)),
358                };
359                if let Some(loc) = locs.get(p) {
360                    coords.insert(
361                        MavenKey {
362                            group: group.value(i).to_string(),
363                            artifact: artifact.value(i).to_string(),
364                            version: version.value(i).to_string(),
365                            classifier,
366                        },
367                        loc.clone(),
368                    );
369                }
370            }
371        }
372        Ok(Self { archive, coords })
373    }
374
375    /// O(1) lookup of the primary artifact (no classifier) for a GAV.
376    pub fn get(&self, group: &str, artifact: &str, version: &str) -> Option<MavenPackage> {
377        self.get_classified(group, artifact, version, None)
378    }
379
380    /// O(1) lookup of a specific classifier (`Some("sources")`) or the primary
381    /// artifact (`None`).
382    pub fn get_classified(
383        &self,
384        group: &str,
385        artifact: &str,
386        version: &str,
387        classifier: Option<&str>,
388    ) -> Option<MavenPackage> {
389        let key = MavenKey {
390            group: group.to_string(),
391            artifact: artifact.to_string(),
392            version: version.to_string(),
393            classifier: classifier.map(|s| s.to_string()),
394        };
395        let loc = self.coords.get(&key)?;
396        Some(MavenPackage {
397            archive: Arc::clone(&self.archive),
398            loc: loc.clone(),
399            group: group.to_string(),
400            artifact: artifact.to_string(),
401            version: version.to_string(),
402            classifier: classifier.map(|s| s.to_string()),
403        })
404    }
405
406    /// Authoritative coords of every artifact: `(group, artifact, version, classifier?)`.
407    pub fn list(&self) -> Vec<(String, String, String, Option<String>)> {
408        self.coords
409            .keys()
410            .map(|k| (k.group.clone(), k.artifact.clone(), k.version.clone(), k.classifier.clone()))
411            .collect()
412    }
413
414    pub fn len(&self) -> usize {
415        self.coords.len()
416    }
417    pub fn is_empty(&self) -> bool {
418        self.coords.is_empty()
419    }
420}
421
422/// Best-effort classifier recovery from a filename when the column is absent.
423/// Maven filename: `{artifact}-{version}[-{classifier}].{ext}`. Returns the
424/// classifier if one is present (i.e. there is a suffix after `-{version}`).
425fn derive_classifier(filename: &str, artifact: &str, version: &str) -> Option<String> {
426    // strip extension(s) — handle compound like .tar.gz defensively
427    let stem = filename.rsplit_once('.').map(|(s, _)| s).unwrap_or(filename);
428    let prefix = format!("{artifact}-{version}");
429    let rest = stem.strip_prefix(&prefix)?;
430    let rest = rest.strip_prefix('-')?;
431    if rest.is_empty() {
432        None
433    } else {
434        Some(rest.to_string())
435    }
436}
437
438impl MavenPackage {
439    /// Authoritative groupId (from the `group_id` column).
440    pub fn group(&self) -> &str {
441        &self.group
442    }
443    /// Authoritative artifactId (from the `artifact_id` column).
444    pub fn artifact(&self) -> &str {
445        &self.artifact
446    }
447    /// Authoritative version (from the `version` column).
448    pub fn version(&self) -> &str {
449        &self.version
450    }
451    /// The classifier (`sources`, `javadoc`, …) or `None` for the primary artifact.
452    pub fn classifier(&self) -> Option<&str> {
453        self.classifier.as_deref()
454    }
455    /// `(group, artifact, version, classifier?)` — authoritative coords.
456    pub fn coords(&self) -> (&str, &str, &str, Option<&str>) {
457        (&self.group, &self.artifact, &self.version, self.classifier.as_deref())
458    }
459    pub fn size(&self) -> u64 {
460        self.loc.uncompressed_size
461    }
462    /// LAZY: pread + decompress the artifact bytes.
463    pub fn bytes(&self) -> Result<Vec<u8>> {
464        self.loc.read_bytes(&self.archive)
465    }
466    pub fn into_bytes(self) -> Result<Vec<u8>> {
467        self.loc.read_bytes(&self.archive)
468    }
469}
470
471// ════════════════════════════════════════════════════════════════════════════
472// PYTHON view
473// ════════════════════════════════════════════════════════════════════════════
474
475/// Wheel vs sdist discriminant.
476#[derive(Debug, Clone, Copy, PartialEq, Eq)]
477pub enum PythonKind {
478    Wheel,
479    Sdist,
480}
481
482/// `(name, version)` key for the python coord index.
483#[derive(Debug, Clone, PartialEq, Eq, Hash)]
484struct PythonKey {
485    name: String,
486    version: String,
487}
488
489/// Typed view over the python sub-index. Built once; `get` is O(1).
490pub struct PythonView {
491    archive: Arc<File>,
492    coords: HashMap<PythonKey, FileLoc>,
493    kinds: HashMap<PythonKey, PythonKind>,
494}
495
496/// A handle to one python distribution. Bytes lazy via [`PythonPackage::bytes`].
497pub struct PythonPackage {
498    archive: Arc<File>,
499    loc: FileLoc,
500    name: String,
501    version: String,
502    kind: PythonKind,
503}
504
505impl PythonView {
506    fn build(path: &Path, archive: Arc<File>) -> Result<Self> {
507        let (_schema, batches) =
508            read_znippy_index_filtered(path, &IndexFilter { pkg_type: Some(PYTHON_PKG_TYPE), repo: None })?;
509        let mut coords = HashMap::new();
510        let mut kinds = HashMap::new();
511        for batch in &batches {
512            let name = batch
513                .column_by_name("name")
514                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
515            let version = batch
516                .column_by_name("version")
517                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
518            let (Some(name), Some(version)) = (name, version) else {
519                continue;
520            };
521            let locs = group_rows_by_file(batch)?;
522            let paths = batch
523                .column_by_name("relative_path")
524                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
525                .ok_or_else(|| anyhow!("missing relative_path"))?;
526            let mut seen = std::collections::HashSet::new();
527            for i in 0..batch.num_rows() {
528                let p = paths.value(i);
529                if !seen.insert(p) {
530                    continue;
531                }
532                if name.is_null(i) || version.is_null(i) {
533                    continue;
534                }
535                let key = PythonKey { name: name.value(i).to_string(), version: version.value(i).to_string() };
536                let kind = if file_name(p).ends_with(".whl") {
537                    PythonKind::Wheel
538                } else {
539                    PythonKind::Sdist
540                };
541                if let Some(loc) = locs.get(p) {
542                    // Prefer a wheel over an sdist when both share a (name, version).
543                    let replace = matches!(kind, PythonKind::Wheel)
544                        || !coords.contains_key(&key);
545                    if replace {
546                        coords.insert(key.clone(), loc.clone());
547                        kinds.insert(key, kind);
548                    }
549                }
550            }
551        }
552        Ok(Self { archive, coords, kinds })
553    }
554
555    /// O(1) lookup → handle. `None` if the distribution is not in the archive.
556    pub fn get(&self, name: &str, version: &str) -> Option<PythonPackage> {
557        let key = PythonKey { name: name.to_string(), version: version.to_string() };
558        let loc = self.coords.get(&key)?;
559        let kind = self.kinds.get(&key).copied().unwrap_or(PythonKind::Sdist);
560        Some(PythonPackage {
561            archive: Arc::clone(&self.archive),
562            loc: loc.clone(),
563            name: name.to_string(),
564            version: version.to_string(),
565            kind,
566        })
567    }
568
569    /// Authoritative `(name, version)` coords of every distribution.
570    pub fn list(&self) -> Vec<(String, String)> {
571        self.coords.keys().map(|k| (k.name.clone(), k.version.clone())).collect()
572    }
573
574    pub fn len(&self) -> usize {
575        self.coords.len()
576    }
577    pub fn is_empty(&self) -> bool {
578        self.coords.is_empty()
579    }
580}
581
582impl PythonPackage {
583    pub fn name(&self) -> &str {
584        &self.name
585    }
586    pub fn version(&self) -> &str {
587        &self.version
588    }
589    /// Wheel or sdist (derived from the matched row's filename).
590    pub fn kind(&self) -> PythonKind {
591        self.kind
592    }
593    pub fn size(&self) -> u64 {
594        self.loc.uncompressed_size
595    }
596    /// LAZY: pread + decompress the distribution bytes.
597    pub fn bytes(&self) -> Result<Vec<u8>> {
598        self.loc.read_bytes(&self.archive)
599    }
600    pub fn into_bytes(self) -> Result<Vec<u8>> {
601        self.loc.read_bytes(&self.archive)
602    }
603}
604
605// ════════════════════════════════════════════════════════════════════════════
606// NPM view
607// ════════════════════════════════════════════════════════════════════════════
608
609/// `(name, version)` key for the npm coord index. `name` is the **authoritative**
610/// package name from `package.json` — including the `@scope/` prefix that the
611/// tarball filename drops.
612#[derive(Debug, Clone, PartialEq, Eq, Hash)]
613struct NpmKey {
614    name: String,
615    version: String,
616}
617
618/// Typed view over the npm sub-index. Built once; `get` is O(1).
619pub struct NpmView {
620    archive: Arc<File>,
621    coords: HashMap<NpmKey, FileLoc>,
622}
623
624/// A handle to one npm package tarball. Coords authoritative (from the `name`/
625/// `version` columns the plugin parsed out of `package.json`). Bytes lazy via
626/// [`NpmPackage::bytes`].
627pub struct NpmPackage {
628    archive: Arc<File>,
629    loc: FileLoc,
630    name: String,
631    version: String,
632}
633
634impl NpmView {
635    fn build(path: &Path, archive: Arc<File>) -> Result<Self> {
636        let (_schema, batches) =
637            read_znippy_index_filtered(path, &IndexFilter { pkg_type: Some(NPM_PKG_TYPE), repo: None })?;
638        let mut coords = HashMap::new();
639        for batch in &batches {
640            let name = batch
641                .column_by_name("name")
642                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
643            let version = batch
644                .column_by_name("version")
645                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
646            let (Some(name), Some(version)) = (name, version) else {
647                continue;
648            };
649            let locs = group_rows_by_file(batch)?;
650            let paths = batch
651                .column_by_name("relative_path")
652                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
653                .ok_or_else(|| anyhow!("missing relative_path"))?;
654            let mut seen = std::collections::HashSet::new();
655            for i in 0..batch.num_rows() {
656                let p = paths.value(i);
657                if !seen.insert(p) {
658                    continue;
659                }
660                if name.is_null(i) || version.is_null(i) {
661                    continue;
662                }
663                if let Some(loc) = locs.get(p) {
664                    coords.insert(
665                        NpmKey { name: name.value(i).to_string(), version: version.value(i).to_string() },
666                        loc.clone(),
667                    );
668                }
669            }
670        }
671        Ok(Self { archive, coords })
672    }
673
674    /// O(1) lookup → handle. `None` if the package is not in the archive. `name`
675    /// is the authoritative name (pass `@scope/pkg` for scoped packages).
676    pub fn get(&self, name: &str, version: &str) -> Option<NpmPackage> {
677        let loc = self
678            .coords
679            .get(&NpmKey { name: name.to_string(), version: version.to_string() })?;
680        Some(NpmPackage {
681            archive: Arc::clone(&self.archive),
682            loc: loc.clone(),
683            name: name.to_string(),
684            version: version.to_string(),
685        })
686    }
687
688    /// Authoritative `(name, version)` coords of every package in the view.
689    pub fn list(&self) -> Vec<(String, String)> {
690        self.coords.keys().map(|k| (k.name.clone(), k.version.clone())).collect()
691    }
692
693    pub fn len(&self) -> usize {
694        self.coords.len()
695    }
696    pub fn is_empty(&self) -> bool {
697        self.coords.is_empty()
698    }
699}
700
701impl NpmPackage {
702    /// Authoritative package name (from the `name` column — includes `@scope/`).
703    pub fn name(&self) -> &str {
704        &self.name
705    }
706    /// Authoritative version (from the `version` column).
707    pub fn version(&self) -> &str {
708        &self.version
709    }
710    /// The tarball's uncompressed size in bytes (no decompression).
711    pub fn size(&self) -> u64 {
712        self.loc.uncompressed_size
713    }
714    /// LAZY: pread + decompress the tarball bytes. The only I/O of the read API.
715    pub fn bytes(&self) -> Result<Vec<u8>> {
716        self.loc.read_bytes(&self.archive)
717    }
718    pub fn into_bytes(self) -> Result<Vec<u8>> {
719        self.loc.read_bytes(&self.archive)
720    }
721}
722
723// ════════════════════════════════════════════════════════════════════════════
724// GEM view
725// ════════════════════════════════════════════════════════════════════════════
726
727/// `(name, version, platform)` key for the gem coord index. `platform` is part of
728/// the key so a platform-suffixed native gem (`foo-1.2.3-java.gem`, platform
729/// `java`) resolves distinctly from the pure-ruby gem of the same version. All
730/// three come from `metadata.gz` (authoritative), not the filename.
731#[derive(Debug, Clone, PartialEq, Eq, Hash)]
732struct GemKey {
733    name: String,
734    version: String,
735    platform: String,
736}
737
738/// Typed view over the gem sub-index. Built once; `get` is O(1).
739pub struct GemView {
740    archive: Arc<File>,
741    coords: HashMap<GemKey, FileLoc>,
742}
743
744/// A handle to one gem. Coords authoritative (from the `name`/`version`/`platform`
745/// columns the plugin parsed out of `metadata.gz`). Bytes lazy via
746/// [`GemPackage::bytes`].
747pub struct GemPackage {
748    archive: Arc<File>,
749    loc: FileLoc,
750    name: String,
751    version: String,
752    platform: String,
753}
754
755impl GemView {
756    fn build(path: &Path, archive: Arc<File>) -> Result<Self> {
757        let (_schema, batches) =
758            read_znippy_index_filtered(path, &IndexFilter { pkg_type: Some(GEM_PKG_TYPE), repo: None })?;
759        let mut coords = HashMap::new();
760        for batch in &batches {
761            let name = batch
762                .column_by_name("name")
763                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
764            let version = batch
765                .column_by_name("version")
766                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
767            let (Some(name), Some(version)) = (name, version) else {
768                continue;
769            };
770            // `platform` column is optional (older archives may omit it).
771            let platform_col = batch
772                .column_by_name("platform")
773                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
774            let locs = group_rows_by_file(batch)?;
775            let paths = batch
776                .column_by_name("relative_path")
777                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
778                .ok_or_else(|| anyhow!("missing relative_path"))?;
779            let mut seen = std::collections::HashSet::new();
780            for i in 0..batch.num_rows() {
781                let p = paths.value(i);
782                if !seen.insert(p) {
783                    continue;
784                }
785                if name.is_null(i) || version.is_null(i) {
786                    continue;
787                }
788                let platform = match platform_col {
789                    Some(c) if !c.is_null(i) && !c.value(i).is_empty() => c.value(i).to_string(),
790                    _ => "ruby".to_string(),
791                };
792                if let Some(loc) = locs.get(p) {
793                    coords.insert(
794                        GemKey {
795                            name: name.value(i).to_string(),
796                            version: version.value(i).to_string(),
797                            platform,
798                        },
799                        loc.clone(),
800                    );
801                }
802            }
803        }
804        Ok(Self { archive, coords })
805    }
806
807    /// O(1) lookup of the `ruby`-platform gem for a `(name, version)`.
808    pub fn get(&self, name: &str, version: &str) -> Option<GemPackage> {
809        self.get_platform(name, version, "ruby")
810    }
811
812    /// O(1) lookup of a specific platform (`java`, `x86_64-linux`, …).
813    pub fn get_platform(&self, name: &str, version: &str, platform: &str) -> Option<GemPackage> {
814        let key = GemKey {
815            name: name.to_string(),
816            version: version.to_string(),
817            platform: platform.to_string(),
818        };
819        let loc = self.coords.get(&key)?;
820        Some(GemPackage {
821            archive: Arc::clone(&self.archive),
822            loc: loc.clone(),
823            name: name.to_string(),
824            version: version.to_string(),
825            platform: platform.to_string(),
826        })
827    }
828
829    /// Authoritative `(name, version, platform)` coords of every gem in the view.
830    pub fn list(&self) -> Vec<(String, String, String)> {
831        self.coords
832            .keys()
833            .map(|k| (k.name.clone(), k.version.clone(), k.platform.clone()))
834            .collect()
835    }
836
837    pub fn len(&self) -> usize {
838        self.coords.len()
839    }
840    pub fn is_empty(&self) -> bool {
841        self.coords.is_empty()
842    }
843}
844
845impl GemPackage {
846    /// Authoritative gem name (from the `name` column).
847    pub fn name(&self) -> &str {
848        &self.name
849    }
850    /// Authoritative version (from the `version` column).
851    pub fn version(&self) -> &str {
852        &self.version
853    }
854    /// The gem platform (`ruby` default, e.g. `java` for a native gem).
855    pub fn platform(&self) -> &str {
856        &self.platform
857    }
858    /// The gem's uncompressed size in bytes (no decompression).
859    pub fn size(&self) -> u64 {
860        self.loc.uncompressed_size
861    }
862    /// LAZY: pread + decompress the gem bytes. The only I/O of the read API.
863    pub fn bytes(&self) -> Result<Vec<u8>> {
864        self.loc.read_bytes(&self.archive)
865    }
866    pub fn into_bytes(self) -> Result<Vec<u8>> {
867        self.loc.read_bytes(&self.archive)
868    }
869}
870
871// ════════════════════════════════════════════════════════════════════════════
872// CONDA view
873// ════════════════════════════════════════════════════════════════════════════
874
875/// `(name, version, build, subdir)` key for the conda coord index. `build` +
876/// `subdir` are part of the key so the same `(name, version)` resolves distinctly
877/// across builds and platforms. All four come from `info/index.json`
878/// (authoritative), not the filename.
879#[derive(Debug, Clone, PartialEq, Eq, Hash)]
880struct CondaKey {
881    name: String,
882    version: String,
883    build: String,
884    subdir: String,
885}
886
887/// Typed view over the conda sub-index. Built once; `get` is O(1).
888pub struct CondaView {
889    archive: Arc<File>,
890    coords: HashMap<CondaKey, FileLoc>,
891}
892
893/// A handle to one conda package. Coords authoritative (from the `name`/`version`/
894/// `build`/`subdir` columns the plugin parsed out of `info/index.json`). Bytes
895/// lazy via [`CondaPackage::bytes`].
896pub struct CondaPackage {
897    archive: Arc<File>,
898    loc: FileLoc,
899    name: String,
900    version: String,
901    build: String,
902    subdir: String,
903}
904
905impl CondaView {
906    fn build(path: &Path, archive: Arc<File>) -> Result<Self> {
907        let (_schema, batches) = read_znippy_index_filtered(
908            path,
909            &IndexFilter { pkg_type: Some(CONDA_PKG_TYPE), repo: None },
910        )?;
911        let mut coords = HashMap::new();
912        for batch in &batches {
913            let name = batch
914                .column_by_name("name")
915                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
916            let version = batch
917                .column_by_name("version")
918                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
919            let (Some(name), Some(version)) = (name, version) else {
920                continue;
921            };
922            let build_col = batch
923                .column_by_name("build")
924                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
925            let subdir_col = batch
926                .column_by_name("subdir")
927                .and_then(|c| c.as_any().downcast_ref::<StringArray>());
928            let locs = group_rows_by_file(batch)?;
929            let paths = batch
930                .column_by_name("relative_path")
931                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
932                .ok_or_else(|| anyhow!("missing relative_path"))?;
933            let mut seen = std::collections::HashSet::new();
934            for i in 0..batch.num_rows() {
935                let p = paths.value(i);
936                if !seen.insert(p) {
937                    continue;
938                }
939                if name.is_null(i) || version.is_null(i) {
940                    continue;
941                }
942                let build = match build_col {
943                    Some(c) if !c.is_null(i) => c.value(i).to_string(),
944                    _ => String::new(),
945                };
946                let subdir = match subdir_col {
947                    Some(c) if !c.is_null(i) && !c.value(i).is_empty() => c.value(i).to_string(),
948                    _ => String::new(),
949                };
950                if let Some(loc) = locs.get(p) {
951                    coords.insert(
952                        CondaKey {
953                            name: name.value(i).to_string(),
954                            version: version.value(i).to_string(),
955                            build,
956                            subdir,
957                        },
958                        loc.clone(),
959                    );
960                }
961            }
962        }
963        Ok(Self { archive, coords })
964    }
965
966    /// O(1) lookup of the first `(name, version)` match across any build/subdir.
967    /// Use [`get_exact`](CondaView::get_exact) to pin the build + subdir.
968    pub fn get(&self, name: &str, version: &str) -> Option<CondaPackage> {
969        let (key, loc) = self
970            .coords
971            .iter()
972            .find(|(k, _)| k.name == name && k.version == version)?;
973        Some(CondaPackage {
974            archive: Arc::clone(&self.archive),
975            loc: loc.clone(),
976            name: key.name.clone(),
977            version: key.version.clone(),
978            build: key.build.clone(),
979            subdir: key.subdir.clone(),
980        })
981    }
982
983    /// O(1) lookup of an exact `(name, version, build, subdir)` coord.
984    pub fn get_exact(
985        &self,
986        name: &str,
987        version: &str,
988        build: &str,
989        subdir: &str,
990    ) -> Option<CondaPackage> {
991        let key = CondaKey {
992            name: name.to_string(),
993            version: version.to_string(),
994            build: build.to_string(),
995            subdir: subdir.to_string(),
996        };
997        let loc = self.coords.get(&key)?;
998        Some(CondaPackage {
999            archive: Arc::clone(&self.archive),
1000            loc: loc.clone(),
1001            name: name.to_string(),
1002            version: version.to_string(),
1003            build: build.to_string(),
1004            subdir: subdir.to_string(),
1005        })
1006    }
1007
1008    /// Authoritative `(name, version, build, subdir)` coords of every package.
1009    pub fn list(&self) -> Vec<(String, String, String, String)> {
1010        self.coords
1011            .keys()
1012            .map(|k| (k.name.clone(), k.version.clone(), k.build.clone(), k.subdir.clone()))
1013            .collect()
1014    }
1015
1016    pub fn len(&self) -> usize {
1017        self.coords.len()
1018    }
1019    pub fn is_empty(&self) -> bool {
1020        self.coords.is_empty()
1021    }
1022}
1023
1024impl CondaPackage {
1025    /// Authoritative package name (from the `name` column).
1026    pub fn name(&self) -> &str {
1027        &self.name
1028    }
1029    /// Authoritative version (from the `version` column).
1030    pub fn version(&self) -> &str {
1031        &self.version
1032    }
1033    /// The build string (e.g. `py311h1234567_0`), from `info/index.json`.
1034    pub fn build(&self) -> &str {
1035        &self.build
1036    }
1037    /// The subdir/platform (e.g. `linux-64`), from `info/index.json`.
1038    pub fn subdir(&self) -> &str {
1039        &self.subdir
1040    }
1041    /// The package's uncompressed size in bytes (no decompression).
1042    pub fn size(&self) -> u64 {
1043        self.loc.uncompressed_size
1044    }
1045    /// LAZY: pread + decompress the package bytes. The only I/O of the read API.
1046    pub fn bytes(&self) -> Result<Vec<u8>> {
1047        self.loc.read_bytes(&self.archive)
1048    }
1049    pub fn into_bytes(self) -> Result<Vec<u8>> {
1050        self.loc.read_bytes(&self.archive)
1051    }
1052}
1053
1054// ════════════════════════════════════════════════════════════════════════════
1055// RPM view
1056// ════════════════════════════════════════════════════════════════════════════
1057
1058/// `(name, version, release, arch)` key for the rpm coord index. Epoch is NOT a
1059/// lookup key (an rpm filename / dnf request carries no epoch) — it rides in the
1060/// value so the read side can surface it (e.g. in rpm-md `primary.xml`).
1061#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1062struct RpmKey {
1063    name: String,
1064    version: String,
1065    release: String,
1066    arch: String,
1067}
1068
1069/// The per-rpm value: where its bytes live + the authoritative header metadata
1070/// (`epoch` and the rpm-md `primary.xml` fields), parsed by
1071/// [`crate::plugins::rpm_native`].
1072#[derive(Clone)]
1073struct RpmEntry {
1074    loc: FileLoc,
1075    epoch: Option<String>,
1076    summary: Option<String>,
1077    license: Option<String>,
1078    url: Option<String>,
1079    vendor: Option<String>,
1080    sourcerpm: Option<String>,
1081    /// Provide/require dependency names (the plugin joined them with `\n`).
1082    provides: Vec<String>,
1083    requires: Vec<String>,
1084}
1085
1086/// One rpm's authoritative header metadata — NEVRA plus the rpm-md `primary.xml`
1087/// fields — the shape holger's `primary.xml` synthesis feeds off.
1088#[derive(Debug, Clone, PartialEq, Eq)]
1089pub struct RpmMetaRow {
1090    pub name: String,
1091    pub version: String,
1092    pub release: String,
1093    pub arch: String,
1094    pub epoch: Option<String>,
1095    pub summary: Option<String>,
1096    pub license: Option<String>,
1097    pub url: Option<String>,
1098    pub vendor: Option<String>,
1099    pub sourcerpm: Option<String>,
1100    pub provides: Vec<String>,
1101    pub requires: Vec<String>,
1102}
1103
1104/// Typed view over the rpm sub-index. Coords (incl. real `epoch`) come from the
1105/// `name`/`version`/`release`/`arch`/`epoch` columns the plugin parsed out of the
1106/// RPM header — NOT the filename. Built once; `get` is O(1).
1107pub struct RpmView {
1108    archive: Arc<File>,
1109    coords: HashMap<RpmKey, RpmEntry>,
1110}
1111
1112/// A handle to one rpm. Coords authoritative; bytes lazy via [`RpmPackage::bytes`].
1113pub struct RpmPackage {
1114    archive: Arc<File>,
1115    loc: FileLoc,
1116    name: String,
1117    version: String,
1118    release: String,
1119    arch: String,
1120    epoch: Option<String>,
1121}
1122
1123impl RpmView {
1124    fn build(path: &Path, archive: Arc<File>) -> Result<Self> {
1125        let (_schema, batches) = read_znippy_index_filtered(
1126            path,
1127            &IndexFilter { pkg_type: Some(RPM_PKG_TYPE), repo: None },
1128        )?;
1129        let mut coords = HashMap::new();
1130        for batch in &batches {
1131            let col = |n: &str| {
1132                batch.column_by_name(n).and_then(|c| c.as_any().downcast_ref::<StringArray>())
1133            };
1134            let (Some(name), Some(version)) = (col("name"), col("version")) else {
1135                continue;
1136            };
1137            let (release_c, arch_c, epoch_c) = (col("release"), col("arch"), col("epoch"));
1138            // Rich primary.xml columns — optional, so older archives (NEVRA-only)
1139            // simply carry `None`/empty here.
1140            let (summary_c, license_c, url_c) = (col("summary"), col("license"), col("url"));
1141            let (vendor_c, sourcerpm_c) = (col("vendor"), col("sourcerpm"));
1142            let (provides_c, requires_c) = (col("provides"), col("requires"));
1143            let paths = col("relative_path").ok_or_else(|| anyhow!("missing relative_path"))?;
1144            let locs = group_rows_by_file(batch)?;
1145            let mut seen = std::collections::HashSet::new();
1146            for i in 0..batch.num_rows() {
1147                let p = paths.value(i);
1148                if !seen.insert(p) {
1149                    continue;
1150                }
1151                if name.is_null(i) || version.is_null(i) {
1152                    continue;
1153                }
1154                let Some(loc) = locs.get(p) else { continue };
1155                coords.insert(
1156                    RpmKey {
1157                        name: name.value(i).to_string(),
1158                        version: version.value(i).to_string(),
1159                        release: opt_col(release_c, i).unwrap_or_default(),
1160                        arch: opt_col(arch_c, i).unwrap_or_default(),
1161                    },
1162                    RpmEntry {
1163                        loc: loc.clone(),
1164                        epoch: opt_col(epoch_c, i),
1165                        summary: opt_col(summary_c, i),
1166                        license: opt_col(license_c, i),
1167                        url: opt_col(url_c, i),
1168                        vendor: opt_col(vendor_c, i),
1169                        sourcerpm: opt_col(sourcerpm_c, i),
1170                        provides: split_lines(opt_col(provides_c, i)),
1171                        requires: split_lines(opt_col(requires_c, i)),
1172                    },
1173                );
1174            }
1175        }
1176        Ok(Self { archive, coords })
1177    }
1178
1179    /// O(1) lookup of the rpm for `(name, version, release, arch)`.
1180    pub fn get(&self, name: &str, version: &str, release: &str, arch: &str) -> Option<RpmPackage> {
1181        let key = RpmKey {
1182            name: name.to_string(),
1183            version: version.to_string(),
1184            release: release.to_string(),
1185            arch: arch.to_string(),
1186        };
1187        let entry = self.coords.get(&key)?;
1188        Some(RpmPackage {
1189            archive: Arc::clone(&self.archive),
1190            loc: entry.loc.clone(),
1191            name: name.to_string(),
1192            version: version.to_string(),
1193            release: release.to_string(),
1194            arch: arch.to_string(),
1195            epoch: entry.epoch.clone(),
1196        })
1197    }
1198
1199    /// Authoritative `(name, version, release, arch, epoch)` of every rpm in the
1200    /// view — the NEVRA-only projection.
1201    pub fn list(&self) -> Vec<(String, String, String, String, Option<String>)> {
1202        self.coords
1203            .iter()
1204            .map(|(k, e)| {
1205                (k.name.clone(), k.version.clone(), k.release.clone(), k.arch.clone(), e.epoch.clone())
1206            })
1207            .collect()
1208    }
1209
1210    /// Every rpm's full authoritative header metadata (NEVRA + the rpm-md
1211    /// `primary.xml` fields) — what holger's `primary.xml` synthesis emits.
1212    pub fn list_meta(&self) -> Vec<RpmMetaRow> {
1213        self.coords
1214            .iter()
1215            .map(|(k, e)| RpmMetaRow {
1216                name: k.name.clone(),
1217                version: k.version.clone(),
1218                release: k.release.clone(),
1219                arch: k.arch.clone(),
1220                epoch: e.epoch.clone(),
1221                summary: e.summary.clone(),
1222                license: e.license.clone(),
1223                url: e.url.clone(),
1224                vendor: e.vendor.clone(),
1225                sourcerpm: e.sourcerpm.clone(),
1226                provides: e.provides.clone(),
1227                requires: e.requires.clone(),
1228            })
1229            .collect()
1230    }
1231
1232    pub fn len(&self) -> usize {
1233        self.coords.len()
1234    }
1235    pub fn is_empty(&self) -> bool {
1236        self.coords.is_empty()
1237    }
1238}
1239
1240impl RpmPackage {
1241    pub fn name(&self) -> &str {
1242        &self.name
1243    }
1244    pub fn version(&self) -> &str {
1245        &self.version
1246    }
1247    pub fn release(&self) -> &str {
1248        &self.release
1249    }
1250    pub fn arch(&self) -> &str {
1251        &self.arch
1252    }
1253    /// The authoritative `Epoch` (from the header), or `None` when unset.
1254    pub fn epoch(&self) -> Option<&str> {
1255        self.epoch.as_deref()
1256    }
1257    /// The rpm's uncompressed size in bytes (no decompression).
1258    pub fn size(&self) -> u64 {
1259        self.loc.uncompressed_size
1260    }
1261    /// LAZY: pread + decompress the rpm bytes. The only I/O of the read API.
1262    pub fn bytes(&self) -> Result<Vec<u8>> {
1263        self.loc.read_bytes(&self.archive)
1264    }
1265    pub fn into_bytes(self) -> Result<Vec<u8>> {
1266        self.loc.read_bytes(&self.archive)
1267    }
1268}
1269
1270/// A nullable UTF-8 column value at row `i` as an owned `Option<String>`.
1271fn opt_col(c: Option<&StringArray>, i: usize) -> Option<String> {
1272    c.filter(|a| !a.is_null(i)).map(|a| a.value(i).to_string())
1273}
1274
1275/// Split a newline-joined column value (the shape the rpm plugin stores dependency
1276/// lists in) back into its parts, dropping empties. `None`/`""` ⇒ `[]`.
1277fn split_lines(v: Option<String>) -> Vec<String> {
1278    v.map(|s| s.lines().filter(|l| !l.is_empty()).map(str::to_string).collect())
1279        .unwrap_or_default()
1280}
1281
1282// ════════════════════════════════════════════════════════════════════════════
1283// DEB view
1284// ════════════════════════════════════════════════════════════════════════════
1285
1286/// `(name, version, arch)` key for the deb coord index.
1287#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1288struct DebKey {
1289    name: String,
1290    version: String,
1291    arch: String,
1292}
1293
1294/// The per-deb value: where its bytes live + the raw `control` stanza (the real
1295/// Depends/Maintainer/Description the filename can't carry), parsed by
1296/// [`crate::plugins::deb_native`]. `control` is `None` when the `.deb` wasn't
1297/// parseable at ingest (unsupported codec / off-feature) — coords then came from
1298/// the filename.
1299#[derive(Clone)]
1300struct DebEntry {
1301    loc: FileLoc,
1302    control: Option<String>,
1303}
1304
1305/// Typed view over the deb sub-index. Coords + the authoritative `control` stanza
1306/// come from the columns the plugin parsed out of the control tarball — NOT the
1307/// filename. Built once; `get` is O(1).
1308pub struct DebView {
1309    archive: Arc<File>,
1310    coords: HashMap<DebKey, DebEntry>,
1311}
1312
1313/// A handle to one deb. Coords + `control` authoritative; bytes lazy.
1314pub struct DebPackage {
1315    archive: Arc<File>,
1316    loc: FileLoc,
1317    name: String,
1318    version: String,
1319    arch: String,
1320    control: Option<String>,
1321}
1322
1323impl DebView {
1324    fn build(path: &Path, archive: Arc<File>) -> Result<Self> {
1325        let (_schema, batches) = read_znippy_index_filtered(
1326            path,
1327            &IndexFilter { pkg_type: Some(DEB_PKG_TYPE), repo: None },
1328        )?;
1329        let mut coords = HashMap::new();
1330        for batch in &batches {
1331            let col = |n: &str| {
1332                batch.column_by_name(n).and_then(|c| c.as_any().downcast_ref::<StringArray>())
1333            };
1334            let Some(name) = col("name") else { continue };
1335            let (version_c, arch_c, control_c) = (col("version"), col("arch"), col("control"));
1336            let paths = col("relative_path").ok_or_else(|| anyhow!("missing relative_path"))?;
1337            let locs = group_rows_by_file(batch)?;
1338            let mut seen = std::collections::HashSet::new();
1339            for i in 0..batch.num_rows() {
1340                let p = paths.value(i);
1341                if !seen.insert(p) {
1342                    continue;
1343                }
1344                if name.is_null(i) {
1345                    continue;
1346                }
1347                let Some(loc) = locs.get(p) else { continue };
1348                coords.insert(
1349                    DebKey {
1350                        name: name.value(i).to_string(),
1351                        version: opt_col(version_c, i).unwrap_or_default(),
1352                        arch: opt_col(arch_c, i).unwrap_or_default(),
1353                    },
1354                    DebEntry { loc: loc.clone(), control: opt_col(control_c, i) },
1355                );
1356            }
1357        }
1358        Ok(Self { archive, coords })
1359    }
1360
1361    /// O(1) lookup of the deb for `(name, version, arch)`.
1362    pub fn get(&self, name: &str, version: &str, arch: &str) -> Option<DebPackage> {
1363        let key = DebKey {
1364            name: name.to_string(),
1365            version: version.to_string(),
1366            arch: arch.to_string(),
1367        };
1368        let entry = self.coords.get(&key)?;
1369        Some(DebPackage {
1370            archive: Arc::clone(&self.archive),
1371            loc: entry.loc.clone(),
1372            name: name.to_string(),
1373            version: version.to_string(),
1374            arch: arch.to_string(),
1375            control: entry.control.clone(),
1376        })
1377    }
1378
1379    /// Authoritative `(name, version, arch, control)` of every deb in the view —
1380    /// the APT `Packages` synthesis feeds off this.
1381    pub fn list(&self) -> Vec<(String, String, String, Option<String>)> {
1382        self.coords
1383            .iter()
1384            .map(|(k, e)| (k.name.clone(), k.version.clone(), k.arch.clone(), e.control.clone()))
1385            .collect()
1386    }
1387
1388    pub fn len(&self) -> usize {
1389        self.coords.len()
1390    }
1391    pub fn is_empty(&self) -> bool {
1392        self.coords.is_empty()
1393    }
1394}
1395
1396impl DebPackage {
1397    pub fn name(&self) -> &str {
1398        &self.name
1399    }
1400    pub fn version(&self) -> &str {
1401        &self.version
1402    }
1403    pub fn arch(&self) -> &str {
1404        &self.arch
1405    }
1406    /// The raw `control` stanza from the control tarball, or `None` when the `.deb`
1407    /// wasn't parseable at ingest.
1408    pub fn control(&self) -> Option<&str> {
1409        self.control.as_deref()
1410    }
1411    pub fn size(&self) -> u64 {
1412        self.loc.uncompressed_size
1413    }
1414    pub fn bytes(&self) -> Result<Vec<u8>> {
1415        self.loc.read_bytes(&self.archive)
1416    }
1417    pub fn into_bytes(self) -> Result<Vec<u8>> {
1418        self.loc.read_bytes(&self.archive)
1419    }
1420}
1421
1422// ─── construction entrypoints, shared by ZnippyArchive's cached `as_*` methods ──
1423
1424pub(crate) fn build_rust_view(path: &Path, archive: Arc<File>) -> Result<Option<RustView>> {
1425    let view = RustView::build(path, archive)?;
1426    Ok(if view.is_empty() { None } else { Some(view) })
1427}
1428
1429pub(crate) fn build_maven_view(path: &Path, archive: Arc<File>) -> Result<Option<MavenView>> {
1430    let view = MavenView::build(path, archive)?;
1431    Ok(if view.is_empty() { None } else { Some(view) })
1432}
1433
1434pub(crate) fn build_python_view(path: &Path, archive: Arc<File>) -> Result<Option<PythonView>> {
1435    let view = PythonView::build(path, archive)?;
1436    Ok(if view.is_empty() { None } else { Some(view) })
1437}
1438
1439pub(crate) fn build_npm_view(path: &Path, archive: Arc<File>) -> Result<Option<NpmView>> {
1440    let view = NpmView::build(path, archive)?;
1441    Ok(if view.is_empty() { None } else { Some(view) })
1442}
1443
1444pub(crate) fn build_gem_view(path: &Path, archive: Arc<File>) -> Result<Option<GemView>> {
1445    let view = GemView::build(path, archive)?;
1446    Ok(if view.is_empty() { None } else { Some(view) })
1447}
1448
1449pub(crate) fn build_conda_view(path: &Path, archive: Arc<File>) -> Result<Option<CondaView>> {
1450    let view = CondaView::build(path, archive)?;
1451    Ok(if view.is_empty() { None } else { Some(view) })
1452}
1453
1454pub(crate) fn build_rpm_view(path: &Path, archive: Arc<File>) -> Result<Option<RpmView>> {
1455    let view = RpmView::build(path, archive)?;
1456    Ok(if view.is_empty() { None } else { Some(view) })
1457}
1458
1459pub(crate) fn build_deb_view(path: &Path, archive: Arc<File>) -> Result<Option<DebView>> {
1460    let view = DebView::build(path, archive)?;
1461    Ok(if view.is_empty() { None } else { Some(view) })
1462}