Skip to main content

tylertoo_core/
input_set.rs

1//! Multi-partition conversion input: [`ConvertSource`] (v0.7).
2//!
3//! Real-world GeoParquet datasets frequently arrive as a *set* of parquet
4//! files (Hive/Spark `part-*.parquet` directories, Overture-style
5//! partitions). This module generalizes the converter's single
6//! [`InputSource`] into a [`ConvertSource`] that is either one file/object
7//! or an ordered set of local partitions resolved from a directory or a
8//! glob pattern.
9//!
10//! # The row-order invariant (load-bearing)
11//!
12//! The streaming converter reads its input **several times** (pass 1
13//! assignment scan, buffered coarse levels, canonical finest level) and
14//! keys its winner tables by **global row offset**. Every pass must
15//! therefore see *the same rows in the same order*. A `ConvertSource`
16//! guarantees this by construction:
17//!
18//! - partitions are sorted lexicographically at resolve time and never
19//!   reordered;
20//! - [`ConvertSource::open_stream`] concatenates the parts' batches in
21//!   part order, part `i + 1` opening only after part `i` is exhausted;
22//! - per-part row-group selections ([`RowGroupSelection`]) are computed
23//!   once and applied identically on every open.
24//!
25//! # Compatibility validation
26//!
27//! All partitions must be mutually compatible ([`MultiSource`] validates
28//! against partition 0 at construction): identical field names, types, and
29//! order; identical field (extension) metadata — geometry encoding must
30//! match; and an identical detected CRS. Nullability is the one permitted
31//! difference: the exposed schema unions it (any-nullable ⇒ nullable).
32//!
33//! `s3://` / `gs://` *prefixes* (e.g. `s3://bucket/dataset/`) are listed
34//! natively (sorted `.parquet` keys, one shared store instance);
35//! `http(s)://` prefixes have no generic listing API and error with a
36//! pointer at `--files-from`, which accepts an explicit ordered manifest
37//! of files/URLs instead.
38
39use std::path::{Path, PathBuf};
40use std::sync::Arc;
41
42use arrow_array::RecordBatch;
43use arrow_schema::{Field, Schema, SchemaRef};
44use parquet::arrow::arrow_reader::ParquetRecordBatchReader;
45use parquet::arrow::ProjectionMask;
46use parquet::file::metadata::{KeyValue, ParquetMetaData};
47
48#[cfg(feature = "remote")]
49use crate::input::is_remote_scheme;
50use crate::input::{url_scheme, FetchStats, InputError, InputSource};
51
52/// A resolved conversion input: one parquet file/object, or an ordered set
53/// of local parquet partitions read as one logical dataset.
54#[derive(Debug)]
55pub enum ConvertSource {
56    /// A single parquet file or remote object (the historical input shape).
57    Single(SingleSource),
58    /// An ordered, validated set of local parquet partitions.
59    Multi(MultiSource),
60}
61
62/// One parquet file/object plus its lazily cached footer metadata, so the
63/// header phase's accessors (schema, kv metadata, row-group counts,
64/// selection) parse the footer ONCE — matching the pre-v0.7 single-open
65/// header cost.
66#[derive(Debug)]
67pub struct SingleSource {
68    source: InputSource,
69    meta: std::sync::OnceLock<PartMeta>,
70    /// Root columns every read is restricted to (#386); `None` = all.
71    projection: std::sync::OnceLock<Vec<usize>>,
72}
73
74impl SingleSource {
75    fn new(source: InputSource) -> Self {
76        SingleSource {
77            source,
78            meta: std::sync::OnceLock::new(),
79            projection: std::sync::OnceLock::new(),
80        }
81    }
82
83    /// The wrapped input.
84    pub fn input(&self) -> &InputSource {
85        &self.source
86    }
87
88    /// Footer metadata, parsed on first access and cached.
89    fn meta(&self) -> Result<&PartMeta, InputError> {
90        if let Some(m) = self.meta.get() {
91            return Ok(m);
92        }
93        let m = load_part_meta(&self.source)?;
94        // A concurrent load may have won the race; either copy is
95        // equivalent (same footer).
96        Ok(self.meta.get_or_init(|| m))
97    }
98}
99
100/// Footer-derived metadata of one partition.
101#[derive(Debug, Clone)]
102struct PartMeta {
103    /// Arrow schema decoded from the part's footer.
104    schema: SchemaRef,
105    /// Parsed parquet metadata (row groups, key-value metadata).
106    parquet: Arc<ParquetMetaData>,
107}
108
109/// An ordered set of parquet partitions (local files and/or remote
110/// objects) with footers loaded and validated up front (see the module
111/// docs for the compatibility rules).
112#[derive(Debug)]
113pub struct MultiSource {
114    /// The original input string (directory path or glob pattern).
115    root: String,
116    /// The partitions, sorted lexicographically. Ordering is load-bearing:
117    /// global row offsets are assigned in this order.
118    parts: Vec<InputSource>,
119    /// Per-part footer metadata, parallel to `parts`.
120    metas: Vec<PartMeta>,
121    /// The unioned schema: partition 0's fields with nullability OR-ed
122    /// across all partitions.
123    schema: SchemaRef,
124    /// Root columns every read is restricted to (#386); `None` = all.
125    projection: std::sync::OnceLock<Vec<usize>>,
126}
127
128/// Per-part row-group selection: the multi-file analogue of the single-file
129/// `selected_row_groups: Option<&[usize]>` (bbox pruning, #102). Entry `i`
130/// holds part `i`'s *local* row-group indices; an empty entry skips the
131/// part entirely.
132#[derive(Debug, Clone)]
133pub struct RowGroupSelection(Vec<Vec<usize>>);
134
135impl RowGroupSelection {
136    /// Build from per-part local row-group index lists.
137    pub fn from_parts(parts: Vec<Vec<usize>>) -> Self {
138        RowGroupSelection(parts)
139    }
140
141    /// Per-part selections, in part order.
142    pub fn parts(&self) -> &[Vec<usize>] {
143        &self.0
144    }
145
146    /// Total number of selected row groups across all parts.
147    pub fn total_selected(&self) -> usize {
148        self.0.iter().map(Vec::len).sum()
149    }
150
151    /// Per-part intersection with `other` (#315): a row group survives only
152    /// when both selections keep it, so the bbox covering pruning and the
153    /// attribute-filter statistics pruning compose. Both selections must
154    /// come from the same source (same part count and order).
155    pub fn intersect(&self, other: &RowGroupSelection) -> RowGroupSelection {
156        debug_assert_eq!(self.0.len(), other.0.len());
157        let parts = self
158            .0
159            .iter()
160            .zip(&other.0)
161            .map(|(a, b)| a.iter().copied().filter(|i| b.contains(i)).collect())
162            .collect();
163        RowGroupSelection(parts)
164    }
165}
166
167/// How to read a [`ConvertSource`]: batch size, optional root-column
168/// projection (identical schemas make one index set valid for every part),
169/// and optional per-part row-group selection.
170#[derive(Debug, Clone, Copy)]
171pub struct ReadPlan<'a> {
172    /// Rows per record batch (clamped to >= 1).
173    pub batch_size: usize,
174    /// Root (top-level) column indices to read; `None` = all columns.
175    pub projection: Option<&'a [usize]>,
176    /// Per-part row-group selection; `None` = all row groups.
177    pub row_groups: Option<&'a RowGroupSelection>,
178}
179
180/// Sequential record-batch stream over all parts of a [`ConvertSource`],
181/// in part order. Part `i + 1`'s reader is opened lazily when part `i` is
182/// exhausted; on each part transition the finished part's in-memory read
183/// cache is released ([`InputSource::release_read_cache`]) so resident
184/// memory stays bounded by one part's working set.
185pub struct SourceStream<'a> {
186    parts: &'a [InputSource],
187    projection: Option<Vec<usize>>,
188    row_groups: Option<Vec<Vec<usize>>>,
189    batch_size: usize,
190    part_idx: usize,
191    current: Option<ParquetRecordBatchReader>,
192    done: bool,
193}
194
195impl ConvertSource {
196    /// Wrap an already-constructed [`InputSource`] (single file/object).
197    pub fn single(source: InputSource) -> Self {
198        ConvertSource::Single(SingleSource::new(source))
199    }
200
201    /// Resolve a CLI-style input string:
202    ///
203    /// - existing local file → `Single` (byte-identical behavior to today);
204    /// - existing local directory → recursive `.parquet` collection
205    ///   (sorted; `_`/`.`-prefixed basenames such as `_SUCCESS` skipped);
206    /// - string containing glob metacharacters (`*?[`) → glob expansion,
207    ///   filtered to `.parquet` files, sorted, deduplicated;
208    /// - remote single-object URL (no trailing slash — including
209    ///   extension-less presigned/API URLs) → `Single` (unchanged);
210    /// - `s3://` / `gs://` prefix (path ending `/`) → native object
211    ///   listing: `.parquet` keys sorted by key, `_SUCCESS`/zero-byte/
212    ///   hidden (`.`/`_`) names skipped, one store instance shared by all
213    ///   parts; requires the `remote` feature (without it, the standard
214    ///   [`InputError::RemoteDisabled`] as before);
215    /// - `http(s)://` prefix → [`InputError::RemotePrefixUnsupported`]
216    ///   (no generic listing API; the error points at `--files-from`);
217    /// - a single resolved partition collapses to `Single`;
218    /// - an empty directory/glob result → [`InputError::NoParquetInputs`].
219    pub fn resolve(input: &str) -> Result<Self, InputError> {
220        if let Some(_scheme) = url_scheme(input) {
221            // A remote *prefix* ("directory", trailing `/`) is recognized
222            // only when remote support is compiled in; without the feature
223            // every remote URL fails with the standard `RemoteDisabled`
224            // error exactly as before. Extension-less non-slash URLs
225            // (presigned / API endpoints that serve parquet) are single
226            // objects, matching pre-v0.7 behavior.
227            #[cfg(feature = "remote")]
228            if !_scheme.eq_ignore_ascii_case("file")
229                && is_remote_scheme(_scheme)
230                && remote_url_is_prefix(input)
231            {
232                if _scheme.eq_ignore_ascii_case("http") || _scheme.eq_ignore_ascii_case("https") {
233                    return Err(InputError::RemotePrefixUnsupported {
234                        url: input.to_string(),
235                    });
236                }
237                return Self::from_remote_prefix(input);
238            }
239            // `file://` maps to a local path, remote objects stay single,
240            // unsupported schemes get the standard error — all exactly as
241            // before.
242            return Ok(ConvertSource::single(InputSource::from_str_input(input)?));
243        }
244        let path = Path::new(input);
245        if path.is_file() {
246            // Existing local file: byte-identical behavior to today.
247            return Ok(ConvertSource::single(InputSource::Local(
248                path.to_path_buf(),
249            )));
250        }
251        if path.is_dir() {
252            let files = list_parquet_files(path)?;
253            return Self::from_local_files(input, files);
254        }
255        if has_glob_meta(input) {
256            let files = expand_glob(input)?;
257            return Self::from_local_files(input, files);
258        }
259        // Nonexistent plain path: keep today's behavior (the io error
260        // surfaces at open time).
261        Ok(ConvertSource::single(InputSource::Local(
262            path.to_path_buf(),
263        )))
264    }
265
266    /// `Single` for one file, `Multi` for several, a clear error for none.
267    fn from_local_files(input: &str, files: Vec<PathBuf>) -> Result<Self, InputError> {
268        Self::from_parts(input, files.into_iter().map(InputSource::Local).collect())
269    }
270
271    /// `Single` for one part, `Multi` for several,
272    /// [`InputError::NoParquetInputs`] for none. `parts` must already be in
273    /// read order (the row-order invariant).
274    fn from_parts(input: &str, mut parts: Vec<InputSource>) -> Result<Self, InputError> {
275        match parts.len() {
276            0 => Err(InputError::NoParquetInputs {
277                input: input.to_string(),
278            }),
279            1 => Ok(ConvertSource::single(parts.remove(0))),
280            _ => Ok(ConvertSource::Multi(MultiSource::from_sources(
281                input.to_string(),
282                parts,
283            )?)),
284        }
285    }
286
287    /// Resolve an `s3://`/`gs://` prefix by listing the store: every
288    /// visible `.parquet` object under the prefix, sorted by key, all
289    /// sharing ONE store instance (one credential resolution) with sizes
290    /// taken from the listing (no per-part HEADs).
291    #[cfg(feature = "remote")]
292    fn from_remote_prefix(input: &str) -> Result<Self, InputError> {
293        let sources = crate::input::remote::sources_under_prefix(input)?;
294        Self::from_parts(
295            input,
296            sources.into_iter().map(InputSource::Remote).collect(),
297        )
298    }
299
300    /// Build a source from a `--files-from` manifest: one local path or
301    /// remote URL per line, `#`-prefixed comment lines and blank lines
302    /// skipped, entries trimmed. Line order is preserved VERBATIM — never
303    /// sorted — because the converter's row-order invariant keys winner
304    /// tables by global row offset; reordering the manifest reorders the
305    /// dataset. Each line is resolved as a SINGLE file/object (no
306    /// directory, glob, or prefix expansion); mixing local and remote
307    /// entries is allowed (compatibility is validated as usual).
308    pub fn from_manifest(manifest: &Path) -> Result<Self, InputError> {
309        let text = std::fs::read_to_string(manifest).map_err(|e| InputError::ManifestRead {
310            path: manifest.display().to_string(),
311            source: e,
312        })?;
313        let entries: Vec<(String, String)> = manifest_entries(&text)
314            .into_iter()
315            .map(|(line, entry)| {
316                (
317                    format!("line {line} of manifest {}", manifest.display()),
318                    entry.to_string(),
319                )
320            })
321            .collect();
322        Self::from_explicit_list(&manifest.display().to_string(), &entries)
323    }
324
325    /// Build a source from an explicit ordered list of inputs (local paths
326    /// or URLs) — the Python `list[str]` input shape. Order is preserved
327    /// verbatim; each entry is a single file/object (no expansion).
328    pub fn from_input_list<S: AsRef<str>>(inputs: &[S]) -> Result<Self, InputError> {
329        let entries: Vec<(String, String)> = inputs
330            .iter()
331            .enumerate()
332            .map(|(i, entry)| {
333                (
334                    format!("input list entry {}", i + 1),
335                    entry.as_ref().to_string(),
336                )
337            })
338            .collect();
339        Self::from_explicit_list("input list", &entries)
340    }
341
342    /// Shared by [`Self::from_manifest`] and [`Self::from_input_list`]:
343    /// resolve each `(context, entry)` as a single source, requiring local
344    /// entries to exist up front so the error can name the entry instead
345    /// of surfacing as a bare I/O error at footer-load time. Remote entries
346    /// connect (one HEAD each) under bounded concurrency
347    /// ([`LIST_CONNECT_CONCURRENCY`]); entry order is preserved verbatim
348    /// in the resulting parts (the row-order invariant).
349    fn from_explicit_list(root: &str, entries: &[(String, String)]) -> Result<Self, InputError> {
350        let parts = resolve_list_entries(entries, |context, entry| {
351            let src = InputSource::from_str_input(entry)?;
352            // Without the `remote` feature `Local` is the only variant.
353            #[cfg_attr(not(feature = "remote"), allow(irrefutable_let_patterns))]
354            if let InputSource::Local(p) = &src {
355                if !p.is_file() {
356                    return Err(InputError::MissingListedInput {
357                        context: context.to_string(),
358                        input: entry.to_string(),
359                    });
360                }
361            }
362            Ok(src)
363        })?;
364        Self::from_parts(root, parts)
365    }
366
367    /// [`ConvertSource::resolve`] for `Path` inputs (non-UTF-8 paths fall
368    /// back to a single local source, as [`InputSource::from_path`] does).
369    pub fn resolve_path(path: &Path) -> Result<Self, InputError> {
370        match path.to_str() {
371            Some(s) => Self::resolve(s),
372            None => Ok(ConvertSource::single(InputSource::from_path(path)?)),
373        }
374    }
375
376    /// The underlying parts, in read order (a single source is one part).
377    pub fn parts(&self) -> &[InputSource] {
378        match self {
379            ConvertSource::Single(s) => std::slice::from_ref(s.input()),
380            ConvertSource::Multi(m) => &m.parts,
381        }
382    }
383
384    /// Whether any part is remote.
385    pub fn is_remote(&self) -> bool {
386        self.parts().iter().any(InputSource::is_remote)
387    }
388
389    /// Place the remote-input disk spill in `dir` for every part (#272).
390    /// No-op for local parts, which never spill.
391    pub fn set_spill_dir(&self, dir: Option<&Path>) {
392        for part in self.parts() {
393            part.set_spill_dir(dir);
394        }
395    }
396
397    /// Human-readable input name: the path/URL for a single source,
398    /// `"<root> (N partitions)"` for a multi source.
399    pub fn display_name(&self) -> String {
400        match self {
401            ConvertSource::Single(s) => s.input().display_name(),
402            ConvertSource::Multi(m) => {
403                format!("{} ({} partitions)", m.root, m.parts.len())
404            }
405        }
406    }
407
408    /// The Arrow schema of the dataset. For a multi source this is the
409    /// validated union schema (nullability OR-ed across parts). After
410    /// [`Self::restrict_columns`], only the kept columns, in file order.
411    pub fn schema(&self) -> Result<SchemaRef, InputError> {
412        let full = match self {
413            ConvertSource::Single(s) => s.meta()?.schema.clone(),
414            ConvertSource::Multi(m) => m.schema.clone(),
415        };
416        Ok(match self.column_projection() {
417            None => full,
418            Some(keep) => Arc::new(full.project(keep)?),
419        })
420    }
421
422    /// The unprojected schema: every column the files carry, whether or not
423    /// [`Self::restrict_columns`] has narrowed what reads return.
424    pub fn file_schema(&self) -> Result<SchemaRef, InputError> {
425        match self {
426            ConvertSource::Single(s) => Ok(s.meta()?.schema.clone()),
427            ConvertSource::Multi(m) => Ok(m.schema.clone()),
428        }
429    }
430
431    /// Restrict every later read — and [`Self::schema`] — to these root
432    /// columns of the file schema, sorted ascending (#386). Applied once,
433    /// before anything derives column indices from the schema, so every
434    /// downstream index is already relative to the projected layout and the
435    /// excluded columns are never decoded (a remote input still stages every
436    /// chunk of a row group; only the decode is skipped). A second call is a
437    /// programming error.
438    pub fn restrict_columns(&self, keep: Vec<usize>) -> Result<(), InputError> {
439        let ncols = self.file_schema()?.fields().len();
440        if keep.windows(2).any(|w| w[0] >= w[1]) || keep.iter().any(|&i| i >= ncols) {
441            return Err(InputError::Arrow(arrow_schema::ArrowError::SchemaError(
442                format!("column restriction {keep:?} is not a sorted subset of 0..{ncols}"),
443            )));
444        }
445        let cell = match self {
446            ConvertSource::Single(s) => &s.projection,
447            ConvertSource::Multi(m) => &m.projection,
448        };
449        cell.set(keep).map_err(|_| {
450            InputError::Arrow(arrow_schema::ArrowError::SchemaError(
451                "column restriction already applied to this source".to_string(),
452            ))
453        })
454    }
455
456    /// The root columns reads are restricted to, if any (see
457    /// [`Self::restrict_columns`]).
458    pub fn column_projection(&self) -> Option<&[usize]> {
459        match self {
460            ConvertSource::Single(s) => s.projection.get().map(Vec::as_slice),
461            ConvertSource::Multi(m) => m.projection.get().map(Vec::as_slice),
462        }
463    }
464
465    /// Parquet key-value metadata of partition 0 (the `geo` metadata used
466    /// for CRS detection; construction validated all parts agree).
467    pub fn key_value_metadata(&self) -> Result<Option<Vec<KeyValue>>, InputError> {
468        match self {
469            ConvertSource::Single(s) => Ok(s
470                .meta()?
471                .parquet
472                .file_metadata()
473                .key_value_metadata()
474                .cloned()),
475            ConvertSource::Multi(m) => Ok(m.metas[0]
476                .parquet
477                .file_metadata()
478                .key_value_metadata()
479                .cloned()),
480        }
481    }
482
483    /// Total number of row groups across all parts.
484    pub fn num_row_groups_total(&self) -> Result<usize, InputError> {
485        Ok(self
486            .metas()?
487            .iter()
488            .map(|m| m.parquet.num_row_groups())
489            .sum())
490    }
491
492    /// Per-part bbox row-group selection (#102): applies the single-file
493    /// covering-statistics pruning to each part independently.
494    /// `bbox_units` is `[xmin, ymin, xmax, ymax]` in the file CRS units.
495    pub fn select_row_groups(
496        &self,
497        bbox_units: &[f64; 4],
498    ) -> Result<RowGroupSelection, InputError> {
499        let per_part = self
500            .metas()?
501            .iter()
502            .map(|m| crate::overview::convert::select_input_row_groups(&m.parquet, bbox_units))
503            .collect();
504        Ok(RowGroupSelection(per_part))
505    }
506
507    /// Per-part attribute-filter row-group selection (#315): applies the
508    /// bound filter's column-statistics pushdown
509    /// ([`crate::overview::filter::BoundFilter::select_row_groups`]) to each
510    /// part independently. Conservative — groups without usable statistics
511    /// are kept.
512    pub fn select_row_groups_matching(
513        &self,
514        filter: &crate::overview::filter::BoundFilter,
515    ) -> Result<RowGroupSelection, InputError> {
516        let per_part = self
517            .metas()?
518            .iter()
519            .map(|m| filter.select_row_groups(&m.parquet))
520            .collect();
521        Ok(RowGroupSelection(per_part))
522    }
523
524    /// Total *compressed* bytes of the selected row groups (`None` = every
525    /// row group of every part) — the projected disk-spill size the #272
526    /// free-space preflight consults. Per-part sums come from the shared
527    /// single-file helper [`crate::input::selected_compressed_bytes`].
528    pub fn selected_input_bytes(
529        &self,
530        selection: Option<&RowGroupSelection>,
531    ) -> Result<u64, InputError> {
532        let metas = self.metas()?;
533        if let Some(sel) = selection {
534            debug_assert_eq!(metas.len(), sel.0.len());
535        }
536        Ok(metas
537            .iter()
538            .enumerate()
539            .map(|(pi, m)| {
540                crate::input::selected_compressed_bytes(
541                    &m.parquet,
542                    selection.map(|s| s.0[pi].as_slice()),
543                )
544            })
545            .sum())
546    }
547
548    /// Fetch counters summed over remote parts (`None` when no part is
549    /// remote). `object_size` is the summed size of the remote objects.
550    pub fn fetch_stats(&self) -> Option<FetchStats> {
551        let mut total: Option<FetchStats> = None;
552        for part in self.parts() {
553            if let Some(s) = part.fetch_stats() {
554                let t = total.get_or_insert(FetchStats::default());
555                t.requests += s.requests;
556                t.bytes_fetched += s.bytes_fetched;
557                t.object_size += s.object_size;
558            }
559        }
560        total
561    }
562
563    /// Stage every part's selected row groups to local disk up front (pass 0,
564    /// #286/#287) so the streaming passes read from disk, not the network.
565    ///
566    /// Each remote part coalesces its selected row groups into one parallel
567    /// range request per row group (a row group is a contiguous byte span);
568    /// local parts are no-ops. Parts are staged in order so resident memory
569    /// stays bounded by one part's in-flight spans. `selection` (`None` = all
570    /// row groups) is the per-part bbox pruning the passes will honor, so
571    /// pruned groups are never fetched and total network traffic stays ≈1×
572    /// the input (#219). Errors are the caller's to handle; the streaming
573    /// pipeline treats staging as best-effort (a network error here is one the
574    /// passes would hit anyway).
575    pub fn stage_selected(&self, selection: Option<&RowGroupSelection>) -> Result<(), InputError> {
576        let parts = self.parts();
577        if let Some(sel) = selection {
578            debug_assert_eq!(
579                sel.0.len(),
580                parts.len(),
581                "row-group selection must cover every part"
582            );
583        }
584        for (i, part) in parts.iter().enumerate() {
585            part.stage_row_groups(selection.map(|s| s.0[i].as_slice()))?;
586        }
587        Ok(())
588    }
589
590    /// Open a sequential batch stream over all parts (see [`SourceStream`]).
591    pub fn open_stream(&self, plan: &ReadPlan<'_>) -> Result<SourceStream<'_>, InputError> {
592        let parts = self.parts();
593        if let Some(sel) = plan.row_groups {
594            debug_assert_eq!(
595                sel.0.len(),
596                parts.len(),
597                "row-group selection must cover every part"
598            );
599        }
600        // A plan's projection indexes the (possibly restricted) schema this
601        // source exposes; the reader wants file-root indices. Compose the two.
602        let projection = match (self.column_projection(), plan.projection) {
603            (None, None) => None,
604            (None, Some(cols)) => Some(cols.to_vec()),
605            (Some(base), None) => Some(base.to_vec()),
606            (Some(base), Some(cols)) => Some(cols.iter().map(|&c| base[c]).collect()),
607        };
608        Ok(SourceStream {
609            parts,
610            projection,
611            row_groups: plan.row_groups.map(|s| s.0.clone()),
612            batch_size: plan.batch_size.max(1),
613            part_idx: 0,
614            current: None,
615            done: false,
616        })
617    }
618
619    /// Footer metadata per part: borrowed for `Multi` (loaded at
620    /// construction), loaded on demand for `Single`.
621    fn metas(&self) -> Result<std::borrow::Cow<'_, [PartMeta]>, InputError> {
622        match self {
623            ConvertSource::Single(s) => {
624                Ok(std::borrow::Cow::Borrowed(std::slice::from_ref(s.meta()?)))
625            }
626            ConvertSource::Multi(m) => Ok(std::borrow::Cow::Borrowed(&m.metas)),
627        }
628    }
629}
630
631/// Load one part's footer: Arrow schema + parsed parquet metadata. Cheap
632/// for local files (OS page cache); remote sources reuse their cached
633/// footer after the first open.
634fn load_part_meta(source: &InputSource) -> Result<PartMeta, InputError> {
635    let builder = source.open()?;
636    Ok(PartMeta {
637        schema: builder.schema().clone(),
638        parquet: builder.metadata().clone(),
639    })
640}
641
642/// Ceiling on concurrent footer loads in [`load_part_metas`]. A remote
643/// footer costs two range requests; loading hundreds of parts serially
644/// pays hundreds of sequential round-trips, while unbounded parallelism
645/// would open one connection per part. Eight in flight keeps a
646/// hundreds-of-parts prefix listing responsive without a connection storm.
647const FOOTER_LOAD_CONCURRENCY: usize = 8;
648
649/// Load every part's footer, in order, with bounded concurrency. Results
650/// are written into per-index slots so the returned order always matches
651/// `parts` regardless of completion order; the first error (by part index)
652/// wins.
653fn load_part_metas(parts: &[InputSource]) -> Result<Vec<PartMeta>, InputError> {
654    use std::sync::atomic::{AtomicUsize, Ordering};
655    let workers = FOOTER_LOAD_CONCURRENCY.min(parts.len());
656    if workers <= 1 {
657        return parts.iter().map(load_part_meta).collect();
658    }
659    let next = AtomicUsize::new(0);
660    let slots: Vec<std::sync::Mutex<Option<Result<PartMeta, InputError>>>> = (0..parts.len())
661        .map(|_| std::sync::Mutex::new(None))
662        .collect();
663    std::thread::scope(|scope| {
664        for _ in 0..workers {
665            scope.spawn(|| loop {
666                let i = next.fetch_add(1, Ordering::Relaxed);
667                if i >= parts.len() {
668                    break;
669                }
670                let meta = load_part_meta(&parts[i]);
671                *slots[i].lock().expect("footer slot lock") = Some(meta);
672            });
673        }
674    });
675    slots
676        .into_iter()
677        .map(|slot| {
678            slot.into_inner()
679                .expect("footer slot lock")
680                .expect("every slot filled by a worker")
681        })
682        .collect()
683}
684
685impl MultiSource {
686    /// Build a multi source over already-constructed parts: load every
687    /// part's footer and validate compatibility against part 0 (see the
688    /// module docs). `parts` must be non-empty and already ordered.
689    /// Footer loads run with bounded concurrency
690    /// ([`FOOTER_LOAD_CONCURRENCY`]): a remote footer is two range
691    /// requests, so hundreds of parts would otherwise serialize hundreds
692    /// of round-trips — but must not open hundreds of connections at once
693    /// either.
694    pub fn from_sources(root: String, parts: Vec<InputSource>) -> Result<Self, InputError> {
695        assert!(!parts.is_empty(), "MultiSource requires at least one part");
696        let metas = load_part_metas(&parts)?;
697
698        let first_name = parts[0].display_name();
699        // The reference CRS: `Ok(crs)` per detect_crs_from_kv, `None` for an
700        // unsupported/undetectable CRS. Parts must AGREE; supportedness
701        // itself is enforced by the pipeline (against part 0), so a set
702        // that consistently carries one unsupported CRS still errors with
703        // the standard UnsupportedCrs message.
704        let crs_of = |m: &PartMeta| {
705            crate::overview::convert::detect_crs_from_kv(
706                m.parquet.file_metadata().key_value_metadata(),
707            )
708            .ok()
709        };
710        // Raw CRS descriptor from the `geo` metadata: disambiguates two
711        // *different* unsupported CRSs, which both detect to `None` and
712        // would otherwise "agree" here only to error later naming just
713        // part 0's CRS.
714        let raw_crs_of = |m: &PartMeta| -> String {
715            crate::quality::crs_info_from_kv_metadata(
716                m.parquet.file_metadata().key_value_metadata(),
717            )
718            .ok()
719            .and_then(|info| info.identifier.or(info.name))
720            .unwrap_or_else(|| "unknown".to_string())
721        };
722        let first_crs = crs_of(&metas[0]);
723
724        for (part, meta) in parts.iter().zip(&metas).skip(1) {
725            let incompatible = |detail: String| InputError::IncompatiblePartition {
726                first: first_name.clone(),
727                offender: part.display_name(),
728                detail,
729            };
730            validate_schema_shape(&metas[0].schema, &meta.schema).map_err(&incompatible)?;
731            let crs = crs_of(meta);
732            if crs != first_crs {
733                return Err(incompatible(format!(
734                    "CRS mismatch: partition declares {:?} but the first partition \
735                     declares {:?} (all partitions must share one CRS)",
736                    raw_crs_of(meta),
737                    raw_crs_of(&metas[0]),
738                )));
739            }
740            if crs.is_none() {
741                // Both undetectable: still require the RAW declarations to
742                // agree, so the eventual UnsupportedCrs error is truthful.
743                let (a, b) = (raw_crs_of(&metas[0]), raw_crs_of(meta));
744                if a != b {
745                    return Err(incompatible(format!(
746                        "CRS mismatch: partition declares {b:?} but the first \
747                         partition declares {a:?} (all partitions must share \
748                         one CRS)"
749                    )));
750                }
751            }
752        }
753
754        let schema = union_schema(&metas);
755        Ok(MultiSource {
756            root,
757            parts,
758            metas,
759            schema,
760            projection: std::sync::OnceLock::new(),
761        })
762    }
763}
764
765/// Validate that `other` matches `first` in field count, names, types,
766/// order, and field (extension) metadata — everything except nullability,
767/// which the union schema absorbs. Returns a human-readable detail on the
768/// first difference.
769fn validate_schema_shape(first: &Schema, other: &Schema) -> Result<(), String> {
770    if first.fields().len() != other.fields().len() {
771        return Err(format!(
772            "column count differs: {} vs {} in the first partition",
773            other.fields().len(),
774            first.fields().len()
775        ));
776    }
777    for (ci, (f0, fi)) in first.fields().iter().zip(other.fields()).enumerate() {
778        if f0.name() != fi.name() {
779            return Err(format!(
780                "column {ci} is named {:?} but the first partition has {:?} \
781                 (columns must match in name and order)",
782                fi.name(),
783                f0.name()
784            ));
785        }
786        if f0.data_type() != fi.data_type() {
787            return Err(format!(
788                "column {:?} has type {:?} but the first partition has {:?}",
789                fi.name(),
790                fi.data_type(),
791                f0.data_type()
792            ));
793        }
794        if f0.metadata() != fi.metadata() {
795            return Err(format!(
796                "column {:?} carries different field (extension) metadata than \
797                 the first partition ({}) — geometry encoding/CRS metadata must \
798                 match",
799                fi.name(),
800                describe_metadata_diff(f0.metadata(), fi.metadata())
801            ));
802        }
803    }
804    Ok(())
805}
806
807/// Human-readable first difference between two field-metadata maps:
808/// the mismatching key plus a truncated value diff (or which side is
809/// missing the key). Byte-exact comparison can false-reject cross-writer
810/// partitions (e.g. semantically equal PROJJSON serialized differently),
811/// so the error must show the user exactly what to reconcile.
812fn describe_metadata_diff(
813    first: &std::collections::HashMap<String, String>,
814    other: &std::collections::HashMap<String, String>,
815) -> String {
816    fn trunc(s: &str) -> String {
817        const MAX: usize = 80;
818        if s.chars().count() > MAX {
819            let cut: String = s.chars().take(MAX).collect();
820            format!("{cut:?}…")
821        } else {
822            format!("{s:?}")
823        }
824    }
825    let mut keys: Vec<&String> = first.keys().chain(other.keys()).collect();
826    keys.sort();
827    keys.dedup();
828    for key in keys {
829        match (first.get(key), other.get(key)) {
830            (Some(a), Some(b)) if a != b => {
831                return format!(
832                    "key {key:?}: first partition has {} but this partition has {}",
833                    trunc(a),
834                    trunc(b)
835                );
836            }
837            (Some(_), None) => {
838                return format!("key {key:?} is present only in the first partition");
839            }
840            (None, Some(_)) => {
841                return format!("key {key:?} is present only in this partition");
842            }
843            _ => {}
844        }
845    }
846    "maps differ".to_string()
847}
848
849/// Partition 0's schema with nullability OR-ed across all parts
850/// (any-nullable ⇒ nullable); schema-level metadata from partition 0.
851fn union_schema(metas: &[PartMeta]) -> SchemaRef {
852    let first = &metas[0].schema;
853    let fields: Vec<Field> = first
854        .fields()
855        .iter()
856        .enumerate()
857        .map(|(ci, f0)| {
858            let nullable = metas.iter().any(|m| m.schema.field(ci).is_nullable());
859            f0.as_ref().clone().with_nullable(nullable)
860        })
861        .collect();
862    Arc::new(Schema::new_with_metadata(fields, first.metadata().clone()))
863}
864
865impl SourceStream<'_> {
866    /// Open part `i`'s reader with this stream's projection / row-group
867    /// selection / batch size. Schemas are identical across parts, so the
868    /// root-column projection indices are valid for every part.
869    fn open_part(&self, i: usize) -> Result<ParquetRecordBatchReader, InputError> {
870        let mut builder = self.parts[i].open()?;
871        if let Some(cols) = &self.projection {
872            let mask = ProjectionMask::roots(builder.parquet_schema(), cols.iter().copied());
873            builder = builder.with_projection(mask);
874        }
875        if let Some(sel) = &self.row_groups {
876            builder = builder.with_row_groups(sel[i].clone());
877        }
878        Ok(builder.with_batch_size(self.batch_size).build()?)
879    }
880}
881
882impl Iterator for SourceStream<'_> {
883    type Item = Result<RecordBatch, InputError>;
884
885    fn next(&mut self) -> Option<Self::Item> {
886        if self.done {
887            return None;
888        }
889        loop {
890            if let Some(reader) = &mut self.current {
891                match reader.next() {
892                    Some(Ok(batch)) => return Some(Ok(batch)),
893                    Some(Err(e)) => {
894                        self.done = true;
895                        return Some(Err(InputError::Arrow(e)));
896                    }
897                    None => {
898                        // Part exhausted. Release its in-memory read cache
899                        // before moving on (bounds resident memory to one
900                        // part's working set); a single-part stream never
901                        // releases, preserving the single-file multi-pass
902                        // cache behavior.
903                        self.current = None;
904                        if self.part_idx + 1 < self.parts.len() {
905                            self.parts[self.part_idx].release_read_cache();
906                        }
907                        self.part_idx += 1;
908                    }
909                }
910                continue;
911            }
912            if self.part_idx >= self.parts.len() {
913                self.done = true;
914                return None;
915            }
916            // Skip parts whose row-group selection is empty without opening
917            // them at all (a bbox-pruned remote part is never touched).
918            if let Some(sel) = &self.row_groups {
919                if sel[self.part_idx].is_empty() {
920                    self.part_idx += 1;
921                    continue;
922                }
923            }
924            match self.open_part(self.part_idx) {
925                Ok(reader) => self.current = Some(reader),
926                Err(e) => {
927                    self.done = true;
928                    return Some(Err(e));
929                }
930            }
931        }
932    }
933}
934
935/// Whether `input` contains glob metacharacters (`*`, `?`, `[`).
936fn has_glob_meta(input: &str) -> bool {
937    input.contains(['*', '?', '['])
938}
939
940/// Entry-resolution concurrency for explicit lists (`--files-from`
941/// manifests, Python input lists). Remote entries cost one connect (HEAD)
942/// each, so hundreds of manifest lines would otherwise serialize hundreds
943/// of round-trips; matches [`FOOTER_LOAD_CONCURRENCY`].
944const LIST_CONNECT_CONCURRENCY: usize = 8;
945
946/// Resolve every `(context, entry)` pair with `resolve` under bounded
947/// concurrency ([`LIST_CONNECT_CONCURRENCY`]). Results are written into
948/// per-index slots, so the returned vector always matches `entries` order
949/// regardless of completion order — the row-order invariant — and the
950/// first error (by entry index) wins.
951fn resolve_list_entries<F>(
952    entries: &[(String, String)],
953    resolve: F,
954) -> Result<Vec<InputSource>, InputError>
955where
956    F: Fn(&str, &str) -> Result<InputSource, InputError> + Sync,
957{
958    use std::sync::atomic::{AtomicUsize, Ordering};
959    let workers = LIST_CONNECT_CONCURRENCY.min(entries.len());
960    if workers <= 1 {
961        return entries
962            .iter()
963            .map(|(context, entry)| resolve(context, entry))
964            .collect();
965    }
966    let next = AtomicUsize::new(0);
967    let slots: Vec<std::sync::Mutex<Option<Result<InputSource, InputError>>>> = (0..entries.len())
968        .map(|_| std::sync::Mutex::new(None))
969        .collect();
970    std::thread::scope(|scope| {
971        for _ in 0..workers {
972            scope.spawn(|| loop {
973                let i = next.fetch_add(1, Ordering::Relaxed);
974                if i >= entries.len() {
975                    break;
976                }
977                let (context, entry) = &entries[i];
978                *slots[i].lock().expect("entry slot lock") = Some(resolve(context, entry));
979            });
980        }
981    });
982    slots
983        .into_iter()
984        .map(|slot| {
985            slot.into_inner()
986                .expect("entry slot lock")
987                .expect("every slot filled by a worker")
988        })
989        .collect()
990}
991
992/// Parse `--files-from` manifest text into `(1-based line number, entry)`
993/// pairs: entries are trimmed; blank lines and lines whose first non-space
994/// character is `#` are skipped. Order is preserved verbatim (see
995/// [`ConvertSource::from_manifest`]).
996fn manifest_entries(text: &str) -> Vec<(usize, &str)> {
997    text.lines()
998        .enumerate()
999        .filter_map(|(i, line)| {
1000            let entry = line.trim();
1001            (!entry.is_empty() && !entry.starts_with('#')).then_some((i + 1, entry))
1002        })
1003        .collect()
1004}
1005
1006/// Whether a remote URL names a *prefix* ("directory"): its path component
1007/// — query string and fragment stripped — ends with `/`. Everything else,
1008/// including extension-less presigned/API URLs that serve parquet, is a
1009/// single object (the pre-v0.7 classification).
1010// Used by `resolve` only when remote support is compiled in; the pure
1011// classification is unit-tested under every feature set.
1012#[cfg_attr(not(feature = "remote"), allow(dead_code))]
1013fn remote_url_is_prefix(url: &str) -> bool {
1014    let no_fragment = url.split('#').next().unwrap_or(url);
1015    let no_query = no_fragment.split('?').next().unwrap_or(no_fragment);
1016    no_query.ends_with('/')
1017}
1018
1019/// Recursively collect `.parquet` files under `dir`, sorted
1020/// lexicographically. Basenames starting with `.` or `_` (files *and*
1021/// directories — `_SUCCESS`, `.crc`, `_temporary/`) are skipped.
1022pub(crate) fn list_parquet_files(dir: &Path) -> Result<Vec<PathBuf>, InputError> {
1023    fn hidden(path: &Path) -> bool {
1024        path.file_name()
1025            .and_then(|n| n.to_str())
1026            .is_some_and(|n| n.starts_with('.') || n.starts_with('_'))
1027    }
1028    fn collect(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), InputError> {
1029        for entry in std::fs::read_dir(dir)? {
1030            let path = entry?.path();
1031            if hidden(&path) {
1032                continue;
1033            }
1034            if path.is_dir() {
1035                collect(&path, files)?;
1036            } else if path.extension().is_some_and(|ext| ext == "parquet") {
1037                files.push(path);
1038            }
1039        }
1040        Ok(())
1041    }
1042    let mut files = Vec::new();
1043    collect(dir, &mut files)?;
1044    files.sort();
1045    Ok(files)
1046}
1047
1048/// Expand a glob pattern to `.parquet` files: matched files filtered by
1049/// extension, sorted lexicographically, deduplicated.
1050pub(crate) fn expand_glob(pattern: &str) -> Result<Vec<PathBuf>, InputError> {
1051    let paths = glob::glob(pattern).map_err(|e| InputError::GlobPattern {
1052        pattern: pattern.to_string(),
1053        message: e.to_string(),
1054    })?;
1055    let mut files = Vec::new();
1056    for entry in paths {
1057        // glob 0.3.3 dropped `impl From<GlobError> for io::Error` and 0.3.4
1058        // restored it, deprecating `into_error`. The floor in Cargo.toml is
1059        // 0.3.4 so that `into` is the one form that compiles warning-free.
1060        let path = entry.map_err(|e| InputError::Io(e.into()))?;
1061        if path.is_file() && path.extension().is_some_and(|ext| ext == "parquet") {
1062            files.push(path);
1063        }
1064    }
1065    files.sort();
1066    files.dedup();
1067    Ok(files)
1068}
1069
1070/// Default PMTiles layer name derived from a CLI-style input string — the
1071/// multi-partition generalization of "the input file's stem":
1072///
1073/// - single file (local path or nonexistent-yet path) → file stem
1074///   (historical behavior; also what a `--files-from` manifest path gives:
1075///   the manifest file's stem);
1076/// - existing local directory → the directory's last path segment,
1077///   verbatim (a dotted directory name is a name, not an extension);
1078/// - glob pattern → the deepest literal (wildcard-free) path segment
1079///   before the first wildcard segment;
1080/// - remote URL (`scheme://…`, query string and fragment stripped):
1081///   trailing-slash prefix → last non-empty path segment verbatim
1082///   (bucket name for a bucket-root prefix); single object → the key's
1083///   last segment's stem;
1084/// - anything degenerate (empty, no usable segment) → `"layer"`, the
1085///   same fallback the single-file path has always used.
1086pub fn derive_layer_name(input: &str) -> String {
1087    const FALLBACK: &str = "layer";
1088    let stem_of = |p: &Path| p.file_stem().and_then(|s| s.to_str()).map(str::to_string);
1089
1090    let name = if url_scheme(input).is_some() {
1091        let no_fragment = input.split('#').next().unwrap_or(input);
1092        let no_query = no_fragment.split('?').next().unwrap_or(no_fragment);
1093        let rest = no_query
1094            .split_once("://")
1095            .map(|(_, rest)| rest)
1096            .unwrap_or(no_query);
1097        rest.split('/')
1098            .rev()
1099            .find(|seg| !seg.is_empty())
1100            .and_then(|seg| {
1101                if remote_url_is_prefix(input) {
1102                    Some(seg.to_string())
1103                } else {
1104                    stem_of(Path::new(seg))
1105                }
1106            })
1107    } else {
1108        let path = Path::new(input);
1109        if path.is_dir() {
1110            path.file_name()
1111                .and_then(|n| n.to_str())
1112                .map(str::to_string)
1113        } else if !path.is_file() && has_glob_meta(input) {
1114            // Deepest literal segment before the first wildcard one.
1115            let mut last_literal = None;
1116            for comp in path.components() {
1117                if let std::path::Component::Normal(seg) = comp {
1118                    match seg.to_str() {
1119                        Some(s) if !has_glob_meta(s) => last_literal = Some(s.to_string()),
1120                        // Wildcard (or non-UTF-8) segment: stop descending.
1121                        _ => break,
1122                    }
1123                }
1124            }
1125            last_literal
1126        } else {
1127            stem_of(path)
1128        }
1129    };
1130    name.filter(|n| !n.is_empty())
1131        .unwrap_or_else(|| FALLBACK.to_string())
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136    use super::*;
1137    use arrow_array::{ArrayRef, Int64Array, StringArray};
1138    use arrow_schema::DataType;
1139    use parquet::arrow::ArrowWriter;
1140    use parquet::file::properties::WriterProperties;
1141    use std::fs::File;
1142
1143    /// Write a small parquet file with the given fields/columns and row
1144    /// group size (None = single row group).
1145    fn write_parquet(
1146        path: &Path,
1147        fields: Vec<Field>,
1148        columns: Vec<ArrayRef>,
1149        max_row_group_size: Option<usize>,
1150    ) {
1151        let schema = Arc::new(Schema::new(fields));
1152        let batch = RecordBatch::try_new(schema.clone(), columns).unwrap();
1153        let props = max_row_group_size.map(|n| {
1154            WriterProperties::builder()
1155                .set_max_row_group_row_count(Some(n))
1156                .build()
1157        });
1158        let file = File::create(path).unwrap();
1159        let mut writer = ArrowWriter::try_new(file, schema, props).unwrap();
1160        writer.write(&batch).unwrap();
1161        writer.close().unwrap();
1162    }
1163
1164    /// Standard two-column fixture: `id: Int64 (non-null)`, `name: Utf8`.
1165    fn write_standard(path: &Path, ids: Vec<i64>, nullable_id: bool) {
1166        let n = ids.len();
1167        write_parquet(
1168            path,
1169            vec![
1170                Field::new("id", DataType::Int64, nullable_id),
1171                Field::new("name", DataType::Utf8, true),
1172            ],
1173            vec![
1174                Arc::new(Int64Array::from(ids)),
1175                Arc::new(StringArray::from(
1176                    (0..n).map(|i| format!("r{i}")).collect::<Vec<_>>(),
1177                )),
1178            ],
1179            None,
1180        );
1181    }
1182
1183    fn tmpdir() -> tempfile::TempDir {
1184        tempfile::tempdir().unwrap()
1185    }
1186
1187    // --- resolution ---------------------------------------------------------
1188
1189    #[test]
1190    fn resolve_existing_file_is_single() {
1191        let dir = tmpdir();
1192        let f = dir.path().join("a.parquet");
1193        write_standard(&f, vec![1, 2], false);
1194        let src = ConvertSource::resolve(f.to_str().unwrap()).unwrap();
1195        assert!(matches!(src, ConvertSource::Single(_)));
1196        assert_eq!(src.display_name(), f.display().to_string());
1197        assert_eq!(src.parts().len(), 1);
1198    }
1199
1200    #[test]
1201    fn resolve_directory_recursive_sorted_multi() {
1202        let dir = tmpdir();
1203        let sub = dir.path().join("sub");
1204        std::fs::create_dir(&sub).unwrap();
1205        write_standard(&dir.path().join("b.parquet"), vec![1], false);
1206        write_standard(&sub.join("a.parquet"), vec![2], false);
1207        std::fs::write(dir.path().join("readme.txt"), "x").unwrap();
1208
1209        let src = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap();
1210        let ConvertSource::Multi(m) = &src else {
1211            panic!("directory with 2 parquet files must resolve to Multi");
1212        };
1213        assert_eq!(m.parts.len(), 2);
1214        // Lexicographic: "b.parquet" < "sub/a.parquet".
1215        assert!(src.parts()[0].display_name().ends_with("b.parquet"));
1216        assert!(src.parts()[1].display_name().ends_with("a.parquet"));
1217        assert!(src.display_name().contains("(2 partitions)"));
1218    }
1219
1220    #[test]
1221    fn list_parquet_files_skips_hidden_and_success_markers() {
1222        let dir = tmpdir();
1223        write_standard(&dir.path().join("part-0.parquet"), vec![1], false);
1224        write_standard(&dir.path().join("part-1.parquet"), vec![2], false);
1225        // Markers and hidden files/dirs must be skipped.
1226        std::fs::write(dir.path().join("_SUCCESS"), "").unwrap();
1227        write_standard(&dir.path().join("_stale.parquet"), vec![9], false);
1228        write_standard(&dir.path().join(".hidden.parquet"), vec![9], false);
1229        let hidden_dir = dir.path().join("_temporary");
1230        std::fs::create_dir(&hidden_dir).unwrap();
1231        write_standard(&hidden_dir.join("x.parquet"), vec![9], false);
1232
1233        let files = list_parquet_files(dir.path()).unwrap();
1234        assert_eq!(files.len(), 2, "only visible .parquet files: {files:?}");
1235        assert!(files.windows(2).all(|w| w[0] <= w[1]), "sorted: {files:?}");
1236        assert!(files[0].ends_with("part-0.parquet"));
1237        assert!(files[1].ends_with("part-1.parquet"));
1238    }
1239
1240    #[test]
1241    fn resolve_empty_directory_errors_naming_input() {
1242        let dir = tmpdir();
1243        std::fs::write(dir.path().join("_SUCCESS"), "").unwrap();
1244        let err = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap_err();
1245        match err {
1246            InputError::NoParquetInputs { input } => {
1247                assert_eq!(input, dir.path().to_str().unwrap());
1248            }
1249            other => panic!("expected NoParquetInputs, got {other:?}"),
1250        }
1251    }
1252
1253    #[test]
1254    fn resolve_glob_sorted_dedup_and_single_collapse() {
1255        let dir = tmpdir();
1256        write_standard(&dir.path().join("p2.parquet"), vec![1], false);
1257        write_standard(&dir.path().join("p1.parquet"), vec![2], false);
1258        std::fs::write(dir.path().join("p3.txt"), "x").unwrap();
1259
1260        let pattern = format!("{}/p*.parquet", dir.path().display());
1261        let files = expand_glob(&pattern).unwrap();
1262        assert_eq!(files.len(), 2);
1263        assert!(files[0].ends_with("p1.parquet"));
1264        assert!(files[1].ends_with("p2.parquet"));
1265
1266        let src = ConvertSource::resolve(&pattern).unwrap();
1267        assert!(matches!(src, ConvertSource::Multi(_)));
1268
1269        // A single-match glob collapses to Single.
1270        let single = format!("{}/p1*.parquet", dir.path().display());
1271        let src = ConvertSource::resolve(&single).unwrap();
1272        assert!(matches!(src, ConvertSource::Single(_)));
1273
1274        // A no-match glob errors, naming the pattern.
1275        let none = format!("{}/zzz*.parquet", dir.path().display());
1276        let err = ConvertSource::resolve(&none).unwrap_err();
1277        assert!(matches!(err, InputError::NoParquetInputs { .. }), "{err:?}");
1278    }
1279
1280    /// Pure prefix classification: ONLY a trailing-slash path is a prefix.
1281    /// Extension-less non-slash URLs (presigned / API download endpoints
1282    /// that serve parquet) must stay single objects, as on main.
1283    #[test]
1284    fn remote_prefix_is_trailing_slash_only() {
1285        assert!(remote_url_is_prefix("s3://bucket/dataset/"));
1286        assert!(remote_url_is_prefix("gs://bucket/prefix/"));
1287        assert!(remote_url_is_prefix("https://example.com/data/?list=1"));
1288        assert!(remote_url_is_prefix("https://example.com/data/#frag"));
1289        assert!(!remote_url_is_prefix("s3://bucket/key.parquet"));
1290        assert!(!remote_url_is_prefix(
1291            "https://h/k.parquet?X-Amz-Signature=abc"
1292        ));
1293        assert!(!remote_url_is_prefix(
1294            "https://host/api/datasets/42/download"
1295        ));
1296        assert!(!remote_url_is_prefix("s3://bucket/dataset"));
1297    }
1298
1299    /// http(s) prefixes stay hard errors (generic HTTP has no listing API)
1300    /// and the error points at `--files-from`. s3/gs prefixes are listed
1301    /// for real now (covered by the InMemory tests below), so they are NOT
1302    /// rejected here.
1303    #[cfg(feature = "remote")]
1304    #[test]
1305    fn resolve_https_prefix_points_at_files_from() {
1306        for url in ["https://example.com/data/", "http://example.com/data/"] {
1307            let err = ConvertSource::resolve(url).unwrap_err();
1308            assert!(
1309                matches!(err, InputError::RemotePrefixUnsupported { .. }),
1310                "{url} → {err:?}"
1311            );
1312            let msg = err.to_string();
1313            assert!(
1314                msg.contains("--files-from"),
1315                "error must point at --files-from: {msg}"
1316            );
1317        }
1318    }
1319
1320    /// Without the `remote` feature every remote URL — prefix-shaped or
1321    /// not — keeps main's behavior: `RemoteDisabled`, never the misleading
1322    /// "prefix listing coming later" message. An extension-less URL
1323    /// reaching `RemoteDisabled` also proves it was classified as a single
1324    /// object (the prefix arm would have returned earlier).
1325    #[cfg(not(feature = "remote"))]
1326    #[test]
1327    fn remote_disabled_behavior_unchanged() {
1328        for url in [
1329            "s3://bucket/prefix/",
1330            "s3://bucket/key.parquet",
1331            "https://host/api/datasets/42/download",
1332        ] {
1333            let err = ConvertSource::resolve(url).unwrap_err();
1334            assert!(
1335                matches!(err, InputError::RemoteDisabled(_)),
1336                "{url} → {err:?}"
1337            );
1338        }
1339    }
1340
1341    // --- compatibility validation -------------------------------------------
1342
1343    #[test]
1344    fn schema_mismatch_names_offending_partition() {
1345        let dir = tmpdir();
1346        write_standard(&dir.path().join("a.parquet"), vec![1], false);
1347        // Different column name in the second partition.
1348        write_parquet(
1349            &dir.path().join("b.parquet"),
1350            vec![
1351                Field::new("id2", DataType::Int64, false),
1352                Field::new("name", DataType::Utf8, true),
1353            ],
1354            vec![
1355                Arc::new(Int64Array::from(vec![1i64])),
1356                Arc::new(StringArray::from(vec!["x"])),
1357            ],
1358            None,
1359        );
1360        let err = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap_err();
1361        match err {
1362            InputError::IncompatiblePartition {
1363                first,
1364                offender,
1365                detail,
1366            } => {
1367                assert!(first.ends_with("a.parquet"), "first: {first}");
1368                assert!(offender.ends_with("b.parquet"), "offender: {offender}");
1369                assert!(detail.contains("id2") || detail.contains("id"), "{detail}");
1370            }
1371            other => panic!("expected IncompatiblePartition, got {other:?}"),
1372        }
1373    }
1374
1375    #[test]
1376    fn type_mismatch_rejected() {
1377        let dir = tmpdir();
1378        write_standard(&dir.path().join("a.parquet"), vec![1], false);
1379        write_parquet(
1380            &dir.path().join("b.parquet"),
1381            vec![
1382                Field::new("id", DataType::Utf8, false), // Int64 in part a
1383                Field::new("name", DataType::Utf8, true),
1384            ],
1385            vec![
1386                Arc::new(StringArray::from(vec!["1"])),
1387                Arc::new(StringArray::from(vec!["x"])),
1388            ],
1389            None,
1390        );
1391        let err = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap_err();
1392        assert!(
1393            matches!(err, InputError::IncompatiblePartition { .. }),
1394            "{err:?}"
1395        );
1396    }
1397
1398    /// Two partitions with *different unsupported* CRSs must be rejected at
1399    /// set-construction time (naming both raw values), not "agree on
1400    /// undetectable" and error later with only part 0's CRS.
1401    #[test]
1402    fn different_unsupported_crs_rejected_with_both_values() {
1403        let dir = tmpdir();
1404        let geo = |epsg: u32| {
1405            format!(
1406                r#"{{"version":"1.0.0","primary_column":"geometry","columns":{{"geometry":{{"crs":"EPSG:{epsg}"}}}}}}"#
1407            )
1408        };
1409        for (file, epsg) in [("a.parquet", 32633u32), ("b.parquet", 32634u32)] {
1410            let path = dir.path().join(file);
1411            let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
1412            let batch = RecordBatch::try_new(
1413                schema.clone(),
1414                vec![Arc::new(Int64Array::from(vec![1i64])) as ArrayRef],
1415            )
1416            .unwrap();
1417            let file = File::create(&path).unwrap();
1418            let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
1419            writer.write(&batch).unwrap();
1420            writer.append_key_value_metadata(parquet::file::metadata::KeyValue::new(
1421                "geo".to_string(),
1422                geo(epsg),
1423            ));
1424            writer.close().unwrap();
1425        }
1426        let err = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap_err();
1427        match err {
1428            InputError::IncompatiblePartition {
1429                offender, detail, ..
1430            } => {
1431                assert!(offender.ends_with("b.parquet"), "offender: {offender}");
1432                assert!(
1433                    detail.contains("32633") && detail.contains("32634"),
1434                    "detail must name both raw CRS values: {detail}"
1435                );
1436            }
1437            other => panic!("expected IncompatiblePartition, got {other:?}"),
1438        }
1439    }
1440
1441    /// A field-metadata mismatch must say WHAT differs: the key and a
1442    /// (truncated) value diff, so cross-writer CRS/encoding differences are
1443    /// actionable instead of a bare "metadata differs".
1444    #[test]
1445    fn metadata_mismatch_detail_names_key_and_values() {
1446        let dir = tmpdir();
1447        for (file, val) in [("a.parquet", "value-one"), ("b.parquet", "value-two")] {
1448            let md: std::collections::HashMap<String, String> =
1449                [("ARROW:extension:name".to_string(), val.to_string())].into();
1450            write_parquet(
1451                &dir.path().join(file),
1452                vec![Field::new("id", DataType::Int64, false).with_metadata(md)],
1453                vec![Arc::new(Int64Array::from(vec![1i64]))],
1454                None,
1455            );
1456        }
1457        let err = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap_err();
1458        match err {
1459            InputError::IncompatiblePartition { detail, .. } => {
1460                assert!(
1461                    detail.contains("ARROW:extension:name"),
1462                    "detail must name the differing key: {detail}"
1463                );
1464                assert!(
1465                    detail.contains("value-one") && detail.contains("value-two"),
1466                    "detail must show both values: {detail}"
1467                );
1468            }
1469            other => panic!("expected IncompatiblePartition, got {other:?}"),
1470        }
1471    }
1472
1473    #[test]
1474    fn nullability_difference_unions_to_nullable() {
1475        let dir = tmpdir();
1476        write_standard(&dir.path().join("a.parquet"), vec![1], false); // id non-null
1477        write_standard(&dir.path().join("b.parquet"), vec![2], true); // id nullable
1478        let src = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap();
1479        let schema = src.schema().unwrap();
1480        let id = schema.field_with_name("id").unwrap();
1481        assert!(
1482            id.is_nullable(),
1483            "any-nullable must union to nullable: {schema:?}"
1484        );
1485    }
1486
1487    // --- streaming ------------------------------------------------------------
1488
1489    /// Row `id`s seen across the whole stream, in order.
1490    fn stream_ids(src: &ConvertSource, plan: &ReadPlan<'_>) -> Vec<i64> {
1491        let mut out = Vec::new();
1492        for batch in src.open_stream(plan).unwrap() {
1493            let batch = batch.unwrap();
1494            let ids = batch
1495                .column(batch.schema().index_of("id").unwrap())
1496                .as_any()
1497                .downcast_ref::<Int64Array>()
1498                .unwrap()
1499                .clone();
1500            out.extend(ids.values().iter().copied());
1501        }
1502        out
1503    }
1504
1505    #[test]
1506    fn stream_concatenates_parts_in_order() {
1507        let dir = tmpdir();
1508        write_standard(&dir.path().join("p0.parquet"), vec![0, 1, 2], false);
1509        write_standard(&dir.path().join("p1.parquet"), vec![3, 4], false);
1510        write_standard(&dir.path().join("p2.parquet"), vec![5], false);
1511        let src = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap();
1512        let plan = ReadPlan {
1513            batch_size: 2,
1514            projection: None,
1515            row_groups: None,
1516        };
1517        assert_eq!(stream_ids(&src, &plan), vec![0, 1, 2, 3, 4, 5]);
1518    }
1519
1520    #[test]
1521    fn stream_skips_zero_row_partition() {
1522        let dir = tmpdir();
1523        write_standard(&dir.path().join("p0.parquet"), vec![0, 1], false);
1524        write_standard(&dir.path().join("p1.parquet"), vec![], false); // 0 rows
1525        write_standard(&dir.path().join("p2.parquet"), vec![2, 3], false);
1526        let src = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap();
1527        let plan = ReadPlan {
1528            batch_size: 1024,
1529            projection: None,
1530            row_groups: None,
1531        };
1532        assert_eq!(stream_ids(&src, &plan), vec![0, 1, 2, 3]);
1533    }
1534
1535    #[test]
1536    fn stream_honors_per_part_row_group_selection() {
1537        let dir = tmpdir();
1538        // Two row groups per part (max_row_group_size = 2, 4 rows each).
1539        let f0 = dir.path().join("p0.parquet");
1540        let f1 = dir.path().join("p1.parquet");
1541        for (f, base) in [(&f0, 0i64), (&f1, 10i64)] {
1542            write_parquet(
1543                f,
1544                vec![Field::new("id", DataType::Int64, false)],
1545                vec![Arc::new(Int64Array::from(vec![
1546                    base,
1547                    base + 1,
1548                    base + 2,
1549                    base + 3,
1550                ]))],
1551                Some(2),
1552            );
1553        }
1554        let src = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap();
1555        assert_eq!(src.num_row_groups_total().unwrap(), 4);
1556
1557        // Part 0: skip entirely (empty selection); part 1: second group only.
1558        let sel = RowGroupSelection::from_parts(vec![vec![], vec![1]]);
1559        assert_eq!(sel.total_selected(), 1);
1560        let plan = ReadPlan {
1561            batch_size: 1024,
1562            projection: None,
1563            row_groups: Some(&sel),
1564        };
1565        assert_eq!(stream_ids(&src, &plan), vec![12, 13]);
1566
1567        // selected_input_bytes = the one selected group's compressed size;
1568        // None means every row group of every part.
1569        let bytes = src.selected_input_bytes(Some(&sel)).unwrap();
1570        assert!(bytes > 0);
1571        let all = RowGroupSelection::from_parts(vec![vec![0, 1], vec![0, 1]]);
1572        let all_bytes = src.selected_input_bytes(Some(&all)).unwrap();
1573        assert!(all_bytes > bytes);
1574        assert_eq!(src.selected_input_bytes(None).unwrap(), all_bytes);
1575    }
1576
1577    #[test]
1578    fn stream_projection_applies_to_every_part() {
1579        let dir = tmpdir();
1580        write_standard(&dir.path().join("p0.parquet"), vec![0], false);
1581        write_standard(&dir.path().join("p1.parquet"), vec![1], false);
1582        let src = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap();
1583        let cols = [0usize]; // id only
1584        let plan = ReadPlan {
1585            batch_size: 8,
1586            projection: Some(&cols),
1587            row_groups: None,
1588        };
1589        for batch in src.open_stream(&plan).unwrap() {
1590            let batch = batch.unwrap();
1591            assert_eq!(batch.num_columns(), 1);
1592            assert_eq!(batch.schema().field(0).name(), "id");
1593        }
1594    }
1595
1596    // --- column restriction (#386) -------------------------------------------
1597
1598    /// Three-column fixture: `id`, `name`, `extra`.
1599    fn write_three(path: &Path, ids: Vec<i64>) {
1600        let n = ids.len();
1601        write_parquet(
1602            path,
1603            vec![
1604                Field::new("id", DataType::Int64, false),
1605                Field::new("name", DataType::Utf8, true),
1606                Field::new("extra", DataType::Int64, true),
1607            ],
1608            vec![
1609                Arc::new(Int64Array::from(ids)),
1610                Arc::new(StringArray::from(
1611                    (0..n).map(|i| format!("r{i}")).collect::<Vec<_>>(),
1612                )),
1613                Arc::new(Int64Array::from((0..n as i64).collect::<Vec<_>>())),
1614            ],
1615            None,
1616        );
1617    }
1618
1619    #[test]
1620    fn restrict_columns_narrows_schema_and_every_read() {
1621        for multi in [false, true] {
1622            let dir = tmpdir();
1623            write_three(&dir.path().join("p0.parquet"), vec![0, 1]);
1624            if multi {
1625                write_three(&dir.path().join("p1.parquet"), vec![2]);
1626            }
1627            let input = if multi {
1628                dir.path().to_path_buf()
1629            } else {
1630                dir.path().join("p0.parquet")
1631            };
1632            let src = ConvertSource::resolve(input.to_str().unwrap()).unwrap();
1633            assert_eq!(src.schema().unwrap().fields().len(), 3);
1634
1635            // Keep id + extra (drop the middle column, so indices shift).
1636            src.restrict_columns(vec![0, 2]).unwrap();
1637            let names: Vec<String> = src
1638                .schema()
1639                .unwrap()
1640                .fields()
1641                .iter()
1642                .map(|f| f.name().clone())
1643                .collect();
1644            assert_eq!(names, vec!["id", "extra"], "multi={multi}");
1645            assert_eq!(src.file_schema().unwrap().fields().len(), 3);
1646
1647            // A full read returns only the kept columns.
1648            let plan = ReadPlan {
1649                batch_size: 8,
1650                projection: None,
1651                row_groups: None,
1652            };
1653            let mut rows = 0;
1654            for batch in src.open_stream(&plan).unwrap() {
1655                let batch = batch.unwrap();
1656                assert_eq!(batch.num_columns(), 2);
1657                assert_eq!(batch.schema().field(1).name(), "extra");
1658                rows += batch.num_rows();
1659            }
1660            assert_eq!(rows, if multi { 3 } else { 2 });
1661
1662            // A plan projection indexes the restricted schema: 1 = extra.
1663            let cols = [1usize];
1664            let plan = ReadPlan {
1665                batch_size: 8,
1666                projection: Some(&cols),
1667                row_groups: None,
1668            };
1669            for batch in src.open_stream(&plan).unwrap() {
1670                let batch = batch.unwrap();
1671                assert_eq!(batch.num_columns(), 1);
1672                assert_eq!(batch.schema().field(0).name(), "extra");
1673            }
1674
1675            // Applying twice, or an unsorted / out-of-range set, is refused.
1676            assert!(src.restrict_columns(vec![0]).is_err());
1677            let fresh = ConvertSource::resolve(input.to_str().unwrap()).unwrap();
1678            assert!(fresh.restrict_columns(vec![2, 0]).is_err());
1679            assert!(fresh.restrict_columns(vec![0, 7]).is_err());
1680        }
1681    }
1682
1683    // --- Send (the pipeline moves streams into reader threads) ---------------
1684
1685    #[test]
1686    fn source_stream_is_send() {
1687        fn assert_send<T: Send>() {}
1688        assert_send::<SourceStream<'static>>();
1689        // Scoped reader threads capture &ConvertSource.
1690        fn assert_sync<T: Sync>() {}
1691        assert_sync::<ConvertSource>();
1692    }
1693
1694    // --- --files-from manifest (v0.7 PR-B) ------------------------------------
1695
1696    /// Comments, blank lines, and surrounding whitespace are skipped;
1697    /// entry order is preserved VERBATIM (never sorted) — the row-order
1698    /// invariant keys winner tables by global row offset.
1699    #[test]
1700    fn manifest_entries_skip_comments_and_preserve_order() {
1701        let text = "\
1702# heading comment
1703b.parquet
1704
1705  # indented comment
1706  a.parquet  \n\nz/c.parquet\n";
1707        let entries = manifest_entries(text);
1708        assert_eq!(
1709            entries,
1710            vec![(2, "b.parquet"), (5, "a.parquet"), (7, "z/c.parquet")],
1711            "order verbatim (b before a), 1-based line numbers"
1712        );
1713    }
1714
1715    #[test]
1716    fn manifest_multi_source_preserves_line_order() {
1717        let dir = tmpdir();
1718        // Deliberately list b before a: order must be kept, not sorted.
1719        write_standard(&dir.path().join("b.parquet"), vec![0, 1], false);
1720        write_standard(&dir.path().join("a.parquet"), vec![2, 3], false);
1721        let manifest = dir.path().join("parts.txt");
1722        std::fs::write(
1723            &manifest,
1724            format!(
1725                "# two partitions, b first\n{}\n\n{}\n",
1726                dir.path().join("b.parquet").display(),
1727                dir.path().join("a.parquet").display()
1728            ),
1729        )
1730        .unwrap();
1731        let src = ConvertSource::from_manifest(&manifest).unwrap();
1732        assert!(matches!(src, ConvertSource::Multi(_)));
1733        let plan = ReadPlan {
1734            batch_size: 1024,
1735            projection: None,
1736            row_groups: None,
1737        };
1738        assert_eq!(stream_ids(&src, &plan), vec![0, 1, 2, 3]);
1739    }
1740
1741    /// A single-entry manifest collapses to `Single`; a `file://` URL line
1742    /// proves the URL classification path runs per line (no network).
1743    #[test]
1744    fn manifest_single_entry_and_file_url() {
1745        let dir = tmpdir();
1746        let f = dir.path().join("only.parquet");
1747        write_standard(&f, vec![7], false);
1748        let manifest = dir.path().join("one.txt");
1749        std::fs::write(&manifest, format!("file://{}\n", f.display())).unwrap();
1750        let src = ConvertSource::from_manifest(&manifest).unwrap();
1751        assert!(matches!(src, ConvertSource::Single(_)));
1752        let plan = ReadPlan {
1753            batch_size: 8,
1754            projection: None,
1755            row_groups: None,
1756        };
1757        assert_eq!(stream_ids(&src, &plan), vec![7]);
1758    }
1759
1760    /// A manifest line naming a missing local file errors, naming BOTH the
1761    /// line number and the offending path. Lines are single files only —
1762    /// a directory line is rejected too (no recursion).
1763    #[test]
1764    fn manifest_missing_file_names_line_and_path() {
1765        let dir = tmpdir();
1766        write_standard(&dir.path().join("ok.parquet"), vec![1], false);
1767        let manifest = dir.path().join("bad.txt");
1768        std::fs::write(
1769            &manifest,
1770            format!(
1771                "{}\n# comment\n{}\n",
1772                dir.path().join("ok.parquet").display(),
1773                dir.path().join("nope.parquet").display()
1774            ),
1775        )
1776        .unwrap();
1777        let err = ConvertSource::from_manifest(&manifest).unwrap_err();
1778        let msg = err.to_string();
1779        assert!(msg.contains("line 3"), "names the manifest line: {msg}");
1780        assert!(msg.contains("nope.parquet"), "names the path: {msg}");
1781
1782        // A directory entry is not a file: same error (no recursion).
1783        let manifest2 = dir.path().join("dir.txt");
1784        std::fs::write(&manifest2, format!("{}\n", dir.path().display())).unwrap();
1785        let err = ConvertSource::from_manifest(&manifest2).unwrap_err();
1786        assert!(
1787            matches!(err, InputError::MissingListedInput { .. }),
1788            "directory lines are rejected: {err:?}"
1789        );
1790    }
1791
1792    #[test]
1793    fn manifest_empty_or_unreadable_is_a_clear_error() {
1794        let dir = tmpdir();
1795        let empty = dir.path().join("empty.txt");
1796        std::fs::write(&empty, "# only comments\n\n").unwrap();
1797        let err = ConvertSource::from_manifest(&empty).unwrap_err();
1798        assert!(matches!(err, InputError::NoParquetInputs { .. }), "{err:?}");
1799
1800        let err = ConvertSource::from_manifest(&dir.path().join("missing.txt")).unwrap_err();
1801        let msg = err.to_string();
1802        assert!(msg.contains("missing.txt"), "names the manifest: {msg}");
1803    }
1804
1805    /// The Python list-input path: explicit entries, order verbatim, each a
1806    /// single file (no recursion), missing entries named by position.
1807    #[test]
1808    fn input_list_preserves_order_and_validates() {
1809        let dir = tmpdir();
1810        write_standard(&dir.path().join("b.parquet"), vec![0], false);
1811        write_standard(&dir.path().join("a.parquet"), vec![1], false);
1812        let list = [
1813            dir.path().join("b.parquet").display().to_string(),
1814            dir.path().join("a.parquet").display().to_string(),
1815        ];
1816        let src = ConvertSource::from_input_list(&list).unwrap();
1817        let plan = ReadPlan {
1818            batch_size: 8,
1819            projection: None,
1820            row_groups: None,
1821        };
1822        assert_eq!(stream_ids(&src, &plan), vec![0, 1]);
1823
1824        let bad = [dir.path().join("nope.parquet").display().to_string()];
1825        let err = ConvertSource::from_input_list(&bad).unwrap_err();
1826        let msg = err.to_string();
1827        assert!(msg.contains("entry 1"), "names the entry: {msg}");
1828        assert!(msg.contains("nope.parquet"), "names the path: {msg}");
1829
1830        let none: [String; 0] = [];
1831        let err = ConvertSource::from_input_list(&none).unwrap_err();
1832        assert!(matches!(err, InputError::NoParquetInputs { .. }), "{err:?}");
1833    }
1834
1835    // --- remote prefix listing (v0.7 PR-B) ------------------------------------
1836
1837    #[cfg(feature = "remote")]
1838    mod remote_listing {
1839        use super::*;
1840        use crate::input::remote::{list_parquet_under_prefix, RemoteSource};
1841        use object_store::memory::InMemory;
1842        use object_store::path::Path as ObjectPath;
1843        use object_store::{ObjectStore, ObjectStoreExt};
1844
1845        /// Seed one InMemory store with `objects` (key → bytes).
1846        fn seeded_store(objects: &[(&str, Vec<u8>)]) -> Arc<InMemory> {
1847            let store = Arc::new(InMemory::new());
1848            let rt = tokio::runtime::Builder::new_current_thread()
1849                .enable_all()
1850                .build()
1851                .unwrap();
1852            for (key, bytes) in objects {
1853                rt.block_on(store.put(&ObjectPath::from(*key), bytes.clone().into()))
1854                    .unwrap();
1855            }
1856            store
1857        }
1858
1859        fn parquet_bytes(ids: Vec<i64>) -> Vec<u8> {
1860            let batch = RecordBatch::try_new(
1861                Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
1862                vec![Arc::new(Int64Array::from(ids))],
1863            )
1864            .unwrap();
1865            let mut buf = Vec::new();
1866            let mut w = ArrowWriter::try_new(&mut buf, batch.schema(), None).unwrap();
1867            w.write(&batch).unwrap();
1868            w.close().unwrap();
1869            buf
1870        }
1871
1872        /// The listing finds exactly the `.parquet` keys, sorted by key,
1873        /// skipping `_SUCCESS`, zero-byte objects, hidden (`.`/`_`) names —
1874        /// including hidden "directory" components below the prefix — and
1875        /// keys outside the prefix.
1876        #[test]
1877        fn listing_filters_and_sorts() {
1878            let store = seeded_store(&[
1879                ("set/b.parquet", parquet_bytes(vec![1])),
1880                ("set/a.parquet", parquet_bytes(vec![2])),
1881                ("set/nested/c.parquet", parquet_bytes(vec![3])),
1882                ("set/_SUCCESS", vec![]),
1883                ("set/_delta_log/d.parquet", parquet_bytes(vec![4])),
1884                ("set/.hidden.parquet", parquet_bytes(vec![5])),
1885                ("set/zero.parquet", vec![]),
1886                ("set/readme.txt", b"hi".to_vec()),
1887                ("other/x.parquet", parquet_bytes(vec![6])),
1888            ]);
1889            let store: Arc<dyn ObjectStore> = store;
1890            let listed =
1891                list_parquet_under_prefix(&store, &ObjectPath::from("set"), "memory://set/")
1892                    .unwrap();
1893            let keys: Vec<String> = listed
1894                .iter()
1895                .map(|p| p.location.as_ref().to_string())
1896                .collect();
1897            assert_eq!(
1898                keys,
1899                vec!["set/a.parquet", "set/b.parquet", "set/nested/c.parquet"],
1900                "exactly the visible .parquet keys, sorted"
1901            );
1902            assert!(listed.iter().all(|p| p.size > 0), "sizes from the listing");
1903        }
1904
1905        /// Listed parts stream in key order through a ConvertSource, all
1906        /// sharing ONE store instance.
1907        #[test]
1908        fn listed_parts_stream_in_order() {
1909            let store = seeded_store(&[
1910                ("set/p1.parquet", parquet_bytes(vec![10, 11])),
1911                ("set/p0.parquet", parquet_bytes(vec![0, 1])),
1912            ]);
1913            let store: Arc<dyn ObjectStore> = store;
1914            let listed =
1915                list_parquet_under_prefix(&store, &ObjectPath::from("set"), "memory://set/")
1916                    .unwrap();
1917            let parts: Vec<InputSource> = listed
1918                .into_iter()
1919                .map(|p| {
1920                    InputSource::Remote(RemoteSource::from_store_sized(
1921                        Arc::clone(&store),
1922                        p.location.clone(),
1923                        format!("memory://{}", p.location),
1924                        p.size,
1925                    ))
1926                })
1927                .collect();
1928            let src = ConvertSource::Multi(
1929                MultiSource::from_sources("memory://set/".to_string(), parts).unwrap(),
1930            );
1931            let plan = ReadPlan {
1932                batch_size: 1024,
1933                projection: None,
1934                row_groups: None,
1935            };
1936            assert_eq!(stream_ids(&src, &plan), vec![0, 1, 10, 11]);
1937            let stats = src.fetch_stats().expect("remote parts have stats");
1938            assert!(stats.object_size > 0, "object_size sums the parts");
1939        }
1940
1941        /// PR-C: explicit-list (manifest / input-list) entries resolve under
1942        /// bounded parallelism, but the resulting parts MUST preserve entry
1943        /// order VERBATIM (the row-order invariant) — never completion or
1944        /// lexicographic order.
1945        #[test]
1946        fn explicit_list_parallel_connect_preserves_order() {
1947            use std::sync::atomic::{AtomicBool, Ordering};
1948            use std::sync::{Condvar, Mutex as StdMutex};
1949
1950            let store = seeded_store(&[
1951                ("m/a.parquet", parquet_bytes(vec![10])),
1952                ("m/b.parquet", parquet_bytes(vec![0, 1])),
1953                ("m/c.parquet", parquet_bytes(vec![20])),
1954            ]);
1955            let store: Arc<dyn ObjectStore> = store;
1956
1957            // Deliberately NOT lexicographic: c, a, b.
1958            let entries: Vec<(String, String)> = ["m/c.parquet", "m/a.parquet", "m/b.parquet"]
1959                .into_iter()
1960                .enumerate()
1961                .map(|(i, key)| (format!("entry {}", i + 1), key.to_string()))
1962                .collect();
1963
1964            // Overlap detector: the first resolver call blocks until a
1965            // second one is in flight (or a generous timeout passes), so a
1966            // serial implementation fails the `overlapped` assertion while
1967            // a parallel one passes deterministically.
1968            let in_flight = StdMutex::new(0usize);
1969            let cv = Condvar::new();
1970            let overlapped = AtomicBool::new(false);
1971
1972            let resolved = resolve_list_entries(&entries, |_context, entry| {
1973                {
1974                    let mut n = in_flight.lock().unwrap();
1975                    *n += 1;
1976                    if *n >= 2 {
1977                        overlapped.store(true, Ordering::SeqCst);
1978                        cv.notify_all();
1979                    } else {
1980                        let (guard, _) = cv
1981                            .wait_timeout_while(n, std::time::Duration::from_secs(10), |_| {
1982                                !overlapped.load(Ordering::SeqCst)
1983                            })
1984                            .unwrap();
1985                        drop(guard);
1986                    }
1987                }
1988                Ok(InputSource::Remote(RemoteSource::from_store(
1989                    Arc::clone(&store),
1990                    ObjectPath::from(entry),
1991                    format!("memory://{entry}"),
1992                )?))
1993            })
1994            .unwrap();
1995            assert!(
1996                overlapped.load(Ordering::SeqCst),
1997                "entry connects must overlap (bounded parallelism), not run serially"
1998            );
1999
2000            let src = ConvertSource::from_parts("list", resolved).unwrap();
2001            let plan = ReadPlan {
2002                batch_size: 1024,
2003                projection: None,
2004                row_groups: None,
2005            };
2006            assert_eq!(
2007                stream_ids(&src, &plan),
2008                vec![20, 10, 0, 1],
2009                "parts follow entry order verbatim"
2010            );
2011        }
2012
2013        /// Parallel completion order must not change WHICH error surfaces:
2014        /// the first failing entry by INDEX wins.
2015        #[test]
2016        fn explicit_list_first_error_by_index_wins() {
2017            let entries: Vec<(String, String)> = (1..=4)
2018                .map(|i| (format!("entry {i}"), format!("e{i}")))
2019                .collect();
2020            let err = resolve_list_entries(&entries, |context, entry| {
2021                if entry == "e2" || entry == "e4" {
2022                    return Err(InputError::MissingListedInput {
2023                        context: context.to_string(),
2024                        input: entry.to_string(),
2025                    });
2026                }
2027                Ok(InputSource::Local(PathBuf::from(entry)))
2028            })
2029            .unwrap_err();
2030            let msg = err.to_string();
2031            assert!(
2032                msg.contains("entry 2"),
2033                "first failing entry by index wins: {msg}"
2034            );
2035        }
2036    }
2037
2038    // --- layer-name derivation (v0.7 PR-C) ------------------------------------
2039
2040    /// Single files (local or nonexistent-yet paths) keep the historical
2041    /// behavior: the file stem, `"layer"` when there is none.
2042    #[test]
2043    fn layer_name_single_file_is_stem() {
2044        assert_eq!(derive_layer_name("/data/buildings.parquet"), "buildings");
2045        assert_eq!(derive_layer_name("buildings.parquet"), "buildings");
2046        // A --files-from manifest path takes the manifest file's stem.
2047        assert_eq!(
2048            derive_layer_name("/tmp/portland-roads.txt"),
2049            "portland-roads"
2050        );
2051    }
2052
2053    /// Directory inputs use the directory's last path segment VERBATIM
2054    /// (no extension stripping — a dotted directory name is a name, not
2055    /// a file extension), trailing slash tolerated.
2056    #[test]
2057    fn layer_name_directory_is_last_segment() {
2058        let dir = tmpdir();
2059        let parts = dir.path().join("nyc_buildings");
2060        std::fs::create_dir(&parts).unwrap();
2061        assert_eq!(derive_layer_name(parts.to_str().unwrap()), "nyc_buildings");
2062
2063        let dotted = dir.path().join("buildings.v2");
2064        std::fs::create_dir(&dotted).unwrap();
2065        assert_eq!(
2066            derive_layer_name(&format!("{}/", dotted.display())),
2067            "buildings.v2"
2068        );
2069    }
2070
2071    /// Glob inputs use the deepest literal (wildcard-free) path segment
2072    /// before the first wildcard segment; an all-wildcard pattern falls
2073    /// back to `"layer"`.
2074    #[test]
2075    fn layer_name_glob_uses_last_literal_segment() {
2076        assert_eq!(derive_layer_name("/data/parts/*.parquet"), "parts");
2077        assert_eq!(derive_layer_name("/data/part-*.parquet"), "data");
2078        assert_eq!(derive_layer_name("/data/**/part-?.parquet"), "data");
2079        assert_eq!(derive_layer_name("*.parquet"), "layer");
2080    }
2081
2082    /// `s3://`/`gs://` prefixes use the last non-empty path segment of the
2083    /// prefix (query string and fragment stripped); a bucket-root prefix
2084    /// uses the bucket name.
2085    #[test]
2086    fn layer_name_remote_prefix_uses_last_segment() {
2087        assert_eq!(derive_layer_name("s3://bucket/datasets/roads/"), "roads");
2088        assert_eq!(derive_layer_name("gs://bucket/roads/"), "roads");
2089        assert_eq!(derive_layer_name("s3://bucket/roads/?list-type=2"), "roads");
2090        assert_eq!(derive_layer_name("s3://bucket/"), "bucket");
2091    }
2092
2093    /// Remote single objects behave like local single files: the object
2094    /// key's stem, with query string / fragment stripped first.
2095    #[test]
2096    fn layer_name_remote_object_is_stem() {
2097        assert_eq!(derive_layer_name("s3://bucket/data/roads.parquet"), "roads");
2098        assert_eq!(
2099            derive_layer_name("https://host/dl/roads.parquet?sig=abc"),
2100            "roads"
2101        );
2102        // Extension-less presigned/API URL: last segment verbatim.
2103        assert_eq!(derive_layer_name("https://host/api/download/42"), "42");
2104    }
2105
2106    /// Degenerate inputs sanitize to the historical `"layer"` fallback.
2107    #[test]
2108    fn layer_name_degenerate_falls_back() {
2109        assert_eq!(derive_layer_name(""), "layer");
2110        assert_eq!(derive_layer_name("s3://"), "layer");
2111        assert_eq!(derive_layer_name("/"), "layer");
2112    }
2113}