Skip to main content

datui_lib/loading/
open_scan.rs

1//! Opening a dataset: routing what was named, downloads, the scan and schema read
2//! for each kind of source, the load's phases as jobs, and installing the result.
3
4use crate::app::feedback::Confirm;
5use crate::app::jobs::{Answer, Job};
6use crate::app::modals::pivot_melt_modal::PivotMeltModal;
7use crate::app::modals::sort_filter_modal::SortFilterModal;
8use crate::cli::{CompressionFormat, FileFormat};
9#[cfg(feature = "cloud")]
10use crate::cloud::cloud_hive;
11use crate::loading::open_options::{OpenOptions, ReadReport, UnaskedDownload};
12use crate::loading::scan::Scan;
13use crate::table::{DataTableState, OpenFacts};
14#[cfg(feature = "cloud")]
15use crate::wait_on_runtime;
16use crate::{
17    App, AppEvent, UNSUPPORTED, cli, cloud::source, formats::dataset_files, home, home::catalog,
18    home::discover, loading,
19};
20use color_eyre::Result;
21#[cfg(feature = "cloud")]
22use polars::io::cloud::{AmazonS3ConfigKey, CloudOptions};
23use polars::prelude::{LazyFrame, Schema, col};
24#[cfg(feature = "cloud")]
25use polars::prelude::{PlRefPath, ScanArgsParquet};
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex};
28
29/// Where the dataset on screen came from, and how it was opened.
30#[derive(Default)]
31pub struct OpenedSource {
32    pub(crate) original_file_format: Option<crate::export::export_modal::ExportFormat>,
33    pub(crate) original_file_delimiter: Option<u8>,
34    /// The paths and options the dataset on screen was opened with: what `H` reopens.
35    pub(crate) opened: Option<(Vec<PathBuf>, OpenOptions)>,
36    /// Whether the dataset was reached through home: `q` returns there, else quits.
37    pub(crate) opened_from_home: bool,
38    /// `--view NAME`, taken by the first install so later opens are not re-dressed.
39    pub(crate) startup_view: Option<String>,
40    /// The dataset whose downloaded shape is kept already. See
41    /// [`crate::App::remember_a_downloads_shape`].
42    pub(crate) shape_remembered: Option<u64>,
43}
44
45impl OpenedSource {
46    /// A new dataset is on screen, opened from `opened` (none for a frame handed over)
47    /// and read as `format`, with `delimiter`: what `H` reopens and an export starts
48    /// from. Reached through home, `q` goes back there; a reread (`H`) never unsets it,
49    /// since it is not a new place.
50    pub(crate) fn reset_for_dataset(
51        &mut self,
52        opened: Option<(Vec<PathBuf>, OpenOptions)>,
53        from_home: bool,
54        format: Option<crate::export::export_modal::ExportFormat>,
55        delimiter: Option<u8>,
56    ) {
57        self.opened = opened;
58        self.opened_from_home |= from_home;
59        self.original_file_format = format;
60        self.original_file_delimiter = delimiter;
61    }
62}
63
64/// What an object in a store named with no format turned out to be.
65#[cfg(feature = "cloud")]
66enum Unnamed {
67    /// No such object: a prefix named without its slash.
68    Missing,
69    Parquet,
70    /// Something else, `size` bytes, downloaded.
71    Other {
72        size: u64,
73    },
74}
75
76/// What a cloud open was pointed at: the URL as given, the prefix to list, and the
77/// glob to keep.
78#[cfg(feature = "cloud")]
79struct CloudTarget<'a> {
80    /// The URL as typed, which is where the bucket and scheme come from.
81    full: &'a str,
82    /// The literal prefix to list: the whole key, or the part of a glob before its star.
83    key: String,
84    /// The glob the user named, if any. The listing keeps only matching keys, so
85    /// downstream sees plain files.
86    pattern: Option<&'a globset::GlobMatcher>,
87}
88
89/// Put hive partition columns first. `drifts` keeps the scan's hidden drift column,
90/// which the select would drop. Free so the table's state can reuse it when
91/// rebuilding a scan.
92pub(crate) fn hoist_partition_columns(
93    lf: LazyFrame,
94    schema: &Schema,
95    partition_columns: &[String],
96    drifts: bool,
97) -> LazyFrame {
98    if partition_columns.is_empty() {
99        return lf;
100    }
101    let mut exprs: Vec<_> = partition_columns
102        .iter()
103        .map(|s| col(s.as_str()))
104        .chain(
105            schema
106                .iter_names()
107                .map(|s| s.to_string())
108                .filter(|c| !partition_columns.contains(c))
109                .map(|s| col(s.as_str())),
110        )
111        .collect();
112    if drifts {
113        exprs.push(col(crate::formats::schema_union::DRIFT_COLUMN));
114    }
115    lf.select(exprs)
116}
117
118impl App {
119    /// Whether an open is on its way and not installed: `data_table_state` still holds
120    /// the outgoing dataset, so the main view shows the open's progress.
121    pub(crate) fn awaiting_dataset(&self) -> bool {
122        self.loading.awaiting_dataset()
123    }
124
125    /// The open's phase, percentage, path and size, for the loading screen and footer.
126    pub(crate) fn load_shown(&self) -> Option<(&str, u16, Option<&Path>, u64)> {
127        self.loading.current().map(|load| {
128            let (phase, percent) = load.phase().label();
129            (phase, percent, load.path(), load.size())
130        })
131    }
132
133    /// What the load is doing. While a footer pass runs its count stands in for the
134    /// phase, read once a frame from [`crate::loading::counting::Counting::footers_this_frame`] so callers agree.
135    pub(crate) fn loading_phase<'a>(&self, phase: &'a str) -> std::borrow::Cow<'a, str> {
136        match self.counting.footers_this_frame {
137            Some((read, total)) => std::borrow::Cow::Owned(format!(
138                "Reading footers: {} of {}",
139                crate::numfmt::group_chrome(read),
140                crate::numfmt::group_chrome(total)
141            )),
142            // A listing has no total to count towards, so it says how far it has got.
143            None => match self.counting.listed_this_frame {
144                Some(listed) => std::borrow::Cow::Owned(format!(
145                    "Listing files: {}",
146                    crate::numfmt::group_chrome(listed)
147                )),
148                None => std::borrow::Cow::Borrowed(phase),
149            },
150        }
151    }
152
153    /// An open is on its way: the loading screen shows `phase` and keys wait. Called
154    /// before the event that carries the open out (by `run`, or a key before its
155    /// `Open`), since a frame drawn between would show the outgoing dataset.
156    pub fn set_loading_phase(&mut self, phase: impl Into<String>, progress_percent: u16) {
157        self.announce_open(false, phase.into(), progress_percent);
158    }
159
160    /// As [`Self::set_loading_phase`]; `from_home` reports a failure on home.
161    pub(crate) fn announce_open(&mut self, from_home: bool, phase: String, percent: u16) {
162        self.put_down_load_in_flight();
163        self.loading.announce(from_home, phase, percent);
164    }
165
166    /// Put the path on the loading screen, so a wait says what it is waiting for.
167    pub(crate) fn name_what_is_loading(&mut self, path: PathBuf) {
168        self.loading.name(path);
169    }
170
171    /// Put down the load in flight unless it has started no work (the look or frame
172    /// leading to this open).
173    pub(crate) fn put_down_load_in_flight(&mut self) {
174        if let Some(retired) = self.loading.make_way() {
175            self.put_down_load(retired);
176        }
177    }
178
179    /// Start an open with its request: the load in flight goes, and so does the
180    /// screen's dataset's own reading.
181    pub(crate) fn begin_new_dataset(&mut self) {
182        self.put_down_load_in_flight();
183        // A preview's dataset this open did not take is unwanted.
184        self.home_app.previews.drop_prepared();
185        self.reset_chart_state();
186        self.jobs.advance();
187        // The open counts footers on its own counter, which the dataset takes over on
188        // install.
189        self.counting.stop_footer_pass();
190    }
191
192    /// Put down what the app keeps for a retired load: its jobs, their footer lines,
193    /// and the question about its download.
194    pub(crate) fn put_down_load(&mut self, retired: loading::Retired) {
195        let id = retired.id;
196        let lines = self.jobs.quiet(|job| job.load() == Some(id));
197        self.jobs.supersede(|job| job.load() == Some(id));
198        if self
199            .status_message
200            .as_ref()
201            .is_some_and(|status| lines.contains(status))
202        {
203            self.status_message = None;
204        }
205        if retired.asking {
206            self.confirmation_modal.hide();
207        }
208    }
209
210    /// Carry out what the open needs next.
211    pub(crate) fn run_load_step(&mut self, step: loading::Step) -> Option<AppEvent> {
212        use loading::Step;
213        let load = self.loading.id();
214        match step {
215            Step::Nothing => None,
216            Step::Crash(message) => Some(AppEvent::Crash(message)),
217            Step::Failed(failed) => {
218                self.load_failed(failed);
219                None
220            }
221            Step::Tables(tables) => {
222                self.land_on_tables(tables);
223                None
224            }
225            Step::Hex(hex) => {
226                self.land_on_hex(hex);
227                None
228            }
229            Step::Install(loaded) => {
230                // A view the open applies reads its own first rows.
231                if self.install_dataset(*loaded) {
232                    return None;
233                }
234                #[cfg(test)]
235                {
236                    self.counting.first_rows_asked += 1;
237                }
238                if !self.spawn_async_collect(Self::LOADING_BUFFER) {
239                    // Nothing to read: the buffer already serves the view.
240                    if self.status_message.as_deref() == Some(Self::LOADING_BUFFER) {
241                        self.status_message = None;
242                    }
243                    self.first_rows_settled();
244                }
245                None
246            }
247            #[cfg(any(feature = "http", feature = "cloud"))]
248            Step::Ask(pending) => {
249                // Nothing runs while the question is up (a spinner would read as progress); the
250                // loader holds the generation.
251                self.confirmation_modal.show(
252                    Self::download_confirmation_message(&pending, self.loading.download_note()),
253                    Confirm::Download,
254                );
255                None
256            }
257            Step::AskRead(read) => {
258                // As for a download: nothing runs, and the generation is held.
259                self.loading.hold_while_asking(self.jobs.hold());
260                self.confirmation_modal.show(
261                    Self::in_memory_confirmation_message(&read),
262                    Confirm::Download,
263                );
264                None
265            }
266            step => {
267                let load = load.expect("a step that runs work belongs to the open in flight");
268                self.spawn_load_phase(load, step);
269                None
270            }
271        }
272    }
273
274    /// Keep a downloaded dataset's shape under its URL once counted: nothing lists a
275    /// web file, so this is how its recent and catalog row show `344 × 9`. Once per
276    /// dataset.
277    pub(crate) fn remember_a_downloads_shape(&mut self) {
278        if self.source.shape_remembered == Some(self.dataset_generation) {
279            return;
280        }
281        let Some(url) = self.path.clone().filter(|p| source::is_remote_url(p)) else {
282            return;
283        };
284        let Some(state) = self.data_table_state.as_ref().filter(|s| s.fetched()) else {
285            return;
286        };
287        let Some(rows) = state.num_rows_if_valid().filter(|_| !state.changes_rows()) else {
288            return;
289        };
290        self.source.shape_remembered = Some(self.dataset_generation);
291        let columns: Vec<String> = state
292            .source_schema()
293            .iter_names()
294            .map(|name| name.to_string())
295            .collect();
296        let facts = crate::cache::DatasetFacts {
297            mtime: std::time::SystemTime::now()
298                .duration_since(std::time::UNIX_EPOCH)
299                .map(|d| d.as_secs())
300                .unwrap_or_default(),
301            size: 0,
302            rows: Some(rows),
303            cols: Some(columns.len()),
304            cols_sampled: false,
305            columns,
306            kind: Some(discover::EntryKind::File),
307            classified_by: discover::CLASSIFIER_VERSION,
308            cost: Default::default(),
309            holds: Default::default(),
310        };
311        // Off the UI thread: the index takes a lock other instances may hold.
312        let cache = self.cache.clone();
313        self.cache_writes
314            .spawn(move || cache.record_dataset_facts(&[(url, facts)]));
315    }
316
317    /// Install the dataset an open read and apply its view, if any. Returns whether
318    /// that view reads the first rows. Only the loader calls this, for the open in
319    /// flight.
320    pub(crate) fn install_dataset(&mut self, loaded: loading::Loaded) -> bool {
321        let loading::Loaded {
322            state,
323            path,
324            options,
325            debug_label,
326            paths,
327            recent,
328            from_home,
329            footers,
330        } = loaded;
331        let options = &options;
332        // One per dataset reaching the screen, not per open: a failed open leaves the last
333        // dataset up, and its footer pass must still finish into it.
334        self.dataset_generation = self.dataset_generation.wrapping_add(1);
335        // Each part of the app lets go of what it kept of the last dataset.
336        self.counting.reset_for_dataset(footers);
337        self.quality.reset_for_dataset();
338        self.analysis_modal.quality.reset_for_dataset();
339        self.prompt.reset_for_dataset();
340        self.sample.reset_for_dataset();
341        self.views.reset_for_dataset();
342        self.info
343            .reset_for_dataset(&self.app_config, path.as_deref());
344        self.sort_filter_modal = SortFilterModal::new();
345        self.pivot_melt_modal = PivotMeltModal::new();
346        // A view waiting on its pivot, a sample being drawn and a chart being prepared
347        // were over the replaced dataset.
348        self.jobs.supersede(|job| matches!(job, Job::ViewPivot(_)));
349        self.put_down_sample_draw();
350        self.reset_chart_state();
351        self.debug.schema_load = debug_label;
352        // A frame handed over has no path. The spec read is dropped: it holds the file's
353        // map, and a decompressed copy keeps its disk space while mapped.
354        let opened = paths.map(|paths| {
355            let options = OpenOptions {
356                format_read: None,
357                sqlite: None,
358                // Counted afresh by the next read.
359                tail: None,
360                prepared: None,
361                place: None,
362                ..options.clone()
363            };
364            (paths, options)
365        });
366        let (format, delimiter) = match path.as_deref() {
367            Some(p) => (
368                Self::export_format_for(p, state.read_as().or(options.format)),
369                // CSV's delimiter: a comma unless the user named one. A `.tsv` exports as
370                // TSV (tab preset); a tab in a `.csv` would reopen as one column.
371                Some(options.separator_or(b',')),
372            ),
373            None => (None, None),
374        };
375        self.source
376            .reset_for_dataset(opened, from_home, format, delimiter);
377        // Recorded only once installed: a file that fails to load is not worth returning to.
378        if let Some(path) = recent {
379            // Off the opening path: the cache lock may be held by other instances. Only the
380            // next home listing waits on it.
381            let cache = self.cache.clone();
382            self.cache_writes.spawn(move || {
383                cache.push_recent(&path);
384            });
385        }
386        self.forget_the_rows_read();
387        // The dataset this one replaces is let go, and the memory it held handed back.
388        if let Some(closed) = self.data_table_state.replace(state) {
389            crate::app::background::release(closed);
390        }
391        // A followed file's watcher starts with its dataset and stops with it.
392        if options.follow
393            && let Some(state) = self.data_table_state.as_mut()
394        {
395            match options.tail.as_deref() {
396                Some(tail) => {
397                    let follow = crate::loading::follow::Follow::start(
398                        tail.clone(),
399                        self.app_config.read.follow_interval.duration(),
400                        self.events.clone(),
401                        options.spool.clone(),
402                    );
403                    state.start_following(if options.pipe {
404                        follow.as_pipe()
405                    } else {
406                        follow
407                    });
408                    // Already counted by the scan.
409                    state.follow_to(tail.rows(), false);
410                }
411                // A recording of a format that cannot be read as it grows.
412                None => self.flash_note(
413                    "Only text and Arrow streams are followed: this shows what had arrived, and recording goes on"
414                        .to_string(),
415                ),
416            }
417        }
418        // A count waiting on the last dataset's paint is no longer owed.
419        self.retire_a_count_the_rows_answered();
420        self.path = path.clone();
421        // Named for this file, so after its path is set.
422        self.open_info_documentation();
423        // A panel still up says what it says about the dataset on screen.
424        if self.overlay.shows(&crate::Overlay::Info) {
425            self.read_file_facts();
426            self.count_unfit();
427        }
428        // What the dataset still has to learn about itself is read behind it.
429        self.start_pending_footers();
430        self.index_lines();
431        // `#` for text and logs, unless the flag or the config said.
432        if options.row_numbers_auto
433            && let Some(state) = self.data_table_state.as_mut()
434            && state.numbered_by_default()
435        {
436            state.set_row_numbers(true);
437        }
438        self.status_message = Some(Self::LOADING_BUFFER.to_string());
439
440        // A reopen puts back where the dataset was, rather than what matches it.
441        if let Some(place) = options.place.as_deref() {
442            let restored = self.restore_place(
443                place.settings.clone(),
444                place.active.clone(),
445                place.drill.clone(),
446            );
447            return match restored {
448                Ok(()) => true,
449                Err(e) => {
450                    let applying = crate::view::view_apply::Applying::Restored(None);
451                    self.view_failed(&applying, &e.to_string());
452                    false
453                }
454            };
455        }
456        // Where a view meets the dataset: `--view` names one for this first open only;
457        // `[views] auto_apply` dresses every open with a matching view. A fresh dataset
458        // starts with none applied, so the last file's view is not checked here.
459        let (view, reason) = match self.source.startup_view.take() {
460            Some(name) => match self.views.manager.get_view_by_name(&name).cloned() {
461                Some(view) => (Some(view), None),
462                None => {
463                    self.error_modal.show(format!("No view named \"{name}\""));
464                    (None, None)
465                }
466            },
467            None if self.app_config.views.auto_apply => self
468                .view_dataset()
469                .zip(self.data_table_state.as_ref())
470                .and_then(|(dataset, state)| {
471                    self.views
472                        .manager
473                        .get_most_relevant(dataset, state.source_schema())
474                })
475                .map_or((None, None), |(view, reason)| (Some(view), Some(reason))),
476            None => (None, None),
477        };
478        let Some(view) = view else {
479            return false;
480        };
481        let applied = match reason {
482            // Applied unasked, it says which view and why.
483            Some(why) => self.apply_matched_view(&view, why),
484            None => self.apply_view(&view),
485        };
486        match applied {
487            // The view reads its own first rows, so the dataset's are never read.
488            Ok(()) => true,
489            Err(e) => {
490                self.error_modal
491                    .show(format!("Error applying view \"{}\": {e}", view.name));
492                false
493            }
494        }
495    }
496
497    /// Read the dataset on screen again from what it was opened with, a new listing
498    /// included, and put back its place once read: the query, filters, sort, columns,
499    /// reshape and sample as a view keeps them, the saved view marked applied, and the
500    /// drill-down.
501    /// `asked` is the place the failed read was for (a query or view the user asked
502    /// for, not yet in effect), tried again in place of the one on screen.
503    pub(crate) fn reopen_in_place(
504        &mut self,
505        asked: Option<Box<loading::open_options::KeptPlace>>,
506    ) -> Option<AppEvent> {
507        let (paths, options) = self.source.opened.clone()?;
508        if self.query_prompt_mode().is_some() {
509            self.close_query_prompt();
510        }
511        // What the question was asked over read the dataset being replaced.
512        self.close_overlays();
513        let place = asked.map(|asked| *asked).or_else(|| self.place_on_screen());
514        let options = OpenOptions {
515            view: None,
516            prepared: None,
517            place: place.map(Arc::new),
518            ..options
519        };
520        self.set_loading_phase("Scanning input", 10);
521        self.name_what_is_loading(paths[0].clone());
522        Some(AppEvent::Open(paths, options))
523    }
524
525    /// Where the dataset on screen is, for a reopen to put back: its steps as a view keeps
526    /// them, the saved view marked applied and the drill-down.
527    pub(crate) fn place_on_screen(&self) -> Option<loading::open_options::KeptPlace> {
528        let state = self.data_table_state.as_ref()?;
529        let mut settings = crate::view_settings_of(state);
530        settings.chart = self.saved_chart();
531        // Drilled in, the steps are the grouped view's, and so is the column order.
532        if let Some((order, locked)) = state.grouped_column_order() {
533            settings.column_order = order.to_vec();
534            settings.locked_columns_count = locked;
535        }
536        Some(loading::open_options::KeptPlace {
537            settings,
538            active: self.views.active_id.clone(),
539            drill: state.drill_place().map(Box::new),
540        })
541    }
542
543    /// Enter the home screen, rebuilt, with the cursor on what is open, abandoning any
544    /// load. `loading::Loader::retire` supersedes the open's jobs (their answers are
545    /// dropped) and raises its stop flag, so downloads and cloud passes stop within a
546    /// wave. Work not the open's (an export, an analysis, the screen's footer pass) is
547    /// left running, with its progress and completion modal.
548    pub fn abandon_load(&mut self) {
549        let retired = self.loading.retire();
550        if let Some(retired) = retired {
551            self.put_down_load(retired);
552        }
553        // A chart being prepared would keep the throbber up at home, and could land later
554        // in another dataset with the same column names.
555        self.reset_chart_state();
556        // A look in flight may never return (a gone share): supersede it so nothing waits.
557        if self.jobs.supersede(|job| matches!(job, Job::Classify(_))) {
558            self.home.status = None;
559        }
560        // A collect owed behind this load goes too; it would read the abandoned dataset
561        // at home with every key held.
562        self.jobs.take_owed(Self::owed_rows);
563        // Only the open's wait is put down: an export holds keys too and keeps running.
564        // Rows the open's last step reads still land, unwaited.
565        if retired.is_some() {
566            self.busy = false;
567            let quieted = self.jobs.quiet(Self::reading_rows);
568            if self
569                .status_message
570                .as_ref()
571                .is_some_and(|status| quieted.contains(status))
572            {
573                self.status_message = None;
574            }
575        }
576        // Keys typed at the frozen screen were meant for the load; replayed at home they
577        // could open something unasked.
578        self.screen_generation = self.screen_generation.wrapping_add(1);
579    }
580
581    /// Browse into a path, report it as a lake table, or open it, as its kind calls for.
582    /// `jump` (a path typed at `~`) starts a new browse so Esc returns to the listing.
583    pub(crate) fn open_what_it_is(
584        &mut self,
585        path: PathBuf,
586        kind: discover::EntryKind,
587        jump: bool,
588    ) -> Option<AppEvent> {
589        let go_inside = |app: &mut Self, path: PathBuf| {
590            if jump {
591                app.home_jump_into(path);
592            } else {
593                app.home_browse_into(path);
594            }
595        };
596        if kind == discover::EntryKind::Directory {
597            go_inside(self, path);
598            return None;
599        }
600        // No reader: a local file opens in the hex view; a remote one is dimmed and its
601        // details pane says why.
602        if kind == discover::EntryKind::Other {
603            if matches!(source::input_source(&path), source::InputSource::Local(_)) {
604                self.open_hex(path, crate::app::hex_view::Origin::Home, true, None);
605            }
606            return None;
607        }
608        // A lake table's files are not its rows (tombstoned and rewritten files stay on
609        // disk), so datui goes inside rather than give a wrong answer.
610        if let Some(format) = kind.lake_name() {
611            self.home.lake_here = Some((path.clone(), format));
612            go_inside(self, path);
613            return None;
614        }
615        let directory = matches!(
616            kind,
617            discover::EntryKind::Hive | discover::EntryKind::MultiFile
618        );
619        // A directory typed at `~` is a place to go, as →: naming one never reads all of it.
620        if directory && jump {
621            go_inside(self, path);
622            return None;
623        }
624        // A cloud directory that is a dataset opens as a prefix, scanning every file under
625        // it.
626        if directory && home::is_object_store_url(&path) {
627            return Some(self.home_open_path(home::directory_dataset_url(&path), false));
628        }
629        // Said here, where the file was named, rather than after a download and load that
630        // could only fail; a typed path has no dimmed row to say it. A format spec may
631        // read it by glob or by magic.
632        let a_spec_may_read = !self.formats.by_glob(&path, false).is_empty()
633            || self.formats.specs.iter().any(|f| !f.spec.magic.is_empty());
634        // A table inside a file of tables (`flight.ulg/sensor_accel.1`) or a log found by
635        // magic (`00000042.BIN`) has a name that says nothing.
636        if kind == discover::EntryKind::File
637            && discover::unreadable_by_name(&path)
638            && !a_spec_may_read
639            && crate::formats::members::split(&path).is_none()
640            && crate::formats::members::holder(&path).is_none()
641            && crate::formats::members::split_variant(&path, &self.formats).is_none()
642            && crate::formats::hf_splits::split_place(&path).is_none()
643        {
644            self.home.status = Some(discover::NO_READER.to_string());
645            return None;
646        }
647        // The preview read this file's first page through the open's own steps: install
648        // that rather than read again.
649        let prepared = (!directory)
650            .then(|| self.home_app.previews.take_prepared(&path))
651            .flatten();
652        // A small built-in catalog file is fetched unasked (its row gave its size); a typed
653        // URL still asks.
654        let unasked = self
655            .home
656            .catalogs
657            .iter()
658            .filter(|c| c.origin == catalog::Origin::Bundled)
659            .flat_map(|c| c.datasets.iter())
660            .find(|d| d.location == path)
661            .filter(|_| {
662                !jump && matches!(source::input_source(&path), source::InputSource::Http(_))
663            })
664            .map(|dataset| UnaskedDownload {
665                limit: UnaskedDownload::LIMIT,
666                listed: dataset.size,
667            });
668        match self.home_open_path(path, directory) {
669            AppEvent::Open(paths, mut options) => {
670                options.prepared = prepared.map(|p| Arc::new(Mutex::new(Some(p))));
671                options.download_unasked = unasked;
672                Some(AppEvent::Open(paths, options))
673            }
674            event => Some(event),
675        }
676    }
677
678    /// What `datui <path>` does with a directory, by the same rule as `Enter` on its
679    /// row ([`home::look_into_as`]): a hive root or a one-table directory opens as one
680    /// table, any other opens home browsed into it. `--hive` still forces partition
681    /// columns. Stats the path, so `run` calls it on a worker
682    /// ([`AppEvent::OpenNamed`]); returns `LookThenOpenDirectory` or `Open`.
683    pub fn route_named_paths(paths: Vec<PathBuf>, options: OpenOptions) -> AppEvent {
684        Self::route_named_paths_with(paths, options, &crate::formats::Registry::default())
685    }
686
687    /// [`Self::route_named_paths`] with the format specs: a directory a spec reads as
688    /// column files opens rather than being looked at.
689    pub fn route_named_paths_with(
690        paths: Vec<PathBuf>,
691        options: OpenOptions,
692        formats: &crate::formats::Registry,
693    ) -> AppEvent {
694        if let Some(event) = Self::route_named_without_looking(&paths, &options) {
695            return event;
696        }
697        // Several paths are files read together, and `--hive` already answers; neither asks
698        // what one directory is.
699        let single = (paths.len() == 1 && !options.hive).then(|| paths[0].clone());
700        let Some(dir) = single.filter(|p| p.is_dir()) else {
701            return AppEvent::Open(paths, options);
702        };
703        // A format spec named for it, or one whose glob names it, reads it as columns.
704        if options.spec_file.is_some()
705            || options.spec_name.is_some()
706            || !formats.by_glob(&dir, true).is_empty()
707        {
708            return AppEvent::Open(paths, options);
709        }
710        // Looking reads footers or the front of a spread of files: seconds for large
711        // Parquet, so a worker does it.
712        AppEvent::LookThenOpenDirectory(dir, options)
713    }
714
715    /// The part of [`Self::route_named_paths`] that needs no filesystem. A cloud
716    /// directory is looked at by one listing page, whose contents pick the reader as
717    /// for `(all files)`. A glob, a file name or `--format` already says what to read.
718    pub(crate) fn route_named_without_looking(
719        paths: &[PathBuf],
720        options: &OpenOptions,
721    ) -> Option<AppEvent> {
722        #[cfg(feature = "cloud")]
723        if let [dir] = paths
724            && !options.hive
725            && home::is_object_store_url(dir)
726            && options.format.is_none()
727            && !dir.to_string_lossy().contains('*')
728            && !home::names_a_file(dir)
729        {
730            return Some(AppEvent::LookThenOpenDirectory(
731                dir.clone(),
732                options.clone(),
733            ));
734        }
735        let _ = (paths, options);
736        None
737    }
738
739    /// The first named local path that is not there. URLs and globs are left to the
740    /// open; stdin is no path.
741    pub fn missing_named_path(
742        paths: &[PathBuf],
743        formats: &crate::formats::Registry,
744    ) -> Option<PathBuf> {
745        paths
746            .iter()
747            .find(|path| {
748                !source::is_remote_url(path)
749                    && !crate::loading::stdin::is_stdin(path)
750                    && !source::expands_as_glob(path)
751                    && !path.exists()
752                    && crate::formats::members::split(path).is_none()
753                    && crate::formats::members::split_variant(path, formats).is_none()
754            })
755            .cloned()
756    }
757
758    /// Act on what the look at a directory named on the command line found; the rule is
759    /// at [`Self::route_named_paths`].
760    pub(crate) fn open_the_directory_looked_at(
761        &mut self,
762        dir: PathBuf,
763        kind: discover::EntryKind,
764        holds: Option<&discover::Holds>,
765        mut options: OpenOptions,
766    ) -> Option<AppEvent> {
767        #[cfg(feature = "cloud")]
768        if home::is_object_store_url(&dir) {
769            return self.open_the_cloud_directory_looked_at(dir, kind, holds, options);
770        }
771        let _ = holds;
772        // No override of the reader settings: the look read every file as this open will,
773        // so `--no-header` and skips are already accounted for. A lake table opens home
774        // with the same refusal its row gives.
775        if let Some(format) = kind.lake_name() {
776            self.rest_at_start();
777            self.enter_home();
778            self.home.lake_here = Some((dir.clone(), format));
779            self.home_jump_into(dir);
780            return None;
781        }
782        // One table: read it. `hive` puts the open on the directory route, where the
783        // contents pick the reader.
784        if matches!(
785            kind,
786            discover::EntryKind::Hive | discover::EntryKind::MultiFile
787        ) {
788            options.hive = true;
789            self.set_loading_phase("Scanning input", 10);
790            self.name_what_is_loading(dir.clone());
791            return Some(AppEvent::Open(vec![dir], options));
792        }
793        // A place to look inside: `datui .`, or a directory of separate tables (whose
794        // `(all files)` row unions them).
795        self.rest_at_start();
796        self.enter_home();
797        self.home_jump_into(dir);
798        None
799    }
800
801    /// As [`Self::open_the_directory_looked_at`] for a cloud directory: its `(all files)`
802    /// row's open, or a browse when nothing directly inside is readable.
803    #[cfg(feature = "cloud")]
804    fn open_the_cloud_directory_looked_at(
805        &mut self,
806        dir: PathBuf,
807        kind: discover::EntryKind,
808        holds: Option<&discover::Holds>,
809        options: OpenOptions,
810    ) -> Option<AppEvent> {
811        let open = |app: &mut Self, path: PathBuf, options: OpenOptions| {
812            app.set_loading_phase("Scanning input", 10);
813            app.name_what_is_loading(path.clone());
814            Some(AppEvent::Open(vec![path], options))
815        };
816        // The listing was refused: the open says why in the refuser's words.
817        let Some(holds) = holds else {
818            return open(self, dir, options);
819        };
820        if let Some(format) = kind.lake_name() {
821            self.rest_at_start();
822            self.enter_home();
823            self.home.lake_here = Some((dir.clone(), format));
824            self.home_jump_into(dir);
825            return None;
826        }
827        let directory = home::directory_dataset_url(&dir);
828        if matches!(
829            kind,
830            discover::EntryKind::Hive | discover::EntryKind::MultiFile
831        ) {
832            let options = OpenOptions {
833                hive: true,
834                ..options
835            };
836            return open(self, directory, options);
837        }
838        if let Some((format, left_out)) = Self::cloud_prefix_format(holds) {
839            let options = OpenOptions {
840                hive: true,
841                format: Some(format),
842                left_out,
843                ..options
844            };
845            return open(self, directory, options);
846        }
847        // Only directories, or nothing readable: browse inside, with the reason if any.
848        self.rest_at_start();
849        self.enter_home();
850        self.home_jump_into(dir);
851        self.home.status = Self::why_a_cloud_prefix_cannot_be_read(holds);
852        None
853    }
854
855    /// The options a home-started open reads with: the config's read and CSV settings,
856    /// as the command line would give them.
857    pub(crate) fn open_defaults(&self) -> OpenOptions {
858        match crate::cli::parse_args(["datui"]) {
859            Ok(args) => OpenOptions::from_args_and_config(&args, &self.app_config),
860            Err(_) => OpenOptions::default(),
861        }
862    }
863
864    /// `options` for the compressed delimited `file` in the dialect of the delimited
865    /// spec it matches, if any. Such files skip the scan that matches the others and
866    /// go straight to decompression.
867    fn with_delimited_spec(
868        file: &Path,
869        mut options: OpenOptions,
870        formats: &crate::formats::Registry,
871    ) -> Result<OpenOptions> {
872        if options.delimited.is_some() {
873            return Ok(options);
874        }
875        let asked = crate::formats::Asked {
876            spec_file: options.spec_file.clone(),
877            spec: options.spec_fetched.clone(),
878            spec_name: options.spec_name.clone(),
879            compression: options.compression,
880            ..Default::default()
881        };
882        let crate::formats::Route::Delimited(choice) =
883            crate::formats::route(file, &asked, formats).map_err(|e| color_eyre::eyre::eyre!(e))?
884        else {
885            return Ok(options);
886        };
887        let Some(delimited) = choice.spec.delimited.clone() else {
888            return Ok(options);
889        };
890        delimited.apply(&mut options);
891        let chosen = crate::formats::delimited_spec::DelimitedRead::chosen(
892            choice.spec,
893            choice.by,
894            choice.also,
895        );
896        let read =
897            crate::formats::delimited_spec::read_facts(&chosen, &[file.to_path_buf()], &options)?;
898        options.delimited = Some(Arc::new(read));
899        Ok(options)
900    }
901
902    /// Read a compressed CSV, TSV or PSV into a table state, split on its separator.
903    /// The one input that cannot be scanned lazily (minutes for a large export), so it
904    /// takes no `&self` and runs on a worker.
905    fn decompressed_delimited_state(
906        path: &Path,
907        options: &OpenOptions,
908        writer: &crate::loading::unfinished::Writer,
909    ) -> Result<DataTableState> {
910        let separator = options
911            .format
912            .and_then(FileFormat::separator)
913            .unwrap_or(b',');
914        DataTableState::from_read(
915            crate::formats::readers::csv::read_delimited(path, separator, options, writer)?,
916            options,
917        )
918    }
919
920    /// Polars' view of one source's S3 settings, for `scan_parquet`.
921    #[cfg(feature = "cloud")]
922    fn build_s3_cloud_options(settings: &crate::cloud::cloud_sources::S3Settings) -> CloudOptions {
923        let settings = settings.clone();
924        let virtual_hosted = (settings.endpoint.is_some() || settings.virtual_hosted.is_some())
925            .then(|| settings.virtual_hosted_style().to_string());
926        let configs: Vec<(AmazonS3ConfigKey, String)> = [
927            (AmazonS3ConfigKey::Endpoint, settings.endpoint),
928            (AmazonS3ConfigKey::AccessKeyId, settings.access_key_id),
929            (
930                AmazonS3ConfigKey::SecretAccessKey,
931                settings.secret_access_key,
932            ),
933            (AmazonS3ConfigKey::Token, settings.session_token),
934            (AmazonS3ConfigKey::Region, settings.region),
935            (AmazonS3ConfigKey::VirtualHostedStyleRequest, virtual_hosted),
936            (
937                AmazonS3ConfigKey::SkipSignature,
938                settings.skip_signature.then(|| "true".to_string()),
939            ),
940        ]
941        .into_iter()
942        .filter_map(|(key, value)| value.map(|v| (key, v)))
943        .chain([(
944            AmazonS3ConfigKey::Client(crate::cloud::user_agent::CLIENT_KEY),
945            crate::cloud::user_agent::get(),
946        )])
947        .collect();
948        CloudOptions::default().with_aws(configs)
949    }
950
951    /// The bucket and key of an `s3://bucket/key` or `gs://bucket/key` URL. The key
952    /// is empty for a bucket root.
953    #[cfg(feature = "cloud")]
954    pub(crate) fn cloud_bucket_and_key(url: &str) -> Result<(String, String)> {
955        if let Some((_, container, key)) = source::azure_parts(url) {
956            return Ok((container, key.trim_matches('/').to_string()));
957        }
958        crate::cloud::cloud_browse::split_bucket_url(url)
959            .map(|(_, bucket, key)| (bucket, key))
960            .ok_or_else(|| {
961                color_eyre::eyre::eyre!("URL must be s3://bucket/key or gs://bucket/key")
962            })
963    }
964
965    /// The store Polars will scan `url` through, from its cache keyed on bucket and
966    /// `options`, so footer reads, size probes and downloads share its credentials,
967    /// TLS client and connection pool.
968    #[cfg(feature = "cloud")]
969    fn polars_object_store(
970        url: &str,
971        options: &CloudOptions,
972        runtime: &tokio::runtime::Handle,
973    ) -> Result<Arc<dyn object_store::ObjectStore>> {
974        let url = url.to_string();
975        let options = options.clone();
976        wait_on_runtime(runtime, async move {
977            let (_, store) = polars::io::cloud::build_object_store(
978                PlRefPath::new(url.as_str()),
979                Some(&options),
980                false,
981            )
982            .await?;
983            polars::prelude::PolarsResult::Ok(store.to_dyn_object_store().await.into_owned())
984        })
985        .ok_or_else(|| color_eyre::eyre::eyre!("cancelled"))?
986        .map_err(|e| color_eyre::eyre::eyre!("Object store config failed: {}", e))
987    }
988
989    /// An HTTP agent with a total time budget. ureq 3 bounds only through agent
990    /// config, and `timeout_global` covers the whole exchange, so a server dribbling a
991    /// byte every 29 seconds cannot hang the TUI.
992    #[cfg(feature = "http")]
993    fn http_agent(total: std::time::Duration) -> ureq::Agent {
994        crate::cloud::user_agent::ureq_config()
995            .timeout_global(Some(total))
996            .build()
997            .into()
998    }
999
1000    /// What a HEAD says an HTTP(S) file weighs; `None` if unsaid. An error only when
1001    /// the file cannot be had (404, no server); one refusing HEAD may still send it.
1002    #[cfg(feature = "http")]
1003    pub(crate) fn fetch_remote_size_http(
1004        url: &str,
1005    ) -> std::result::Result<Option<u64>, crate::error_display::HttpGone> {
1006        let agent = Self::http_agent(std::time::Duration::from_secs(15));
1007        // ureq asks for gzip and drops Content-Length from compressed answers (GitHub
1008        // Pages compresses); identity gets the on-disk length.
1009        match agent.head(url).header("Accept-Encoding", "identity").call() {
1010            Ok(r) => Ok(r
1011                .headers()
1012                .get("Content-Length")
1013                .and_then(|v| v.to_str().ok())
1014                .and_then(|s| s.parse::<u64>().ok())),
1015            Err(e) => crate::error_display::http_gone(url, &e).map_or(Ok(None), Err),
1016        }
1017    }
1018
1019    /// The size of one S3 or GCS object, from a HEAD through the shared store.
1020    #[cfg(feature = "cloud")]
1021    fn fetch_remote_size_cloud(
1022        url: &str,
1023        cloud: &crate::config::CloudConfig,
1024        runtime: &tokio::runtime::Handle,
1025    ) -> Result<Option<u64>> {
1026        use object_store::ObjectStoreExt;
1027
1028        let (_bucket, key) = Self::cloud_bucket_and_key(url)?;
1029        if key.is_empty() {
1030            return Ok(None);
1031        }
1032        let (_, _, store) = Self::cloud_store_for(Path::new(url), cloud, runtime)?;
1033        let path = crate::cloud::cloud_browse::object_path(&key);
1034        let head = wait_on_runtime(runtime, async move { store.head(&path).await });
1035        Ok(head.and_then(|r| r.ok()).map(|meta| meta.size))
1036    }
1037
1038    /// What an object named with no format is, by a `HEAD` and its first
1039    /// [`crate::formats::readers::HEAD`] bytes, sniffed as a local file's are.
1040    #[cfg(feature = "cloud")]
1041    fn sniff_cloud_object(
1042        url: &str,
1043        cloud: &crate::config::CloudConfig,
1044        runtime: &tokio::runtime::Handle,
1045    ) -> Result<Unnamed> {
1046        use object_store::ObjectStoreExt;
1047
1048        let (_bucket, key) = Self::cloud_bucket_and_key(url)?;
1049        let (_, _, store) = Self::cloud_store_for(Path::new(url), cloud, runtime)?;
1050        let path = crate::cloud::cloud_browse::object_path(&key);
1051        let sniffed = wait_on_runtime(runtime, async move {
1052            let size = match store.head(&path).await {
1053                Ok(meta) => meta.size,
1054                Err(object_store::Error::NotFound { .. }) => return Ok(Unnamed::Missing),
1055                Err(e) => return Err(e),
1056            };
1057            let end = size.min(crate::formats::readers::HEAD as u64);
1058            let head = if end == 0 {
1059                Default::default()
1060            } else {
1061                store.get_range(&path, 0..end).await?
1062            };
1063            let parquet = crate::formats::readers::sniff(
1064                &head,
1065                None,
1066                crate::formats::readers::Asked::Open { extension: false },
1067                |f| f == FileFormat::Parquet,
1068            )
1069            .is_some();
1070            Ok(if parquet {
1071                Unnamed::Parquet
1072            } else {
1073                Unnamed::Other { size }
1074            })
1075        });
1076        sniffed
1077            .ok_or_else(|| color_eyre::eyre::eyre!("cancelled"))?
1078            .map_err(|e| color_eyre::eyre::eyre!(crate::error_display::store_message(&e)))
1079    }
1080
1081    /// Download `url` to a temp file; `stop` ends it early, even while the server is
1082    /// silent, and any failure removes the file (see
1083    /// [`crate::cloud::download::read_to_temp`]). Past `limit` bytes it fails with
1084    /// [`crate::cloud::download::PastLimit`].
1085    #[cfg(feature = "http")]
1086    fn download_http_to_temp(
1087        url: &str,
1088        temp_dir: Option<&Path>,
1089        extension: Option<&str>,
1090        limit: Option<u64>,
1091        writer: &crate::loading::unfinished::Writer,
1092    ) -> Result<crate::cloud::download::TempDownload> {
1093        use crate::cloud::download::StreamError;
1094
1095        let url = url.to_string();
1096        let open = move || {
1097            let agent = Self::http_agent(std::time::Duration::from_secs(300));
1098            // ureq answers a 4xx or 5xx with an error, so every failure is said here.
1099            let response = agent
1100                .get(&url)
1101                .call()
1102                .map_err(|e| crate::error_display::http_message(&url, &e))?;
1103            // No length: ureq decompresses, and the Content-Length was the wire's.
1104            Ok((response.into_body().into_reader(), None))
1105        };
1106        crate::cloud::download::read_to_temp(temp_dir, extension, open, writer, limit).map_err(
1107            |error| match error {
1108                StreamError::Open(message) => color_eyre::eyre::eyre!(message),
1109                StreamError::Read(e) => {
1110                    color_eyre::eyre::eyre!("Download failed partway. Check your connection: {e}")
1111                }
1112                StreamError::Short { expected, got } => color_eyre::eyre::eyre!(
1113                    "Download failed partway: it ended after {got} of {expected} bytes."
1114                ),
1115                StreamError::Write(report) => report,
1116                StreamError::Cut => color_eyre::eyre::eyre!("Download was cancelled."),
1117            },
1118        )
1119    }
1120
1121    /// Stream one S3, GCS or Azure object to a temp file, a few chunks in memory at a
1122    /// time ([`crate::cloud::download`]). Errors name its scheme; `writer`'s open stopping ends
1123    /// it, and any failure removes the file.
1124    #[cfg(feature = "cloud")]
1125    fn download_cloud_to_temp(
1126        url: &str,
1127        cloud: &crate::config::CloudConfig,
1128        options: &OpenOptions,
1129        runtime: &tokio::runtime::Handle,
1130        writer: &crate::loading::unfinished::Writer,
1131    ) -> Result<crate::cloud::download::TempDownload> {
1132        use crate::cloud::download::StreamError;
1133        use object_store::ObjectStoreExt;
1134
1135        let (label, example) = match source::input_source(Path::new(url)) {
1136            source::InputSource::Gcs(_) => ("GCS", "gs://bucket/path/file.csv"),
1137            source::InputSource::Azure(_) => (
1138                "Azure",
1139                "abfss://container@account.dfs.core.windows.net/path/file.csv",
1140            ),
1141            _ => ("S3", "s3://bucket/path/file.csv"),
1142        };
1143        let ext = source::download_suffix(url);
1144        let (_bucket, key) = Self::cloud_bucket_and_key(url)?;
1145        if key.is_empty() {
1146            return Err(crate::error_display::FileError::new(
1147                Path::new(url),
1148                format!("a {label} URL names an object here, such as {example}"),
1149            )
1150            .into());
1151        }
1152        let (_, _, store) = Self::cloud_store_for(Path::new(url), cloud, runtime)?;
1153
1154        let path = crate::cloud::cloud_browse::object_path(&key);
1155        let open = async move {
1156            let got = store
1157                .get(&path)
1158                .await
1159                .map_err(|e| crate::error_display::store_message(&e))?;
1160            let len = got.range.end - got.range.start;
1161            Ok((got.into_stream(), Some(len)))
1162        };
1163        let failed = |what: String| -> color_eyre::Report {
1164            crate::error_display::FileError::new(Path::new(url), what).into()
1165        };
1166        crate::cloud::download::stream_to_temp(
1167            runtime,
1168            options.temp_dir.as_deref(),
1169            ext.as_deref(),
1170            open,
1171            writer,
1172        )
1173        .map_err(|error| match error {
1174            StreamError::Open(e) => failed(e),
1175            StreamError::Read(e) => failed(format!("the download stopped: {e}")),
1176            StreamError::Short { expected, got } => {
1177                failed(format!("it ended after {got} of {expected} bytes"))
1178            }
1179            StreamError::Write(report) => report,
1180            StreamError::Cut => failed("the download was cancelled".to_string()),
1181        })
1182    }
1183
1184    /// The open's scan of `paths`, named `path`: the frame, or what must happen before
1185    /// there is one. Run by the open's `Scan` phase and by the home preview, which hands
1186    /// its result to the open.
1187    pub(crate) fn scan_for_open(
1188        cloud: &crate::config::CloudConfig,
1189        formats: &crate::formats::Registry,
1190        paths: &[PathBuf],
1191        options: OpenOptions,
1192        path: Option<PathBuf>,
1193    ) -> std::result::Result<loading::LoadAnswer, String> {
1194        use loading::LoadAnswer;
1195        let bytes_of = |files: &[PathBuf]| -> u64 {
1196            files
1197                .iter()
1198                .filter_map(|f| std::fs::metadata(f).ok())
1199                .map(|m| m.len())
1200                .sum()
1201        };
1202        // What the read passed over rides back with the options, so the dataset can say
1203        // what it left out. Seeded with the caller's knowledge and overwritten by the read:
1204        // on disk the read decides, but for an object-store prefix Polars lists and never
1205        // sees other formats, so home's listing is the only witness.
1206        let mut report = ReadReport {
1207            left_out: options.left_out.clone(),
1208            files_disagree: options.files_disagree,
1209            format: None,
1210            format_read: None,
1211            read_python: Vec::new(),
1212            sqlite: None,
1213            opened: None,
1214            splits: options.splits.clone(),
1215            delimited: None,
1216            table: None,
1217            guessed: false,
1218            read_notes: Vec::new(),
1219            typing: Default::default(),
1220        };
1221        // A followed file reads what it can and counts the rest: a misfit row never stops
1222        // the follow.
1223        let options = OpenOptions {
1224            ignore_errors: options.ignore_errors || options.follow,
1225            ..options
1226        };
1227        let named = |e: color_eyre::Report| {
1228            crate::error_display::user_message_from_report(&e, path.as_deref())
1229        };
1230        // A followed Arrow IPC stream gets its own scan, not a conversion; followed NDJSON
1231        // is scanned rather than read whole.
1232        let followed_stream = options.follow
1233            && crate::loading::follow::followed_stream(
1234                &paths[0],
1235                Some(crate::loading::follow::format_of(&paths[0], options.format)),
1236                &options,
1237            );
1238        let scan = if followed_stream {
1239            crate::loading::follow::stream::scan(&paths[0])
1240                .map(Scan::from)
1241                .map_err(|e| color_eyre::eyre::eyre!(e))
1242        } else {
1243            Self::build_lazyframe_from_paths_with(cloud, paths, &options, &mut report, formats)
1244        }
1245        // Named as the dataset is: a download by its URL, not its temp file.
1246        .map_err(named)?;
1247        let format = scan.format(report.format.or(options.format));
1248        // Bounded to complete records and counted for the watcher. A recording that cannot
1249        // be followed is read as it stands.
1250        let recording = options
1251            .spool
1252            .as_ref()
1253            .is_some_and(|handle| handle.spool().tee().is_some());
1254        let (scan, tail) = match scan {
1255            Scan::Frame(lf) if options.follow => {
1256                let format = crate::loading::follow::format_of(&paths[0], format);
1257                let refused = (!followed_stream)
1258                    .then(|| crate::loading::follow::refusal(Some(format), &options))
1259                    .flatten();
1260                match refused {
1261                    Some(_) if recording => (Scan::Frame(lf), None),
1262                    Some(refusal) => return Err(refusal),
1263                    None => {
1264                        let (lf, tail) = crate::loading::follow::bound_to_complete(
1265                            *lf, &paths[0], format, &options,
1266                        )
1267                        .map_err(named)?;
1268                        (Scan::Frame(Box::new(lf)), Some(Arc::new(tail)))
1269                    }
1270                }
1271            }
1272            _ if options.follow && !recording => {
1273                return Err(crate::loading::follow::refusal(format, &options)
1274                    .unwrap_or_else(|| "This file cannot be followed as it grows.".to_string()));
1275            }
1276            scan => (scan, None),
1277        };
1278        let read_mode = scan.read_mode(format, report.format_read.is_some(), &options);
1279        let mut options = OpenOptions {
1280            left_out: report.left_out,
1281            files_disagree: report.files_disagree,
1282            format,
1283            format_read: report.format_read,
1284            sqlite: report.sqlite,
1285            opened: report.opened,
1286            splits: report.splits,
1287            read_python: report.read_python,
1288            read_mode,
1289            tail,
1290            table: report.table.or_else(|| options.table.clone()),
1291            format_guessed: options.format_guessed || report.guessed,
1292            read_notes: report.read_notes,
1293            typing: report.typing,
1294            ..options
1295        };
1296        // The spec's dialect stays with the dataset, so a re-read (`H`, a decompressed
1297        // copy) reads the same.
1298        if let Some(read) = report.delimited {
1299            read.delimited().apply(&mut options);
1300            options.delimited = Some(read);
1301        }
1302        Ok(match scan {
1303            Scan::Frame(lf) => LoadAnswer::Scanned { lf, path, options },
1304            Scan::Decompress { file, .. } => LoadAnswer::Compressed {
1305                file,
1306                path,
1307                options,
1308            },
1309            Scan::Streams(files) => LoadAnswer::Convert {
1310                what: loading::Conversion::Streams,
1311                bytes: bytes_of(&files),
1312                files,
1313                path,
1314                options,
1315            },
1316            Scan::DecompressSpec { file, choice } => LoadAnswer::CompressedRecords {
1317                file,
1318                path,
1319                choice,
1320                options,
1321            },
1322            Scan::ReadInto { files, format } => LoadAnswer::Convert {
1323                what: loading::Conversion::Text(format),
1324                bytes: bytes_of(&files),
1325                files,
1326                path,
1327                options,
1328            },
1329            Scan::Tables { file, tables, .. } => LoadAnswer::Tables { file, tables, path },
1330            Scan::Unpack {
1331                file,
1332                member,
1333                format,
1334            } => LoadAnswer::Convert {
1335                what: loading::Conversion::Text(format),
1336                bytes: bytes_of(std::slice::from_ref(&file)),
1337                files: vec![file],
1338                path,
1339                options: OpenOptions {
1340                    table: Some(member),
1341                    ..options
1342                },
1343            },
1344            Scan::Hex { file, asked } => LoadAnswer::Hex {
1345                file,
1346                asked,
1347                record_size: options.record_size,
1348            },
1349        })
1350    }
1351
1352    /// The open's schema read of the scan's frame, building the dataset with everything
1353    /// the open `made`. Run by the `ReadSchema` phase and by the home preview.
1354    pub(crate) fn read_schema_for_open(
1355        lf: LazyFrame,
1356        path: Option<PathBuf>,
1357        options: OpenOptions,
1358        cloud: &crate::config::CloudConfig,
1359        runtime: &tokio::runtime::Handle,
1360        report: &crate::loading::measurements::OpenReport,
1361        made: loading::Made,
1362    ) -> std::result::Result<loading::LoadAnswer, String> {
1363        use loading::LoadAnswer;
1364        let (state, facts, debug_label) =
1365            Self::build_schema_state(lf, path.as_deref(), &options, cloud, runtime, report)
1366                .map_err(|e| crate::error_display::user_message_from_report(&e, path.as_deref()))?;
1367        // Everything the open found, given to the dataset as it is built.
1368        let loading::Made {
1369            download,
1370            converted,
1371            notes,
1372            other_tables,
1373            detail,
1374        } = made;
1375        let mut open_notes = facts.open_notes;
1376        open_notes.extend(notes);
1377        let mut other_tables_found = facts.other_tables;
1378        other_tables_found.extend(other_tables);
1379        let state = state.with_open(OpenFacts {
1380            fetched: Self::was_fetched(download.as_ref(), path.as_deref()),
1381            download,
1382            converted,
1383            other_tables: other_tables_found,
1384            open_notes,
1385            detail: detail.or(facts.detail),
1386            ..facts
1387        });
1388        Ok(LoadAnswer::SchemaRead {
1389            state: Box::new(state),
1390            path,
1391            options,
1392            debug_label: Some(debug_label),
1393        })
1394    }
1395
1396    /// Run the worker of an open's phase as `load`'s job; its answer goes to the
1397    /// loader. Every phase runs off the event thread: HEAD probes can stall for
1398    /// seconds, scans infer schemas, and directory schema reads touch every footer.
1399    fn spawn_load_phase(&mut self, load: loading::LoadId, step: loading::Step) {
1400        use loading::{LoadAnswer, Step};
1401        let job = Job::Load(load);
1402        // Every phase that reads, reads with the same cloud settings on the shared runtime.
1403        let (cloud, runtime) = (self.app_config.cloud.clone(), self.runtime.clone());
1404        match step {
1405            #[cfg(any(feature = "http", feature = "cloud"))]
1406            Step::ReadHeaders {
1407                url,
1408                format,
1409                options,
1410                writer,
1411            } => {
1412                self.spawn_job(job, Some("Reading headers..."), move |_| {
1413                    let read =
1414                        crate::cloud::remote_model::read(&url, format, &cloud, &runtime, &|| {
1415                            writer.stopped()
1416                        });
1417                    let crate::cloud::remote_model::Read { lf, summary, notes } = match read {
1418                        Ok(read) => read,
1419                        Err(crate::formats::model_files::RangeError::NoRanges) => {
1420                            return Ok(Answer::Load(Box::new(LoadAnswer::NoRanges { options })));
1421                        }
1422                        // The URL in the message may carry a password or a signature.
1423                        Err(crate::formats::model_files::RangeError::Failed(message)) => {
1424                            return Err(crate::logging::redact(&message, &[]));
1425                        }
1426                    };
1427                    let opened = Arc::new(crate::formats::model_files::opened(&summary));
1428                    let options = OpenOptions {
1429                        format: Some(format),
1430                        opened: Some(opened.clone()),
1431                        ..options
1432                    };
1433                    // The table is the headers, in memory: nothing is left to scan.
1434                    let state = Self::schema_state_from_full_scan(
1435                        lf,
1436                        None,
1437                        &OpenOptions {
1438                            hive: false,
1439                            ..options.clone()
1440                        },
1441                    )
1442                    .map_err(|e| crate::error_display::user_message_from_report(&e, Some(&url)))?
1443                    .with_open(OpenFacts {
1444                        detail: opened.detail.clone(),
1445                        open_notes: notes,
1446                        read_as: Some(format),
1447                        ..Default::default()
1448                    });
1449                    Ok(Answer::Load(Box::new(LoadAnswer::SchemaRead {
1450                        state: Box::new(state),
1451                        path: Some(url),
1452                        options,
1453                        debug_label: Some("model headers (ranged)".to_string()),
1454                    })))
1455                });
1456            }
1457            #[cfg(any(feature = "http", feature = "cloud"))]
1458            Step::Probe(pending) => {
1459                self.spawn_job(job, Some("Checking size..."), move |_| {
1460                    // Arrow in a store: its listing says which objects are streams to download.
1461                    #[cfg(feature = "cloud")]
1462                    if let loading::PendingDownload::Arrow { url, .. } = &pending {
1463                        let (_, _, options) = pending.parts();
1464                        let (objects, options) =
1465                            crate::cloud::cloud_arrow::list(url, options, &cloud, &runtime)
1466                                .map_err(|e| {
1467                                    crate::error_display::user_message_from_report(&e, None)
1468                                })?;
1469                        let size = crate::cloud::cloud_arrow::stream_bytes(&objects);
1470                        return Ok(Answer::Load(Box::new(LoadAnswer::Sized(
1471                            loading::PendingDownload::Arrow {
1472                                url: url.clone(),
1473                                objects,
1474                                size: Some(size),
1475                                options,
1476                            },
1477                        ))));
1478                    }
1479                    let size = match &pending {
1480                        #[cfg(feature = "http")]
1481                        loading::PendingDownload::Http { url, .. } => {
1482                            // A missing file or silent host ends the open here, before asking about a
1483                            // download.
1484                            Self::fetch_remote_size_http(url).map_err(|gone| gone.message)?
1485                        }
1486                        #[cfg(feature = "cloud")]
1487                        loading::PendingDownload::S3 { url, options, .. }
1488                        | loading::PendingDownload::Gcs { url, options, .. }
1489                        | loading::PendingDownload::Azure { url, options, .. }
1490                            if loading::unnamed_object(url, options) =>
1491                        {
1492                            // Its first bytes say what a local file's would: Parquet is read
1493                            // in place, anything else downloaded and told apart there.
1494                            match Self::sniff_cloud_object(url, &cloud, &runtime) {
1495                                // Named without its slash, as a local directory may be.
1496                                Ok(Unnamed::Missing) => {
1497                                    return Ok(Answer::Load(Box::new(LoadAnswer::InPlace {
1498                                        path: PathBuf::from(format!("{url}/")),
1499                                        options: options.clone(),
1500                                    })));
1501                                }
1502                                Ok(Unnamed::Parquet) => {
1503                                    return Ok(Answer::Load(Box::new(LoadAnswer::InPlace {
1504                                        path: PathBuf::from(url),
1505                                        options: OpenOptions {
1506                                            format: Some(FileFormat::Parquet),
1507                                            ..options.clone()
1508                                        },
1509                                    })));
1510                                }
1511                                Ok(Unnamed::Other { size }) => Some(size),
1512                                // The download says why it cannot be read.
1513                                Err(_) => None,
1514                            }
1515                        }
1516                        #[cfg(feature = "cloud")]
1517                        loading::PendingDownload::S3 { url, .. }
1518                        | loading::PendingDownload::Gcs { url, .. }
1519                        | loading::PendingDownload::Azure { url, .. } => {
1520                            Self::fetch_remote_size_cloud(url, &cloud, &runtime).unwrap_or(None)
1521                        }
1522                        #[cfg(feature = "cloud")]
1523                        loading::PendingDownload::Arrow { size, .. } => *size,
1524                    };
1525                    Ok(Answer::Load(Box::new(LoadAnswer::Sized(
1526                        pending.with_size(size),
1527                    ))))
1528                });
1529            }
1530            #[cfg(any(feature = "http", feature = "cloud"))]
1531            Step::Download { pending, writer } => {
1532                // The load's stop flag (abandon, replacement, app drop) stops the download at the
1533                // next chunk or during silence and removes its file; `ExitSweep` covers quitting.
1534                let status = match &pending {
1535                    #[cfg(feature = "http")]
1536                    loading::PendingDownload::Http { .. } => "Downloading...",
1537                    #[cfg(feature = "cloud")]
1538                    loading::PendingDownload::S3 { .. } => "Downloading from S3...",
1539                    #[cfg(feature = "cloud")]
1540                    loading::PendingDownload::Gcs { .. } => "Downloading from GCS...",
1541                    #[cfg(feature = "cloud")]
1542                    loading::PendingDownload::Azure { .. } => "Downloading from Azure...",
1543                    #[cfg(feature = "cloud")]
1544                    loading::PendingDownload::Arrow { url, .. } => {
1545                        match source::input_source(Path::new(url)) {
1546                            source::InputSource::Gcs(_) => "Downloading from GCS...",
1547                            source::InputSource::Azure(_) => "Downloading from Azure...",
1548                            _ => "Downloading from S3...",
1549                        }
1550                    }
1551                };
1552                // An unasked download says how much it fetches, when the server said.
1553                let sized = pending
1554                    .parts()
1555                    .1
1556                    .filter(|_| status == "Downloading...")
1557                    .map(|size| format!("Downloading {}...", crate::numfmt::bytes(size)));
1558                let status = sized.as_deref().unwrap_or(status);
1559                self.spawn_job(job, Some(status), move |_| {
1560                    let (url, _, options) = pending.parts();
1561                    let fetched = match &pending {
1562                        #[cfg(feature = "http")]
1563                        loading::PendingDownload::Http { .. } => {
1564                            let ext = source::download_suffix(url);
1565                            // An unasked download of unstated size stops at its limit; a stated size bounds the
1566                            // transfer itself, and the bytes counted here are decompressed.
1567                            let limit = options
1568                                .download_unasked
1569                                .filter(|_| pending.parts().1.is_none())
1570                                .map(|unasked| unasked.limit);
1571                            Self::download_http_to_temp(
1572                                url,
1573                                options.temp_dir.as_deref(),
1574                                ext.as_deref(),
1575                                limit,
1576                                &writer,
1577                            )
1578                            .map(|file| (file, options.clone()))
1579                        }
1580                        #[cfg(feature = "cloud")]
1581                        loading::PendingDownload::S3 { .. }
1582                        | loading::PendingDownload::Gcs { .. }
1583                        | loading::PendingDownload::Azure { .. } => {
1584                            Self::download_cloud_to_temp(url, &cloud, options, &runtime, &writer)
1585                                .map(|file| (file, options.clone()))
1586                        }
1587                        // Its streams, converted as they arrive; its IPC files stay put.
1588                        #[cfg(feature = "cloud")]
1589                        loading::PendingDownload::Arrow { objects, .. } => {
1590                            crate::cloud::cloud_arrow::download(
1591                                objects, options, &cloud, &runtime, &writer,
1592                            )
1593                            .map(|(file, parts)| {
1594                                let options = OpenOptions {
1595                                    format: Some(FileFormat::Arrow),
1596                                    hive: false,
1597                                    arrow_parts: Some(Arc::new(parts)),
1598                                    ..options.clone()
1599                                };
1600                                (file, options)
1601                            })
1602                        }
1603                    };
1604                    let (download, options) = match fetched {
1605                        Err(e)
1606                            if e.downcast_ref::<crate::cloud::download::PastLimit>()
1607                                .is_some() =>
1608                        {
1609                            return Ok(Answer::Load(Box::new(LoadAnswer::PastLimit(pending))));
1610                        }
1611                        fetched => fetched.map_err(|e| {
1612                            crate::error_display::user_message_from_report(&e, None)
1613                        })?,
1614                    };
1615                    Ok(Answer::Load(Box::new(LoadAnswer::Downloaded {
1616                        download,
1617                        options,
1618                    })))
1619                });
1620            }
1621            Step::Spool {
1622                options,
1623                writer,
1624                read,
1625            } => {
1626                // The read is its own thread, so a quiet producer does not hold up the stop:
1627                // Ctrl+O and quitting remove the partial file at once.
1628                let piped = self.pipes.stdin_reader.take();
1629                let stdout = self.pipes.stdout_pass.take();
1630                self.spawn_job(job, Some("Reading stdin..."), move |_| {
1631                    let open =
1632                        move || -> crate::cloud::download::Opened<Box<dyn std::io::Read + Send>> {
1633                            Ok((piped.unwrap_or_else(|| Box::new(std::io::stdin())), None))
1634                        };
1635                    // Followed, the copy goes on behind the first rows; recorded, it goes to the
1636                    // named file; and it is read as it arrives when its format allows.
1637                    let (download, options) = if options.follow
1638                        || options.tee.is_some()
1639                        || crate::loading::stdin::may_read_as_it_arrives(&options)
1640                    {
1641                        match crate::loading::follow::spool(open, options, &writer, &read, stdout)?
1642                        {
1643                            (crate::loading::follow::Spooled::Temp(download), options) => {
1644                                (download, options)
1645                            }
1646                            (crate::loading::follow::Spooled::Kept(file), options) => {
1647                                return Ok(Answer::Load(Box::new(LoadAnswer::Recorded {
1648                                    file,
1649                                    options,
1650                                })));
1651                            }
1652                        }
1653                    } else {
1654                        crate::loading::stdin::spool(open, options, &writer, &read)?
1655                    };
1656                    Ok(Answer::Load(Box::new(LoadAnswer::Spooled {
1657                        download,
1658                        options,
1659                    })))
1660                });
1661            }
1662            Step::FetchSpec {
1663                url,
1664                options,
1665                writer,
1666            } => {
1667                self.spawn_job(job, Some("Reading spec..."), move |_| {
1668                    #[cfg(any(feature = "http", feature = "cloud"))]
1669                    let fetched = crate::cloud::remote_model::fetch_small(
1670                        &url,
1671                        crate::formats::MAX_SPEC_BYTES,
1672                        &cloud,
1673                        &runtime,
1674                        &|| writer.stopped(),
1675                    );
1676                    #[cfg(not(any(feature = "http", feature = "cloud")))]
1677                    let fetched: std::result::Result<Option<Vec<u8>>, String> = {
1678                        let _ = &writer;
1679                        Err(crate::error_display::file_message(
1680                            &url,
1681                            "this build reads no URLs",
1682                        ))
1683                    };
1684                    // The URL in the message may carry a password or a signature.
1685                    let bytes = fetched
1686                        .map_err(|message| crate::logging::redact(&message, &[]))?
1687                        .ok_or_else(|| {
1688                            crate::logging::redact(
1689                                &crate::error_display::file_message(
1690                                    &url,
1691                                    &format!(
1692                                        "a format spec is at most {}",
1693                                        crate::formats::MAX_SPEC_SAID
1694                                    ),
1695                                ),
1696                                &[],
1697                            )
1698                        })?;
1699                    let spec = crate::formats::Spec::from_bytes(&bytes, &url)
1700                        .map_err(|e| crate::logging::redact(&e.to_string(), &[]))?;
1701                    Ok(Answer::Load(Box::new(LoadAnswer::SpecFetched {
1702                        spec: Arc::new(spec),
1703                        options,
1704                    })))
1705                });
1706            }
1707            Step::DecompressRecords {
1708                file,
1709                path,
1710                choice,
1711                options,
1712                writer,
1713            } => {
1714                self.spawn_job(job, Some("Decompressing..."), move |_| {
1715                    let failed = |e: color_eyre::Report| {
1716                        crate::error_display::user_message_from_report(&e, Some(path.as_path()))
1717                    };
1718                    let compression = options
1719                        .compression
1720                        .or_else(|| CompressionFormat::from_extension(&file))
1721                        .ok_or_else(|| format!("{} is not compressed", path.display()))?;
1722                    let temp_dir = options.temp_dir.clone().unwrap_or_else(std::env::temp_dir);
1723                    let copy = crate::formats::readers::csv::decompress_to_copy(
1724                        &file,
1725                        compression,
1726                        &temp_dir,
1727                        &writer,
1728                    )
1729                    .map_err(failed)?;
1730                    Ok(Answer::Load(Box::new(LoadAnswer::DecompressedRecords {
1731                        copy,
1732                        path,
1733                        choice,
1734                        options,
1735                    })))
1736                });
1737            }
1738            Step::ReadRecords {
1739                copy,
1740                path,
1741                choice,
1742                options,
1743            } => {
1744                self.spawn_job(job, Some("Reading records..."), move |_| {
1745                    let named = path
1746                        .file_name()
1747                        .map(|n| n.to_string_lossy().into_owned())
1748                        .unwrap_or_default();
1749                    let read = crate::formats::read(&copy, &named, choice)?;
1750                    let lf = Arc::clone(&read.records).into_lazy().map_err(|e| {
1751                        crate::error_display::user_message_from_report(
1752                            &color_eyre::eyre::eyre!(e),
1753                            Some(path.as_path()),
1754                        )
1755                    })?;
1756                    Ok(Answer::Load(Box::new(LoadAnswer::Scanned {
1757                        lf: Box::new(lf),
1758                        path: Some(path),
1759                        options: OpenOptions {
1760                            format_read: Some(Arc::new(read)),
1761                            ..options
1762                        },
1763                    })))
1764                });
1765            }
1766            Step::Decompress {
1767                file,
1768                path,
1769                options,
1770                writer,
1771                download,
1772            } => {
1773                // Only delimited text and lines come here, the format said by the loader or scan.
1774                let options = OpenOptions {
1775                    format: options.format.or(Some(FileFormat::TEXT)),
1776                    ..options
1777                };
1778                let formats = self.formats.clone();
1779                self.spawn_job(job, Some("Decompressing..."), move |_| {
1780                    let failed = |e: color_eyre::Report| {
1781                        crate::error_display::user_message_from_report(&e, Some(path.as_path()))
1782                    };
1783                    let options =
1784                        Self::with_delimited_spec(&file, options, &formats).map_err(failed)?;
1785                    let lines = options.delimited.is_none()
1786                        && options.format.is_some_and(FileFormat::is_lines);
1787                    let (state, opened) = if lines {
1788                        let (read, opened) = crate::formats::readers::csv::from_lines_decompressed(
1789                            &file, &options, &writer,
1790                        )
1791                        .map_err(failed)?;
1792                        let state = DataTableState::from_read(read, &options).map_err(failed)?;
1793                        (state, Some(opened))
1794                    } else {
1795                        let state = Self::decompressed_delimited_state(&file, &options, &writer)
1796                            .map_err(failed)?;
1797                        (state, None)
1798                    };
1799                    let mut open_notes = options
1800                        .delimited
1801                        .as_ref()
1802                        .map(|read| read.notes())
1803                        .unwrap_or_default();
1804                    open_notes.extend(opened.iter().flat_map(|o| o.notes.iter().cloned()));
1805                    let state = state.with_open(OpenFacts {
1806                        fetched: Self::was_fetched(download.as_ref(), Some(&path)),
1807                        download,
1808                        open_notes,
1809                        records: opened.and_then(|o| o.window),
1810                        delimited: options.delimited.clone(),
1811                        read_as: options.format,
1812                        // The loader sends a compressed file here without a scan.
1813                        read_mode: options.format.and_then(|f| {
1814                            f.read_mode(crate::Stored::Compressed {
1815                                in_memory: options.decompress_in_memory,
1816                            })
1817                        }),
1818                        ..Default::default()
1819                    });
1820                    Ok(Answer::Load(Box::new(LoadAnswer::SchemaRead {
1821                        state: Box::new(state),
1822                        path: Some(path),
1823                        options,
1824                        debug_label: Some("decompressed delimited".to_string()),
1825                    })))
1826                });
1827            }
1828            Step::Convert {
1829                what,
1830                files,
1831                path,
1832                options,
1833                writer,
1834                read,
1835            } => {
1836                // The load's stop flag ends it at the next batch or chunk, removing its files;
1837                // quitting removes them even if the process ends first.
1838                let formats = self.formats.clone();
1839                self.spawn_job(job, Some(what.status()), move |_| {
1840                    let named = |e: color_eyre::Report| {
1841                        crate::error_display::user_message_from_report(&e, path.as_deref())
1842                    };
1843                    let converted = match what {
1844                        loading::Conversion::Streams => {
1845                            let converted = crate::formats::ipc_stream::convert(
1846                                &files,
1847                                options.temp_dir.as_deref(),
1848                                &writer,
1849                                &read,
1850                            )
1851                            .map_err(named)?;
1852                            loading::Converted::Streams {
1853                                file: converted.file,
1854                                parts: converted.parts,
1855                            }
1856                        }
1857                        loading::Conversion::Text(format) => {
1858                            let display = path.clone().unwrap_or_else(|| files[0].clone());
1859                            let (converted, detail) = crate::formats::readers::convert(
1860                                &crate::formats::readers::ConvertIn {
1861                                    files: &files,
1862                                    display: &display,
1863                                    format,
1864                                    options: &options,
1865                                    formats: &formats,
1866                                    writer: &writer,
1867                                    read: &read,
1868                                },
1869                            )
1870                            .map_err(named)?;
1871                            loading::Converted::Frame {
1872                                files: converted.files,
1873                                lf: Box::new(converted.lf),
1874                                notes: converted.notes,
1875                                other_tables: converted.other_tables,
1876                                detail,
1877                            }
1878                        }
1879                    };
1880                    Ok(Answer::Load(Box::new(LoadAnswer::Converted {
1881                        converted,
1882                        path,
1883                        options,
1884                    })))
1885                });
1886            }
1887            Step::Scan {
1888                paths,
1889                options,
1890                display,
1891                status,
1892            } => {
1893                let formats = self.formats.clone();
1894                // A download is scanned from a temp path; the URL the user typed names the dataset.
1895                let path = display.or_else(|| paths.first().cloned());
1896                self.home_app.reads.scans += 1;
1897                self.spawn_job(job, Some(status), move |_| {
1898                    Self::scan_for_open(&cloud, &formats, &paths, options, path)
1899                        .map(|answer| Answer::Load(Box::new(answer)))
1900                });
1901            }
1902            Step::ReadSchema {
1903                lf,
1904                path,
1905                options,
1906                progress,
1907                made,
1908            } => {
1909                self.debug.schema_load = None;
1910                let report = crate::loading::measurements::OpenReport {
1911                    progress,
1912                    meter: Arc::new(crate::loading::measurements::Meter::default()),
1913                    remembered: Some(self.cache.clone()),
1914                    writes: self.cache_writes.clone(),
1915                };
1916                self.spawn_job(job, Some("Reading schema..."), move |_| {
1917                    Self::read_schema_for_open(*lf, path, options, &cloud, &runtime, &report, made)
1918                        .map(|answer| Answer::Load(Box::new(answer)))
1919                });
1920            }
1921            Step::Nothing
1922            | Step::Crash(_)
1923            | Step::Install(_)
1924            | Step::Failed(_)
1925            | Step::Tables(_)
1926            | Step::Hex(_) => {
1927                unreachable!("not a phase with a worker")
1928            }
1929            #[cfg(any(feature = "http", feature = "cloud"))]
1930            Step::Ask(_) => unreachable!("not a phase with a worker"),
1931            Step::AskRead(_) => unreachable!("not a phase with a worker"),
1932        }
1933    }
1934
1935    /// The question before files past `[read] memory_warning` are read whole:
1936    /// `big.json: JSON reads 2.0 GiB into memory`.
1937    fn in_memory_confirmation_message(read: &loading::InMemory) -> String {
1938        let what = match read.files {
1939            1 => format!(
1940                "{}: {} reads",
1941                read.name
1942                    .file_name()
1943                    .map(|n| n.to_string_lossy().into_owned())
1944                    .unwrap_or_else(|| read.name.display().to_string()),
1945                read.format.title()
1946            ),
1947            n => format!("{n} {} files read", read.format.title()),
1948        };
1949        format!(
1950            "{what} {} into memory before the table appears.\n\nRead it?",
1951            crate::numfmt::bytes(read.bytes)
1952        )
1953    }
1954
1955    /// The question asked before a remote file is downloaded.
1956    #[cfg(any(feature = "http", feature = "cloud"))]
1957    fn download_confirmation_message(
1958        pending: &loading::PendingDownload,
1959        note: Option<&str>,
1960    ) -> String {
1961        let (url, size, options) = pending.parts();
1962        let size_str = size
1963            .map(crate::numfmt::bytes)
1964            .unwrap_or_else(|| "unknown".to_string());
1965        let dest_dir = options
1966            .temp_dir
1967            .as_deref()
1968            .map(|p| p.display().to_string())
1969            .unwrap_or_else(|| std::env::temp_dir().display().to_string());
1970        let note = note.map(|note| format!("{note}\n\n")).unwrap_or_default();
1971        // Only a store's Arrow streams are downloaded, and converted as they arrive.
1972        let files = match pending.arrow_files() {
1973            Some((1, 0)) => "Arrow stream: converted as it downloads\n".to_string(),
1974            Some((streams, 0)) => {
1975                format!("Files: {streams} Arrow streams, converted as they download\n")
1976            }
1977            Some((streams, in_place)) => {
1978                let streams = match streams {
1979                    1 => "1 Arrow stream, converted as it downloads".to_string(),
1980                    n => format!("{n} Arrow streams, converted as they download"),
1981                };
1982                let in_place = match in_place {
1983                    1 => "1 IPC file read in place".to_string(),
1984                    n => format!("{n} IPC files read in place"),
1985                };
1986                format!("Files: {streams}; {in_place}\n")
1987            }
1988            None => String::new(),
1989        };
1990        format!(
1991            "{note}URL: {url}\n{files}File size: {size_str}\nDestination: {dest_dir} (temporary file)\n\nContinue with download?"
1992        )
1993    }
1994}
1995
1996/// Scans and schema reads for each kind of source, used by the load's phases.
1997impl App {
1998    fn hoist_partition_columns(
1999        lf: LazyFrame,
2000        schema: &Schema,
2001        partition_columns: &[String],
2002        drifts: bool,
2003    ) -> LazyFrame {
2004        hoist_partition_columns(lf, schema, partition_columns, drifts)
2005    }
2006
2007    /// Schema for a local directory of Parquet: the union of every footer's columns,
2008    /// not `collect_schema()` or one file's. Like a cloud prefix, past one wave of
2009    /// footers the dataset opens and the rest join behind it; an unchanged listing
2010    /// reopens from its cached footers. `None` when not that shape or nothing could
2011    /// be read: the caller falls back to the general scan, which reports any error.
2012    pub(crate) fn schema_state_from_local_hive(
2013        path: Option<&Path>,
2014        options: &OpenOptions,
2015        report: &crate::loading::measurements::OpenReport,
2016    ) -> Option<(DataTableState, OpenFacts)> {
2017        if !options.single_spine_schema {
2018            return None;
2019        }
2020        let p = path.filter(|p| p.is_dir() && options.hive)?;
2021        dataset_files::open(Arc::new(dataset_files::LocalFiles::new(p)), options, report)
2022    }
2023
2024    /// The same for an object store, off the UI thread.
2025    #[cfg(feature = "cloud")]
2026    fn schema_state_from_cloud_hive(
2027        path: Option<&Path>,
2028        options: &OpenOptions,
2029        cloud: &crate::config::CloudConfig,
2030        runtime: &tokio::runtime::Handle,
2031        report: &crate::loading::measurements::OpenReport,
2032    ) -> Option<(DataTableState, OpenFacts)> {
2033        // A prefix of Arrow files is read from its download (`cloud_arrow`).
2034        if !options.single_spine_schema || options.format == Some(FileFormat::Arrow) {
2035            return None;
2036        }
2037        // Unlike the local path this needs no --hive: a directory or glob URL is a hive
2038        // scan by shape.
2039        let p = path.filter(|p| {
2040            let s = p.as_os_str().to_string_lossy();
2041            home::is_object_store_url(p) && (options.hive || source::is_prefix_or_glob(&s))
2042        })?;
2043
2044        let (full, cloud_opts, store) = Self::cloud_store_for(p, cloud, runtime).ok()?;
2045        let (_bucket, key) = Self::cloud_bucket_and_key(&full).ok()?;
2046        Self::schema_state_from_cloud_hive_with(
2047            full, key, store, cloud_opts, options, runtime, report,
2048        )
2049    }
2050
2051    /// The same against a built store; a test hands it an in-memory one to cover the
2052    /// route choice and that each route gets the caller's counter.
2053    #[cfg(feature = "cloud")]
2054    pub(crate) fn schema_state_from_cloud_hive_with(
2055        full: String,
2056        key: String,
2057        store: Arc<dyn object_store::ObjectStore>,
2058        cloud_opts: CloudOptions,
2059        options: &OpenOptions,
2060        runtime: &tokio::runtime::Handle,
2061        report: &crate::loading::measurements::OpenReport,
2062    ) -> Option<(DataTableState, OpenFacts)> {
2063        // List every file once; scan, schema and count work from that list. A listing
2064        // prefix is literal, so datui expands a glob itself: it lists the literal part
2065        // and matches the rest, and the glob gets the schema union, count, notes and
2066        // measurements a prefix gets.
2067        let pattern = full.contains('*').then(|| {
2068            globset::GlobBuilder::new(&key)
2069                .literal_separator(true)
2070                .build()
2071                .map(|g| g.compile_matcher())
2072        });
2073        let pattern = match pattern {
2074            // A pattern datui cannot read is not one it should guess at.
2075            Some(Err(_)) => return None,
2076            Some(Ok(matcher)) => Some(matcher),
2077            None => None,
2078        };
2079        let listed = cloud_hive::prefix_of_glob(&key).to_string();
2080        Self::schema_state_from_cloud_files(
2081            CloudTarget {
2082                full: &full,
2083                key: listed,
2084                pattern: pattern.as_ref(),
2085            },
2086            store,
2087            cloud_opts,
2088            options,
2089            runtime,
2090            report,
2091        )
2092    }
2093
2094    /// A cloud prefix of Parquet files as one dataset from a single listing. Files are
2095    /// scanned by name (no second listing) and leniently (`cloud_hive::lenient_scan`),
2096    /// since files written years apart differ. The state keeps the list so counts read
2097    /// footers and buffers read only the files holding their rows (`RemoteFiles`).
2098    #[cfg(feature = "cloud")]
2099    fn schema_state_from_cloud_files(
2100        target: CloudTarget<'_>,
2101        store: Arc<dyn object_store::ObjectStore>,
2102        cloud_opts: CloudOptions,
2103        options: &OpenOptions,
2104        runtime: &tokio::runtime::Handle,
2105        report: &crate::loading::measurements::OpenReport,
2106    ) -> Option<(DataTableState, OpenFacts)> {
2107        let CloudTarget { full, key, pattern } = target;
2108        dataset_files::open(
2109            Arc::new(dataset_files::StoreFiles::new(
2110                full,
2111                key,
2112                pattern.cloned(),
2113                store,
2114                cloud_opts,
2115                runtime,
2116            )),
2117            options,
2118            report,
2119        )
2120    }
2121
2122    /// Record one object opened from a bucket for home: rows and columns from its
2123    /// footer under the resolved URL. No size (the footer is read from the tail).
2124    /// `mtime` is the open time: remote records are not fingerprinted by it, and the
2125    /// index evicts oldest `mtime` first, so zero would evict these first.
2126    #[cfg(feature = "cloud")]
2127    pub(crate) fn record_cloud_object_facts(
2128        cache: Option<&crate::cache::CacheManager>,
2129        full: &str,
2130        footer: &cloud_hive::FileFooter,
2131    ) {
2132        let Some(cache) = cache else {
2133            return;
2134        };
2135        let columns: Vec<String> = footer
2136            .schema
2137            .iter_names()
2138            .map(|name| name.to_string())
2139            .collect();
2140        cache.record_dataset_facts(&[(
2141            PathBuf::from(full),
2142            crate::cache::DatasetFacts {
2143                mtime: std::time::SystemTime::now()
2144                    .duration_since(std::time::UNIX_EPOCH)
2145                    .map(|d| d.as_secs())
2146                    .unwrap_or_default(),
2147                size: 0,
2148                rows: Some(footer.rows()),
2149                cols: Some(columns.len()),
2150                cols_sampled: false,
2151                columns,
2152                kind: Some(discover::EntryKind::File),
2153                classified_by: discover::CLASSIFIER_VERSION,
2154                cost: discover::Cost {
2155                    row_groups: Some(footer.row_group_rows.len()),
2156                    ..Default::default()
2157                },
2158                holds: Default::default(),
2159            },
2160        )]);
2161    }
2162
2163    /// General schema route: ask the frame. Slow for a wide hive dataset, hence off the
2164    /// UI thread.
2165    fn schema_state_from_full_scan(
2166        mut lf: LazyFrame,
2167        path: Option<&Path>,
2168        options: &OpenOptions,
2169    ) -> Result<DataTableState> {
2170        let schema = lf
2171            .collect_schema()
2172            .map_err(color_eyre::eyre::Report::from)?;
2173        let partition_columns =
2174            match path.filter(|p| options.hive && (p.is_dir() || source::expands_as_glob(p))) {
2175                Some(p) => crate::formats::readers::hive::discover_hive_partition_columns(p)
2176                    .into_iter()
2177                    .filter(|c| schema.contains(c.as_str()))
2178                    .collect::<Vec<_>>(),
2179                None => Vec::new(),
2180            };
2181        let lf = Self::hoist_partition_columns(lf, &schema, &partition_columns, false);
2182        let part_cols = (!partition_columns.is_empty()).then_some(partition_columns);
2183        DataTableState::from_schema_and_lazyframe(schema, lf, options, part_cols)
2184    }
2185
2186    /// Build the table state by the cheapest route that applies, with a label naming
2187    /// the route for the debug overlay. Takes config by value so it runs off-thread.
2188    fn build_schema_state(
2189        lf: LazyFrame,
2190        path: Option<&Path>,
2191        options: &OpenOptions,
2192        cloud: &crate::config::CloudConfig,
2193        runtime: &tokio::runtime::Handle,
2194        report: &crate::loading::measurements::OpenReport,
2195    ) -> Result<(DataTableState, OpenFacts, String)> {
2196        // The facts carry the meter of the route that built the dataset, installed with it.
2197        // A failed open never gets here, so the dataset on screen keeps its own figures.
2198        let (state, mut facts, label) =
2199            Self::schema_state_by_route(lf, path, options, cloud, runtime, report)?;
2200        // What the open did, beside what it found: the one place the scan's report, the
2201        // lake-table flag and the state are all known (see
2202        // `DataTableState::open_notes`). A delimited header whose names are all numbers
2203        // is likely a data row, which `H` reads as data.
2204        let names_look_like_data = options.format.and_then(FileFormat::separator).is_some()
2205            && options.has_header != Some(false)
2206            && !crate::formats::schema_union::names_are_names(
2207                &state
2208                    .schema()
2209                    .iter_names()
2210                    .map(|n| n.to_string())
2211                    .collect::<Vec<_>>(),
2212            );
2213        facts.open_notes = crate::notes::from_the_open(
2214            &options.left_out,
2215            options.read_as_plain_files_of,
2216            options.files_disagree,
2217            names_look_like_data,
2218        );
2219        // The row count on screen is a true count of the files and a wrong one of the
2220        // table.
2221        facts.not_the_table = options.read_as_plain_files_of;
2222        if let Some(splits) = &options.splits {
2223            facts.other_tables = splits.others.clone();
2224            facts
2225                .open_notes
2226                .extend(crate::notes::map_caches(splits.caches));
2227        }
2228        if let Some(read) = &options.format_read {
2229            facts.open_notes.extend(read.notes());
2230            facts.format_read = Some(read.clone());
2231        }
2232        if let Some(opened) = &options.opened {
2233            facts.records = opened.window.clone();
2234            facts.detail = opened.detail.clone();
2235            facts.other_tables = opened.other_tables.clone();
2236            facts.open_notes.extend(opened.notes.iter().cloned());
2237            facts.units = opened.units.clone();
2238            facts.indexing = opened.indexing.clone();
2239            facts.numbering = opened.numbering.clone();
2240        }
2241        if let Some(sqlite) = &options.sqlite {
2242            facts.pushdown = Some(sqlite.pushdown.clone());
2243            facts.hold = sqlite.hold.lock().ok().and_then(|mut hold| hold.take());
2244            facts.other_tables = sqlite.other_tables.clone();
2245        }
2246        if let Some(read) = &options.delimited {
2247            facts.open_notes.extend(read.notes());
2248            facts.delimited = Some(read.clone());
2249        }
2250        facts.open_notes.extend(options.read_notes.iter().cloned());
2251        facts.typing = options.typing.clone();
2252        facts.read_mode = options.read_mode;
2253        facts.read_as = options.format;
2254        // A downloaded object's display path is its URL too; only a scan reading the store
2255        // in place buffers like one. Arrow in a store reads IPC files in place and streams
2256        // from their download (`cloud_arrow`).
2257        facts.remote_source = match &options.arrow_parts {
2258            Some(parts) => parts.iter().any(|part| {
2259                matches!(part, crate::formats::ipc_stream::Part::InPlace(p) if source::is_remote_url(p))
2260            }),
2261            None => path.is_some_and(source::scans_in_place),
2262        };
2263        // The footer-sum row count for a local Parquet hive directory, asked here because a
2264        // stat on a dead mount hangs its thread. A directory of another format counts by
2265        // a scan; one read by file has a counter for the footers the open did not read.
2266        if options.hive
2267            && facts.remote_files.is_none()
2268            && options.format.is_none_or(|f| f == FileFormat::Parquet)
2269            && let Some(dir) = path.filter(|p| !source::is_remote_url(p) && p.is_dir())
2270        {
2271            facts.parquet_count_dir = Some(dir.to_path_buf());
2272        }
2273        Ok((state, facts, label))
2274    }
2275
2276    /// Scan an object-store prefix with the reader its format calls for; Polars'
2277    /// other scans take the same `CloudOptions`. Parquet keeps its own branch at each
2278    /// call site (the only format with hive partitioning here). `None` for other
2279    /// formats, sending the caller to its Parquet scan.
2280    #[cfg(feature = "cloud")]
2281    pub(crate) fn scan_cloud_prefix(
2282        url: &str,
2283        cloud_opts: CloudOptions,
2284        format: FileFormat,
2285        glob: bool,
2286        options: &OpenOptions,
2287    ) -> Option<Result<LazyFrame>> {
2288        // The formats the docs say a prefix reads in place. Parquet takes the caller's
2289        // scan; model files are read by their headers before this.
2290        if !format.reads_bucket_prefix() {
2291            return None;
2292        }
2293        // Narrow a plain prefix to keys with an extension: a console's folder marker lists
2294        // as `data` for `data/`, and Polars refuses the whole prefix over it.
2295        let pl_path = if url.ends_with('/') && !url.contains('*') {
2296            PlRefPath::new(format!("{url}**/*.*").as_str())
2297        } else {
2298            PlRefPath::new(url)
2299        };
2300        let scan = crate::formats::readers::of(format).bucket_scan?;
2301        Some(scan(crate::formats::readers::BucketIn {
2302            url,
2303            path: pl_path,
2304            cloud: cloud_opts,
2305            glob,
2306            options,
2307            format,
2308        }))
2309    }
2310
2311    /// The non-Parquet format a store prefix or glob reads as: the listing's, else a
2312    /// glob's extension (`*.arrow`).
2313    #[cfg(feature = "cloud")]
2314    fn cloud_glob_format(url: &str, options: &OpenOptions) -> Option<FileFormat> {
2315        options
2316            .format
2317            .or_else(|| {
2318                url.contains('*')
2319                    .then(|| FileFormat::from_path(Path::new(url)))
2320                    .flatten()
2321            })
2322            .filter(|f| *f != FileFormat::Parquet)
2323    }
2324
2325    /// The plain URL and Polars options for one object-store path, through the source
2326    /// it names or belongs to (`cloud_sources::resolve`).
2327    #[cfg(feature = "cloud")]
2328    fn resolve_cloud_url(
2329        path: &Path,
2330        cloud: &crate::config::CloudConfig,
2331    ) -> Result<(String, CloudOptions)> {
2332        let text = path.to_string_lossy();
2333        let resolved = crate::cloud::cloud_sources::resolve_for_open(&text, cloud)
2334            .map_err(|e| color_eyre::eyre::eyre!(e))?;
2335        use object_store::azure::AzureConfigKey;
2336        use polars::io::cloud::GoogleConfigKey;
2337        let gcs_agent = (
2338            GoogleConfigKey::Client(crate::cloud::user_agent::CLIENT_KEY),
2339            crate::cloud::user_agent::get(),
2340        );
2341        let options = match resolved.kind {
2342            crate::cloud::source::ProviderKind::S3 => Self::build_s3_cloud_options(&resolved.s3),
2343            crate::cloud::source::ProviderKind::Gcs
2344                if resolved.signing == crate::cloud::cloud_sources::Signing::Unsigned =>
2345            {
2346                CloudOptions::default()
2347                    .with_gcp([(GoogleConfigKey::SkipSignature, "true".into()), gcs_agent])
2348            }
2349            crate::cloud::source::ProviderKind::Gcs => match &resolved.gcloud {
2350                // The token comes from `gcloud` whenever Polars asks, so long scans outlive it.
2351                Some((configuration, _)) => CloudOptions::default()
2352                    .with_gcp([gcs_agent])
2353                    .with_credential_provider(Some(crate::cloud::gcloud::polars_provider(
2354                        configuration,
2355                    ))),
2356                None => match &resolved.google_credentials {
2357                    Some(file) => CloudOptions::default().with_gcp([
2358                        (
2359                            GoogleConfigKey::ApplicationCredentials,
2360                            file.to_string_lossy().into_owned(),
2361                        ),
2362                        gcs_agent,
2363                    ]),
2364                    None => CloudOptions::default().with_gcp([gcs_agent]),
2365                },
2366            },
2367            crate::cloud::source::ProviderKind::Azure => {
2368                let (account, _, _) = source::azure_parts(&resolved.url)
2369                    .ok_or_else(|| color_eyre::eyre::eyre!("not an Azure URL"))?;
2370                let mut azure = crate::cloud::azure::polars_options(&account, &resolved.azure);
2371                azure.push((
2372                    AzureConfigKey::Client(crate::cloud::user_agent::CLIENT_KEY),
2373                    crate::cloud::user_agent::get(),
2374                ));
2375                CloudOptions::default().with_azure(azure)
2376            }
2377        };
2378        Ok((resolved.url, options))
2379    }
2380
2381    /// The URL, Polars options and store for one object-store path. `cloud` is the
2382    /// effective config (see `OpenOptions::effective_cloud`).
2383    #[cfg(feature = "cloud")]
2384    pub(crate) fn cloud_store_for(
2385        path: &Path,
2386        cloud: &crate::config::CloudConfig,
2387        runtime: &tokio::runtime::Handle,
2388    ) -> Result<(String, CloudOptions, Arc<dyn object_store::ObjectStore>)> {
2389        let (full, cloud_opts) = Self::resolve_cloud_url(path, cloud)?;
2390        let store = Self::polars_object_store(&full, &cloud_opts, runtime)?;
2391        Ok((full, cloud_opts, store))
2392    }
2393
2394    /// One Parquet object read in place: schema and row count from its footer in one
2395    /// 256 KiB tail read. Polars answers a cloud `len()` by reading row group 0, so the
2396    /// schema is handed to the scan and the count known before the first frame. A
2397    /// failure is returned: the caller falls back to asking the frame and labels why.
2398    #[cfg(feature = "cloud")]
2399    fn schema_state_from_cloud_object(
2400        path: &Path,
2401        options: &OpenOptions,
2402        cloud: &crate::config::CloudConfig,
2403        runtime: &tokio::runtime::Handle,
2404        report: &crate::loading::measurements::OpenReport,
2405    ) -> Result<(DataTableState, OpenFacts)> {
2406        let (full, cloud_opts, store) = Self::cloud_store_for(path, cloud, runtime)?;
2407        let (_bucket, key) = Self::cloud_bucket_and_key(&full)?;
2408        if key.is_empty() {
2409            return Err(color_eyre::eyre::eyre!("a bucket, not an object"));
2410        }
2411        let meter = report.meter.clone();
2412        let (footer, etag) = wait_on_runtime(runtime, async move {
2413            cloud_hive::footer_of_cloud_parquet(store, &key, &meter).await
2414        })
2415        .ok_or_else(|| color_eyre::eyre::eyre!("cancelled"))??;
2416        let args = ScanArgsParquet {
2417            schema: Some(footer.schema.clone()),
2418            cloud_options: Some(cloud_opts),
2419            hive_options: polars::io::HiveOptions::default(),
2420            glob: false,
2421            ..Default::default()
2422        };
2423        let lf = LazyFrame::scan_parquet(PlRefPath::new(full.as_str()), args)?;
2424        let state =
2425            DataTableState::from_schema_and_lazyframe(footer.schema.clone(), lf, options, None)?;
2426        // Record it in the dataset index, as the prefix route does.
2427        Self::record_cloud_object_facts(report.remembered.as_ref(), &full, &footer);
2428        let column_bytes =
2429            crate::formats::schema_union::column_bytes_per_row(&[Some(footer.clone())]);
2430        let facts = OpenFacts {
2431            remote_objects: vec![crate::cloud::local_copy::RemoteObject {
2432                url: full,
2433                size: footer.file_bytes as u64,
2434                etag,
2435            }],
2436            row_groups: vec![footer.row_group_rows],
2437            column_bytes,
2438            ..Default::default()
2439        };
2440        Ok((state, facts))
2441    }
2442
2443    /// The schema routes, cheapest first: one local footer, one cloud footer (a hive
2444    /// prefix or a single object), then asking the frame.
2445    fn schema_state_by_route(
2446        lf: LazyFrame,
2447        path: Option<&Path>,
2448        options: &OpenOptions,
2449        cloud: &crate::config::CloudConfig,
2450        runtime: &tokio::runtime::Handle,
2451        report: &crate::loading::measurements::OpenReport,
2452    ) -> Result<(DataTableState, OpenFacts, String)> {
2453        #[cfg(not(feature = "cloud"))]
2454        let _ = (cloud, runtime);
2455
2456        // A meter per attempt; the winner's becomes the open's. Earlier routes measure
2457        // before finding they cannot finish, and a shared meter would leave their figures
2458        // on a dataset a later route built. Both full-scan arms also return an empty one;
2459        // `test_a_route_that_gave_up_leaves_no_figures_on_the_dataset_that_opened` holds
2460        // the two guards together.
2461        let attempt = |report: &crate::loading::measurements::OpenReport| {
2462            crate::loading::measurements::OpenReport {
2463                progress: report.progress.clone(),
2464                meter: Arc::new(crate::loading::measurements::Meter::default()),
2465                remembered: report.remembered.clone(),
2466                writes: report.writes.clone(),
2467            }
2468        };
2469
2470        let local = attempt(report);
2471        if let Some((state, facts)) = Self::schema_state_from_local_hive(path, options, &local) {
2472            let facts = OpenFacts {
2473                measurements: local.meter,
2474                ..facts
2475            };
2476            return Ok((state, facts, "one-file (local)".to_string()));
2477        }
2478        // An open abandoned mid-listing is not one for the routes below to scan whole.
2479        if report.progress.is_cancelled() {
2480            return Err(color_eyre::eyre::eyre!("cancelled"));
2481        }
2482        #[cfg(feature = "cloud")]
2483        let cloud_hive_attempt = attempt(report);
2484        #[cfg(feature = "cloud")]
2485        if let Some((state, facts)) =
2486            Self::schema_state_from_cloud_hive(path, options, cloud, runtime, &cloud_hive_attempt)
2487        {
2488            let facts = OpenFacts {
2489                measurements: cloud_hive_attempt.meter,
2490                ..facts
2491            };
2492            return Ok((state, facts, "one-file (cloud)".to_string()));
2493        }
2494        #[cfg(feature = "cloud")]
2495        if let Some(p) = path.filter(|p| {
2496            source::scans_in_place(p)
2497                && !options.hive
2498                && !source::is_prefix_or_glob(&p.to_string_lossy())
2499        }) {
2500            let object = attempt(report);
2501            match Self::schema_state_from_cloud_object(p, options, cloud, runtime, &object) {
2502                Ok((state, facts)) => {
2503                    let facts = OpenFacts {
2504                        measurements: object.meter,
2505                        ..facts
2506                    };
2507                    return Ok((state, facts, "footer (cloud)".to_string()));
2508                }
2509                // Shown in the debug overlay: the fallback costs a row group for the count.
2510                Err(e) => {
2511                    // A fresh meter: the full scan measures nothing, and the failed attempt's figures
2512                    // would describe a route not taken.
2513                    return Self::schema_state_from_full_scan(lf, path, options).map(|state| {
2514                        (
2515                            state,
2516                            OpenFacts::default(),
2517                            format!("full scan (cloud footer: {e})"),
2518                        )
2519                    });
2520                }
2521            }
2522        }
2523        Self::schema_state_from_full_scan(lf, path, options)
2524            .map(|state| (state, OpenFacts::default(), "full scan".to_string()))
2525    }
2526
2527    /// The files of one split when `dir` is a Hugging Face `datasets` cache (its
2528    /// `dataset_info.json` or `state.json` beside Arrow files); choices go in `report`.
2529    /// Any other directory reads every file.
2530    fn hugging_face_split(
2531        dir: &Path,
2532        format: FileFormat,
2533        files: Vec<PathBuf>,
2534        options: &OpenOptions,
2535        report: &mut ReadReport,
2536    ) -> Result<Vec<PathBuf>> {
2537        let metadata = || {
2538            ["dataset_info.json", "state.json"]
2539                .iter()
2540                .any(|name| dir.join(name).is_file())
2541        };
2542        if format != FileFormat::Arrow || !metadata() {
2543            return Ok(files);
2544        }
2545        let names: Vec<&str> = files
2546            .iter()
2547            .map(|f| f.file_name().and_then(|n| n.to_str()).unwrap_or_default())
2548            .collect();
2549        let (chosen, splits) = crate::formats::hf_splits::choose(&names, options.table.as_deref())
2550            .map_err(|e| color_eyre::eyre::eyre!("{}: {e}", dir.display()))?;
2551        report.splits = Some(Arc::new(splits));
2552        Ok(chosen.into_iter().map(|i| files[i].clone()).collect())
2553    }
2554
2555    /// Why `--table` was refused for `path`, a file of `format`, which holds one table.
2556    fn one_table(path: Option<&Path>, format: Option<FileFormat>) -> color_eyre::Report {
2557        match path {
2558            Some(path) => crate::error_display::FileError::new(path, cli::one_table(format)).into(),
2559            None => color_eyre::eyre::eyre!(cli::one_table(format)),
2560        }
2561    }
2562
2563    /// A scan of `url` in an object store that Polars refused, with what to check.
2564    #[cfg(feature = "cloud")]
2565    pub(crate) fn cloud_scan_failed(
2566        url: &str,
2567        e: &polars::prelude::PolarsError,
2568    ) -> color_eyre::Report {
2569        let said = crate::error_display::user_message_from_polars(e);
2570        let (first, rest) = said.split_once('\n').unwrap_or((&said, ""));
2571        let first = first.trim_end().trim_end_matches('.');
2572        // Only the store's own failure says anything of the credentials; a file that is
2573        // not what it was read as says so alone.
2574        fn from_the_store(e: &polars::prelude::PolarsError) -> bool {
2575            use polars::prelude::PolarsError;
2576            match e {
2577                PolarsError::IO { .. } => true,
2578                PolarsError::Context { error, .. } => from_the_store(error),
2579                _ => false,
2580            }
2581        }
2582        let check = if from_the_store(e) {
2583            " Check the credentials and the URL."
2584        } else {
2585            ""
2586        };
2587        let what = format!("could not read it: {first}.{check}");
2588        let what = match rest {
2589            "" => what,
2590            rest => format!("{what}\n{rest}"),
2591        };
2592        crate::error_display::FileError::new(Path::new(url), what).into()
2593    }
2594
2595    /// The inputs of an Arrow read as one table, in order: IPC files scanned in place,
2596    /// and each run of streams as its rows of the `converted` IPC file. Stacked as a
2597    /// directory's files are ([`crate::formats::readers::polars::union_of_files`]).
2598    fn scan_arrow_parts(
2599        cloud: &crate::config::CloudConfig,
2600        converted: Option<&PathBuf>,
2601        parts: &[crate::formats::ipc_stream::Part],
2602    ) -> Result<LazyFrame> {
2603        use crate::formats::ipc_stream::Part;
2604        #[cfg(not(feature = "cloud"))]
2605        let _ = cloud;
2606        let scan = |path: &Path| -> Result<LazyFrame> {
2607            #[cfg(feature = "cloud")]
2608            if source::is_remote_url(path) {
2609                let (url, cloud_options) = Self::resolve_cloud_url(path, cloud)?;
2610                let args = polars::prelude::UnifiedScanArgs {
2611                    cloud_options: Some(cloud_options),
2612                    ..Default::default()
2613                };
2614                return Ok(LazyFrame::scan_ipc(
2615                    PlRefPath::new(url.as_str()),
2616                    Default::default(),
2617                    args,
2618                )?);
2619            }
2620            // Converted streams sit in a user-named temp directory (`[` and all), and a file
2621            // read in place may be called `d[1].arrow`.
2622            let args = polars::prelude::UnifiedScanArgs {
2623                glob: source::expands_as_glob(path),
2624                ..Default::default()
2625            };
2626            Ok(LazyFrame::scan_ipc(
2627                polars::prelude::PlRefPath::try_from_path(path)?,
2628                Default::default(),
2629                args,
2630            )?)
2631        };
2632        let streams = |offset: u64, rows: u64| -> Result<LazyFrame> {
2633            let file = converted
2634                .ok_or_else(|| color_eyre::eyre::eyre!("No converted Arrow file to read."))?;
2635            let lf = scan(file)?;
2636            // The whole file needs no slice, which would hide its row count.
2637            let whole = offset == 0
2638                && parts
2639                    .iter()
2640                    .all(|part| matches!(part, Part::Converted { .. }));
2641            Ok(if whole {
2642                lf
2643            } else {
2644                lf.slice(offset as i64, rows as polars::prelude::IdxSize)
2645            })
2646        };
2647        let mut frames = Vec::new();
2648        let mut run: Option<(u64, u64)> = None;
2649        for part in parts {
2650            match part {
2651                Part::Converted { offset, rows, .. } => {
2652                    run = Some(match run {
2653                        Some((start, n)) if start + n == *offset => (start, n + rows),
2654                        Some((start, n)) => {
2655                            frames.push(streams(start, n)?);
2656                            (*offset, *rows)
2657                        }
2658                        None => (*offset, *rows),
2659                    });
2660                }
2661                Part::InPlace(path) => {
2662                    if let Some((start, n)) = run.take() {
2663                        frames.push(streams(start, n)?);
2664                    }
2665                    frames.push(scan(path)?);
2666                }
2667            }
2668        }
2669        if let Some((start, n)) = run {
2670            frames.push(streams(start, n)?);
2671        }
2672        match frames.len() {
2673            0 => Err(color_eyre::eyre::eyre!("No Arrow files to read.")),
2674            1 => Ok(frames.remove(0)),
2675            _ => Ok(polars::prelude::concat(
2676                frames.as_slice(),
2677                crate::formats::readers::polars::union_of_files(),
2678            )?),
2679        }
2680    }
2681
2682    /// One split of a `save_to_disk` DatasetDict whose `dataset_dict.json` names
2683    /// `splits`: the one `--table` names, else the first, read as any directory. The
2684    /// others are listed.
2685    fn dataset_dict_split(
2686        dir: &Path,
2687        splits: &[String],
2688        options: &OpenOptions,
2689        report: &mut ReadReport,
2690        formats: &crate::formats::Registry,
2691    ) -> Result<Scan> {
2692        let listed: Vec<&str> = splits.iter().map(String::as_str).collect();
2693        let mut picked = crate::formats::hf_splits::pick(&listed, options.table.as_deref())
2694            .map_err(|e| color_eyre::eyre::eyre!("{}: {e}", dir.display()))?;
2695        let split = dir.join(picked.split.as_deref().unwrap_or_default());
2696        let inner = OpenOptions {
2697            table: None,
2698            splits: None,
2699            ..options.clone()
2700        };
2701        let scan = Self::build_local_lazyframe(&[split], &inner, report, formats)?;
2702        // The split's own directory names no splits; its `map()` files are still counted.
2703        picked.caches = report.splits.as_ref().map_or(0, |inner| inner.caches);
2704        report.splits = Some(Arc::new(picked));
2705        Ok(scan)
2706    }
2707
2708    /// Whether the files about to be read as one table differ in columns, for a note.
2709    /// Only footerless formats (Parquet's footers give the exact version), and only
2710    /// the [`crate::formats::schema_union::sample_files`] spread, since the user is waiting.
2711    fn files_disagree(
2712        files: &[PathBuf],
2713        options: &OpenOptions,
2714        found: FileFormat,
2715    ) -> crate::formats::schema_union::Disagreement {
2716        // The format the read will use: `--format` outranks the names, and judging with
2717        // another reader would describe a read that never happened.
2718        let format = options.format.unwrap_or(found);
2719        if format == FileFormat::Parquet {
2720            return Default::default();
2721        }
2722        // `--null` takes `COL=VAL` forms resolved per file, which a sample cannot mirror;
2723        // guessing would report a widening the table never did.
2724        if options.null_values.is_some() {
2725            return Default::default();
2726        }
2727        crate::formats::schema_union::sample_files(files, format, &Self::read_as(options))
2728            .disagreement()
2729    }
2730
2731    /// The reader settings a sample copies to describe what the open will do, from the
2732    /// open's actual options (`from_args_and_config` always fills
2733    /// `infer_schema_length` and `parse_strings`, so "did the user set anything" is
2734    /// always true).
2735    pub(crate) fn read_as(options: &OpenOptions) -> crate::formats::schema_union::ReadAs {
2736        crate::formats::schema_union::ReadAs {
2737            delimiter: options.delimiter,
2738            has_header: options.has_header,
2739            skip_rows: options.skip_rows,
2740            skip_lines: options.skip_lines,
2741            infer_schema_length: options.infer_schema_length,
2742            ignore_errors: options.ignore_errors,
2743            try_parse_dates: options.csv_try_parse_dates(),
2744            comment_char: options.comment_char.clone(),
2745            header_rows: options.header_rows.clone(),
2746            header_join: options.header_join.clone(),
2747        }
2748    }
2749
2750    /// A format spec reads a local file or one downloaded remote object
2751    /// (`loading::remote_download`); a remote prefix or glob here would be scanned in
2752    /// place without the spec.
2753    pub(crate) fn refuse_spec_in_place(path: &Path, options: &OpenOptions) -> Result<()> {
2754        if source::is_remote_url(path)
2755            && (options.spec_file.is_some() || options.spec_name.is_some())
2756        {
2757            return Err(crate::error_display::FileError::new(
2758                path,
2759                "a format spec reads one remote object at a time, not a prefix or a glob; name the object",
2760            )
2761            .into());
2762        }
2763        Ok(())
2764    }
2765
2766    /// Build the LazyFrame for `paths`. Takes the cloud config by reference so it runs
2767    /// on a worker. `found` collects what the read says of itself for the notes (data
2768    /// files passed over, whether columns differ): this pass decides, so a second
2769    /// directory read cannot disagree.
2770    pub(crate) fn build_lazyframe_from_paths_with(
2771        cloud: &crate::config::CloudConfig,
2772        paths: &[PathBuf],
2773        options: &OpenOptions,
2774        report: &mut ReadReport,
2775        formats: &crate::formats::Registry,
2776    ) -> Result<Scan> {
2777        // Arrow streams converted or a bucket's Arrow listed: the load says where each
2778        // input's rows are.
2779        if let Some(parts) = &options.arrow_parts {
2780            if options.table.is_some() && options.splits.is_none() {
2781                // Named by an input the user knows, never the converted copy.
2782                let named = parts.first().map(|part| match part {
2783                    crate::formats::ipc_stream::Part::InPlace(path) => path.as_path(),
2784                    crate::formats::ipc_stream::Part::Converted { source, .. } => source.as_path(),
2785                });
2786                return Err(Self::one_table(named, Some(FileFormat::Arrow)));
2787            }
2788            return Self::scan_arrow_parts(cloud, paths.first(), parts).map(Scan::from);
2789        }
2790        // Only the cloud readers below take the settings.
2791        #[cfg(not(feature = "cloud"))]
2792        let _ = cloud;
2793        let path = &paths[0];
2794        Self::refuse_spec_in_place(path, options)?;
2795        match source::input_source(path) {
2796            source::InputSource::Http(_url) => {
2797                #[cfg(feature = "http")]
2798                {
2799                    return Err(color_eyre::eyre::eyre!(
2800                        "HTTP/HTTPS load is handled in the event loop; this path should not be reached."
2801                    ));
2802                }
2803                #[cfg(not(feature = "http"))]
2804                {
2805                    return Err(color_eyre::eyre::eyre!(
2806                        "HTTP/HTTPS URLs are not supported in this build. Rebuild with default features."
2807                    ));
2808                }
2809            }
2810            source::InputSource::S3(url) => {
2811                #[cfg(feature = "cloud")]
2812                {
2813                    let (full, cloud_opts) =
2814                        Self::resolve_cloud_url(Path::new(&format!("s3://{url}")), cloud)?;
2815                    let is_glob = source::is_prefix_or_glob(&full);
2816                    // The reader the prefix's own format calls for, if the listing said; only Parquet
2817                    // falls through.
2818                    if let Some(format) = Self::cloud_glob_format(&full, options)
2819                        && let Some(lf) = Self::scan_cloud_prefix(
2820                            &full,
2821                            cloud_opts.clone(),
2822                            format,
2823                            is_glob,
2824                            options,
2825                        )
2826                    {
2827                        return lf.map(Scan::from);
2828                    }
2829                    let pl_path = PlRefPath::new(full.as_str());
2830                    let hive_options = if is_glob {
2831                        polars::io::HiveOptions::new_enabled()
2832                    } else {
2833                        polars::io::HiveOptions::default()
2834                    };
2835                    let args = ScanArgsParquet {
2836                        cloud_options: Some(cloud_opts),
2837                        hive_options,
2838                        glob: is_glob,
2839                        ..Default::default()
2840                    };
2841                    let lf = LazyFrame::scan_parquet(pl_path, args)
2842                        .map_err(|e| Self::cloud_scan_failed(&full, &e))?;
2843                    // The frame alone: a state would make Polars list every file under the prefix,
2844                    // and the schema phase lists them again.
2845                    return Ok(lf.into());
2846                }
2847                #[cfg(not(feature = "cloud"))]
2848                {
2849                    let _ = url;
2850                    return Err(color_eyre::eyre::eyre!(
2851                        "S3 is not supported in this build. Rebuild with default features and set AWS credentials (e.g. AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION)."
2852                    ));
2853                }
2854            }
2855            source::InputSource::Gcs(url) => {
2856                #[cfg(feature = "cloud")]
2857                {
2858                    let (full, cloud_opts) =
2859                        Self::resolve_cloud_url(Path::new(&format!("gs://{url}")), cloud)?;
2860                    let is_glob = source::is_prefix_or_glob(&full);
2861                    // The reader the prefix's own format calls for, if the listing said; only Parquet
2862                    // falls through.
2863                    if let Some(format) = Self::cloud_glob_format(&full, options)
2864                        && let Some(lf) = Self::scan_cloud_prefix(
2865                            &full,
2866                            cloud_opts.clone(),
2867                            format,
2868                            is_glob,
2869                            options,
2870                        )
2871                    {
2872                        return lf.map(Scan::from);
2873                    }
2874                    let pl_path = PlRefPath::new(full.as_str());
2875                    let hive_options = if is_glob {
2876                        polars::io::HiveOptions::new_enabled()
2877                    } else {
2878                        polars::io::HiveOptions::default()
2879                    };
2880                    let args = ScanArgsParquet {
2881                        cloud_options: Some(cloud_opts),
2882                        hive_options,
2883                        glob: is_glob,
2884                        ..Default::default()
2885                    };
2886                    let lf = LazyFrame::scan_parquet(pl_path, args)
2887                        .map_err(|e| Self::cloud_scan_failed(&full, &e))?;
2888                    return Ok(lf.into());
2889                }
2890                #[cfg(not(feature = "cloud"))]
2891                {
2892                    let _ = url;
2893                    return Err(color_eyre::eyre::eyre!(
2894                        "GCS (gs://) is not supported in this build. Rebuild with default features."
2895                    ));
2896                }
2897            }
2898            source::InputSource::Azure(url) => {
2899                #[cfg(feature = "cloud")]
2900                {
2901                    let (full, cloud_opts) = Self::resolve_cloud_url(Path::new(&url), cloud)?;
2902                    let is_glob = source::is_prefix_or_glob(&full);
2903                    // The reader the prefix's own format calls for, if the listing said; only Parquet
2904                    // falls through.
2905                    if let Some(format) = Self::cloud_glob_format(&full, options)
2906                        && let Some(lf) = Self::scan_cloud_prefix(
2907                            &full,
2908                            cloud_opts.clone(),
2909                            format,
2910                            is_glob,
2911                            options,
2912                        )
2913                    {
2914                        return lf.map(Scan::from);
2915                    }
2916                    let args = ScanArgsParquet {
2917                        cloud_options: Some(cloud_opts),
2918                        hive_options: if is_glob {
2919                            polars::io::HiveOptions::new_enabled()
2920                        } else {
2921                            polars::io::HiveOptions::default()
2922                        },
2923                        glob: is_glob,
2924                        ..Default::default()
2925                    };
2926                    let lf = LazyFrame::scan_parquet(PlRefPath::new(full.as_str()), args)
2927                        .map_err(|e| Self::cloud_scan_failed(&full, &e))?;
2928                    return Ok(lf.into());
2929                }
2930                #[cfg(not(feature = "cloud"))]
2931                {
2932                    let _ = url;
2933                    return Err(color_eyre::eyre::eyre!(
2934                        "Azure is not supported in this build. Rebuild with default features."
2935                    ));
2936                }
2937            }
2938            source::InputSource::Local(_) => {}
2939        }
2940        Self::build_local_lazyframe(paths, options, report, formats)
2941    }
2942
2943    /// A directory's files read as `found`, through the delimited spec the first
2944    /// matches, if any.
2945    fn read_directory_files(
2946        files: &[PathBuf],
2947        options: &OpenOptions,
2948        found: FileFormat,
2949        report: &mut ReadReport,
2950        formats: &crate::formats::Registry,
2951    ) -> Result<Scan> {
2952        if options.delimited.is_none()
2953            && options.format.is_none()
2954            && found.separator().is_some()
2955            && let Some(first) = files
2956                .iter()
2957                .find(|f| !crate::formats::nul_tail::holds_nothing(f))
2958            && let Some(choice) = Self::delimited_spec_of(first, options, formats)?
2959        {
2960            let nested = OpenOptions {
2961                hive: false,
2962                format: Some(found),
2963                splits: report.splits.clone(),
2964                ..options.clone()
2965            };
2966            return Self::read_with_delimited_spec(files, &nested, report, formats, choice);
2967        }
2968        // A spec's read reports differing files itself, from their header lines.
2969        report.files_disagree = Self::files_disagree(files, options, found);
2970        let nested = OpenOptions {
2971            hive: false,
2972            format: Some(options.format.unwrap_or(found)),
2973            splits: report.splits.clone(),
2974            ..options.clone()
2975        };
2976        Self::build_local_lazyframe(files, &nested, report, formats)
2977    }
2978
2979    /// The delimited spec whose glob or magic `file` matches, if one does.
2980    fn delimited_spec_of(
2981        file: &Path,
2982        options: &OpenOptions,
2983        formats: &crate::formats::Registry,
2984    ) -> Result<Option<crate::formats::Choice>> {
2985        let asked = crate::formats::Asked {
2986            compression: options.compression,
2987            text_only: true,
2988            ..Default::default()
2989        };
2990        match crate::formats::route(file, &asked, formats)
2991            .map_err(|e| color_eyre::eyre::eyre!(e))?
2992        {
2993            crate::formats::Route::Delimited(choice) => Ok(Some(choice)),
2994            _ => Ok(None),
2995        }
2996    }
2997
2998    /// `paths` read with the CSV reader in the dialect of `choice`'s delimited spec.
2999    fn read_with_delimited_spec(
3000        paths: &[PathBuf],
3001        options: &OpenOptions,
3002        report: &mut ReadReport,
3003        formats: &crate::formats::Registry,
3004        choice: crate::formats::Choice,
3005    ) -> Result<Scan> {
3006        let mut nested = options.clone();
3007        let Some(delimited) = choice.spec.delimited.clone() else {
3008            return Err(color_eyre::eyre::eyre!(
3009                "{} is not a delimited spec",
3010                choice.spec.name
3011            ));
3012        };
3013        delimited.apply(&mut nested);
3014        nested.delimited = Some(Arc::new(
3015            crate::formats::delimited_spec::DelimitedRead::chosen(
3016                choice.spec,
3017                choice.by,
3018                choice.also,
3019            ),
3020        ));
3021        Self::build_local_lazyframe(paths, &nested, report, formats)
3022    }
3023
3024    /// The local half of `build_lazyframe_from_paths_with`: directories resolve to local
3025    /// files, so no cloud settings.
3026    pub(crate) fn build_local_lazyframe(
3027        paths: &[PathBuf],
3028        options: &OpenOptions,
3029        report: &mut ReadReport,
3030        formats: &crate::formats::Registry,
3031    ) -> Result<Scan> {
3032        let path = &paths[0];
3033
3034        // `--hex`: the file's bytes, whatever it holds.
3035        if options.hex
3036            && let [one] = paths
3037            && one.is_file()
3038        {
3039            return Ok(Scan::Hex {
3040                file: one.clone(),
3041                asked: true,
3042            });
3043        }
3044
3045        // A local glob whose first file a delimited spec reads goes through the spec file
3046        // by file; Polars' scan would read the spec's header lines as data.
3047        if let [pattern] = paths
3048            && !options.hive
3049            && options.delimited.is_none()
3050            && options.format.is_none()
3051            && source::expands_as_glob(pattern)
3052        {
3053            let files = crate::loading::local_glob::expand(pattern);
3054            if let Some(first) = files
3055                .iter()
3056                .find(|f| !crate::formats::nul_tail::holds_nothing(f))
3057                && let Some(choice) = Self::delimited_spec_of(first, options, formats)?
3058            {
3059                let format = FileFormat::from_path(first).filter(|f| f.separator().is_some());
3060                let nested = OpenOptions {
3061                    format: format.or(Some(FileFormat::Csv)),
3062                    ..options.clone()
3063                };
3064                return Self::read_with_delimited_spec(&files, &nested, report, formats, choice);
3065            }
3066        }
3067
3068        // A format spec, asked for or matched by glob or magic. A path whose name or bytes
3069        // already say its format opens as such, except for delimited specs' text. Several
3070        // files match by the first, and only to a delimited spec.
3071        if !options.hive && options.delimited.is_none() {
3072            let asked = crate::formats::Asked {
3073                spec_file: options.spec_file.clone(),
3074                spec_name: options.spec_name.clone(),
3075                variant: options.table.clone(),
3076                spec: options.spec_fetched.clone(),
3077                builtin: options.format.is_some(),
3078                compression: options.compression,
3079                text_only: paths.len() > 1,
3080            };
3081            match crate::formats::route(path, &asked, formats)
3082                .map_err(|e| color_eyre::eyre::eyre!(e))?
3083            {
3084                crate::formats::Route::Elsewhere => {}
3085                crate::formats::Route::Delimited(choice) => {
3086                    return Self::read_with_delimited_spec(paths, options, report, formats, choice);
3087                }
3088                crate::formats::Route::Read(read) => {
3089                    let lf = Arc::clone(&read.records).into_lazy()?;
3090                    report.format_read = Some(Arc::new(*read));
3091                    return Ok(lf.into());
3092                }
3093                crate::formats::Route::Decompress(choice) => {
3094                    return Ok(Scan::DecompressSpec {
3095                        file: path.clone(),
3096                        choice,
3097                    });
3098                }
3099            }
3100        } else if options.hive && (options.spec_file.is_some() || options.spec_name.is_some()) {
3101            return Err(color_eyre::eyre::eyre!(
3102                "a format spec reads one file, or one directory of column files"
3103            ));
3104        }
3105
3106        // The header lines of a delimited spec's first file: its units and metadata.
3107        if let Some(read) = &options.delimited
3108            && report.delimited.is_none()
3109            && path.is_file()
3110        {
3111            report.delimited = Some(Arc::new(crate::formats::delimited_spec::read_facts(
3112                read, paths, options,
3113            )?));
3114        }
3115
3116        // One directory path, with or without `--hive`: naming a directory asks to read
3117        // it, and the dispatch below picks the reader for its contents.
3118        if paths.len() == 1 && (options.hive || path.is_dir()) {
3119            // A file is a file whatever its name holds: `a*b.parquet` is not a glob.
3120            let is_single_file = path.is_file();
3121            if !is_single_file {
3122                // The directory's contents pick the reader.
3123                if path.is_dir()
3124                    && let Some(splits) = crate::formats::hf_splits::dataset_dict(path)
3125                {
3126                    return Self::dataset_dict_split(path, &splits, options, report, formats);
3127                }
3128                if path.is_dir() {
3129                    match crate::home::discover::directory_format(path) {
3130                        // Flat and Parquet: the scan below is already right for it.
3131                        crate::home::discover::DirectoryFormat::One(FileFormat::Parquet, _) => {}
3132                        // Partitions, or empty. Only the hive scan walks `key=value` trees, and hive
3133                        // partitioning is Parquet-only here (`HiveOptions::new_disabled()` for CSV and
3134                        // NDJSON), so other formats in partitions are refused with their file names.
3135                        crate::home::discover::DirectoryFormat::Deeper => {
3136                            if let crate::home::discover::DirectoryFormat::One(found, files) =
3137                                crate::home::discover::hive_leaf_format(path)
3138                                && found != FileFormat::Parquet
3139                            {
3140                                // The extension, as the user sees it on the files, not the format's name.
3141                                let named = files
3142                                    .first()
3143                                    .and_then(|f| crate::home::discover::data_extension(f))
3144                                    .unwrap_or_else(|| format!("{found:?}").to_lowercase());
3145                                return Err(color_eyre::eyre::eyre!(
3146                                    "{} is partitioned into key=value directories of .{} \
3147                                     files. datui reads hive partitioning for Parquet \
3148                                     only — open one partition instead.",
3149                                    path.display(),
3150                                    named
3151                                ));
3152                            }
3153                        }
3154                        crate::home::discover::DirectoryFormat::One(found, files) => {
3155                            // Read as a list of files typed on the command line would be; `--format`
3156                            // outranks the names.
3157                            let format = options.format.unwrap_or(found);
3158                            let files =
3159                                Self::hugging_face_split(path, format, files, options, report)?;
3160                            return Self::read_directory_files(
3161                                &files, options, found, report, formats,
3162                            );
3163                        }
3164                        crate::home::discover::DirectoryFormat::Mixed {
3165                            format: found,
3166                            files,
3167                            passed_over,
3168                        } => {
3169                            // The commonest format is the table: a thousand CSVs and one stray JSON are a
3170                            // directory of CSVs.
3171                            let format = options.format.unwrap_or(found);
3172                            let files =
3173                                Self::hugging_face_split(path, format, files, options, report)?;
3174                            let lf = Self::read_directory_files(
3175                                &files, options, found, report, formats,
3176                            )?;
3177                            // A model's config and tokenizer JSON are not data passed over, so a model
3178                            // directory reports nothing left out.
3179                            if !matches!(found, FileFormat::Safetensors | FileFormat::Gguf) {
3180                                report.left_out = passed_over;
3181                            }
3182                            return Ok(lf);
3183                        }
3184                    }
3185                }
3186                let use_parquet_hive =
3187                    path.is_dir() || path.as_os_str().to_string_lossy().contains(".parquet");
3188                if use_parquet_hive {
3189                    // Only the LazyFrame here; schema and partition discovery are the schema phase's.
3190                    return crate::formats::readers::hive::scan_parquet_hive(path).map(Scan::from);
3191                }
3192                return Err(color_eyre::eyre::eyre!(
3193                    "With --hive use a directory or a glob pattern for Parquet (e.g. path/to/dir or path/**/*.parquet)"
3194                ));
3195            }
3196        }
3197
3198        // A file with no extension may still be Parquet (a part file in a `.parquet`
3199        // directory). A text name (`.log`, `.txt`) reads as lines unless its bytes say a
3200        // format: candump writes `.log`.
3201        let compressed = options
3202            .compression
3203            .or_else(|| CompressionFormat::from_extension(path))
3204            .is_some();
3205        // Under a compression suffix, the name before it says delimited text or lines
3206        // (`x.tsv.gz`, `app.log.gz`).
3207        let named = FileFormat::from_path(path).or_else(|| {
3208            compressed
3209                .then(|| FileFormat::from_path(Path::new(path.file_stem()?)))
3210                .flatten()
3211                .filter(|f| f.decompressed_once())
3212        });
3213        let mut effective_format = options
3214            .format
3215            // A format another refines asks the bytes: journal JSON in a `.json` file.
3216            .or_else(|| {
3217                named.filter(|f| !f.is_lines()).map(|f| {
3218                    (!compressed)
3219                        .then(|| crate::formats::readers::refined(path, f))
3220                        .flatten()
3221                        .unwrap_or(f)
3222                })
3223            })
3224            .or_else(|| {
3225                (path.extension().is_none()
3226                    && crate::home::discover::is_parquet_key(&path.to_string_lossy()))
3227                .then_some(FileFormat::Parquet)
3228            })
3229            // Any other unnamed format, by its first bytes (see `crate::formats::readers`).
3230            .or_else(|| crate::formats::readers::sniff_open(path, options.compression))
3231            .or(named);
3232        // Text no signature claims: JSON, CSV or TSV on evidence, lines otherwise; non-text
3233        // bytes are shown raw.
3234        if effective_format.is_none()
3235            && let [file] = paths
3236            && file.is_file()
3237        {
3238            effective_format = crate::formats::lines::guess_file(file, options.compression)
3239                .map(|f| crate::formats::lines::as_asked(f, options));
3240            report.guessed = effective_format.is_some();
3241        }
3242        report.format = effective_format;
3243
3244        // Refused, not ignored: a one-table file with `--table` would pass for the table
3245        // asked for.
3246        if options.table.is_some()
3247            && !effective_format.is_some_and(FileFormat::takes_table)
3248            && options.splits.is_none()
3249        {
3250            return Err(Self::one_table(Some(path), effective_format));
3251        }
3252
3253        // One compressed CSV, TSV, PSV or text file: the load decompresses it
3254        // (`Step::Decompress`) into a copy the dataset holds; a copy made here would be
3255        // dropped with the state below.
3256        if let [file] = paths
3257            && compressed
3258            && let Some(format) = effective_format.filter(|f| f.decompressed_once())
3259        {
3260            return Ok(Scan::Decompress {
3261                file: file.clone(),
3262                format,
3263            });
3264        }
3265
3266        let Some(format) = effective_format else {
3267            if !path.exists() {
3268                return Err(std::io::Error::new(
3269                    std::io::ErrorKind::NotFound,
3270                    format!("File not found: {}", path.display()),
3271                )
3272                .into());
3273            }
3274            // A local file nothing reads is shown as its bytes (#588).
3275            if paths.len() == 1 && path.is_file() {
3276                return Ok(Scan::Hex {
3277                    file: path.clone(),
3278                    asked: false,
3279                });
3280            }
3281            return Err(color_eyre::eyre::eyre!(match paths.len() {
3282                1 => UNSUPPORTED.to_string(),
3283                _ => crate::formats::readers::many_files_refused(),
3284            }));
3285        };
3286        // The same question home asks (`reads_many_files`) before offering a directory, so
3287        // it never offers one this refuses.
3288        if paths.len() > 1 && !format.reads_many_files() {
3289            if !path.exists() {
3290                return Err(std::io::Error::new(
3291                    std::io::ErrorKind::NotFound,
3292                    format!("File not found: {}", path.display()),
3293                )
3294                .into());
3295            }
3296            return Err(color_eyre::eyre::eyre!(
3297                crate::formats::readers::many_files_refused()
3298            ));
3299        }
3300        let guessed;
3301        let options = if report.guessed {
3302            guessed = OpenOptions {
3303                format_guessed: true,
3304                ..options.clone()
3305            };
3306            &guessed
3307        } else {
3308            options
3309        };
3310        crate::formats::readers::scan(crate::formats::readers::ScanIn {
3311            format,
3312            paths,
3313            options,
3314            report,
3315            formats,
3316        })
3317    }
3318}