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