Skip to main content

datui_lib/app/
startup.rs

1//! What `run` reads before it can build the app, read behind the first frame.
2//!
3//! The configuration, its imports, `[cloud] env_files` and the log all live in files,
4//! and a file can sit on a mount that does not answer. They are read on a worker
5//! while the terminal is already taken, its reader running: a slow read shows a
6//! screen that says so, and Ctrl+C or Ctrl+Q leave it. Read promptly, as they almost
7//! always are, the app's own first frame is the first thing drawn.
8
9use std::path::{Path, PathBuf};
10use std::sync::mpsc::Receiver;
11use std::time::Duration;
12
13use color_eyre::Result;
14use ratatui::Frame;
15use ratatui::layout::Rect;
16use ratatui::text::Line;
17use ratatui::widgets::Paragraph;
18
19use crate::config::ThemeMode;
20use crate::{APP_NAME, AppConfig, AppEvent, Args, OpenOptions, RunInput, Theme, logging};
21
22/// How long `run` waits for the settings before drawing a screen of its own. Under
23/// it, nobody sees the difference and the app's first frame is the first one.
24pub(crate) const GRACE: Duration = Duration::from_millis(25);
25
26/// Everything read before the app can be built.
27pub struct Settings {
28    pub(crate) config: AppConfig,
29    pub(crate) theme: Theme,
30    pub(crate) input: RunInput,
31    pub(crate) opts: OpenOptions,
32    /// Things to say on stderr once the terminal is handed back: an env file that
33    /// could not be read, a log that could not be opened.
34    pub(crate) notes: Vec<String>,
35}
36
37/// The value for `POLARS_MAX_THREADS` that `performance.threads` asks for: none for 0
38/// (every core), or when the environment already names one, which wins.
39pub(crate) fn polars_threads(threads: usize, env: Option<&std::ffi::OsStr>) -> Option<String> {
40    (threads > 0 && env.is_none()).then(|| threads.to_string())
41}
42
43/// Read the settings: the configuration (unless one was given), the command line over
44/// it, `[cloud] env_files`, the log.
45pub(crate) fn read(input: RunInput, config: Option<AppConfig>) -> Result<Settings> {
46    // `--log-level`, then `-c log.level`, then `DATUI_LOG`; the files' `log.level` is
47    // read below, under all three.
48    let log_level = match &input {
49        RunInput::Cli(args) | RunInput::Host(args, _) => args.log_level.clone().or_else(|| {
50            args.config
51                .iter()
52                .rev()
53                .find(|o| o.key == "log.level")
54                .and_then(|o| o.value.as_str().map(str::to_string))
55        }),
56        _ => None,
57    }
58    .or_else(|| std::env::var("DATUI_LOG").ok());
59    let config = match config {
60        Some(config) => config,
61        None => load_config(&input)?,
62    };
63    crate::cloud::user_agent::configure(&config.http.user_agent);
64    let (input, config) = match input {
65        RunInput::Cli(args) => {
66            let mut config = config;
67            apply_args(&mut config, &args);
68            let opts = OpenOptions::from_args_and_config(&args, &config);
69            // Only the command line reads standard input: a host such as Python has
70            // its own, and it is not data.
71            let piped = crate::loading::stdin::piped();
72            let paths = crate::loading::stdin::paths_or_stdin(args.paths, piped);
73            if let Some(refused) = crate::loading::stdin::refuse(&paths, piped) {
74                return Err(color_eyre::eyre::eyre!(refused));
75            }
76            if let Some(tee) = &opts.tee {
77                if !paths
78                    .iter()
79                    .any(|path| crate::loading::stdin::is_stdin(path))
80                {
81                    return Err(color_eyre::eyre::eyre!(
82                        "--tee records standard input: pipe data in, as in: some_logger | datui --tee run1.csv -"
83                    ));
84                }
85                if !opts.force && !crate::loading::stdin::is_stdin(tee) && tee.exists() {
86                    return Err(color_eyre::eyre::eyre!(crate::loading::tee::refusal(tee)));
87                }
88            }
89            if opts.follow && paths.is_empty() {
90                return Err(color_eyre::eyre::eyre!(
91                    "--follow needs a file to follow, or data piped in: some_logger | datui -f -"
92                ));
93            }
94            (RunInput::Paths(paths, opts), config)
95        }
96        RunInput::Host(args, frame) => {
97            let mut config = config;
98            apply_args(&mut config, &args);
99            let opts = OpenOptions::from_args_and_config(&args, &config);
100            match frame {
101                Some(lf) => (RunInput::LazyFrame(lf, opts), config),
102                None => {
103                    let paths = args
104                        .paths
105                        .into_iter()
106                        .map(crate::loading::stdin::as_file)
107                        .collect();
108                    (RunInput::Paths(paths, opts), config)
109                }
110            }
111        }
112        RunInput::Paths(paths, opts) => {
113            let paths = paths
114                .into_iter()
115                .map(crate::loading::stdin::as_file)
116                .collect();
117            (RunInput::Paths(paths, opts), config)
118        }
119        input => (input, config),
120    };
121    let opts = match &input {
122        RunInput::Paths(_, o) | RunInput::LazyFrame(_, o) => o.clone(),
123        RunInput::Cli(_) | RunInput::Host(..) => unreachable!("resolved above"),
124    };
125    // The home screen has no `OpenOptions` of its own, so the CLI and environment S3
126    // overrides are folded into the config here, once, for discovery, listing and
127    // opens started from a listed bucket.
128    let mut config = config;
129    let mut notes = Vec::new();
130    // Variables from `[cloud] env_files` first, so everything below sees them.
131    if let Ok(dir) = std::env::current_dir() {
132        notes.extend(crate::cloud::cloud_env::load(&config.cloud, &dir));
133    }
134    config.cloud = opts.effective_cloud(&config.cloud);
135    // Said on stderr while it was the log: said again once the terminal is back.
136    notes.extend(config.theme.warnings());
137
138    let cache_dir = crate::cache::CacheManager::new(APP_NAME).ok();
139    notes.extend(logging::init(&logging::LogSettings::resolve(
140        config.log.file.as_deref(),
141        log_level.as_deref().or(config.log.level.as_deref()),
142        cache_dir.as_ref().map(|c| c.cache_dir()),
143    )));
144    for secret in [
145        &config.cloud.s3_access_key_id,
146        &config.cloud.s3_secret_access_key,
147    ]
148    .into_iter()
149    .flatten()
150    {
151        logging::keep_out_of_log(secret);
152    }
153    // A connection's keys come from variables it names, which need not look secret.
154    for connection in &config.cloud.connections {
155        for name in [
156            &connection.secret_access_key_env,
157            &connection.session_token_env,
158            &connection.account_key_env,
159        ]
160        .into_iter()
161        .flatten()
162        {
163            if let Some(value) = crate::cloud::cloud_env::var(name) {
164                logging::keep_out_of_log(&value);
165            }
166        }
167    }
168
169    let theme = Theme::from_config(&config.theme)
170        .or_else(|e| Theme::from_config(&AppConfig::default().theme).map_err(|_| e))?;
171    Ok(Settings {
172        config,
173        theme,
174        input,
175        opts,
176        notes,
177    })
178}
179
180/// The configuration file, read. From the command line its error says how to get
181/// past it; a library caller gets the error as it is.
182pub(crate) fn load_config(input: &RunInput) -> Result<AppConfig> {
183    let overrides = match input {
184        RunInput::Cli(args) | RunInput::Host(args, _) => args.config.as_slice(),
185        _ => &[],
186    };
187    AppConfig::load_with(APP_NAME, overrides).map_err(|e| match input {
188        // In full: a TOML parse error's later lines show the offending line and why.
189        RunInput::Cli(_) => color_eyre::eyre::eyre!(
190            "{e}\nFix the configuration and try again, or remove/rename the config file to \
191             use defaults."
192        ),
193        _ => e,
194    })
195}
196
197/// `input` with a leading `~` expanded in the paths its command line names: the
198/// datasets, `--format FILE`, `--dict` and `--temp-dir`. `--log-file` expands with
199/// `[log] file`.
200pub(crate) fn expand_home(input: RunInput) -> RunInput {
201    match input {
202        RunInput::Cli(mut args) => {
203            let spec = match args.format.as_mut() {
204                Some(crate::cli::FormatChoice::File(path)) => Some(path),
205                _ => None,
206            };
207            for path in args
208                .paths
209                .iter_mut()
210                .chain(spec)
211                .chain(args.dict.iter_mut())
212                .chain(args.temp_dir.as_mut())
213            {
214                *path = crate::config::expand_home(path);
215            }
216            RunInput::Cli(args)
217        }
218        input => input,
219    }
220}
221
222/// The command line's flags that set config keys, over the configuration (which
223/// `-c` is already in). The open's own flags go to `OpenOptions`.
224fn apply_args(config: &mut AppConfig, args: &Args) {
225    if let Some(nf) = args.number_format.as_deref() {
226        config.display.number_format = config.display.number_format.with_grouping_override(nf);
227    }
228    if let Some(row_numbers) = args.row_numbers {
229        config.display.row_numbers = row_numbers.into();
230    }
231    if let Some(mouse) = args.mouse {
232        config.display.mouse = mouse;
233    }
234    if let Some(rows) = args.sample_rows {
235        config.analysis.sample_rows = rows;
236    }
237    if let Some(path) = &args.log_file {
238        config.log.file = Some(path.to_string_lossy().into_owned());
239    }
240}
241
242/// The paths `input` names, if any.
243pub(crate) fn named_paths(input: &RunInput) -> &[PathBuf] {
244    match input {
245        RunInput::Cli(args) | RunInput::Host(args, None) => &args.paths,
246        RunInput::Host(_, Some(_)) => &[],
247        RunInput::Paths(paths, _) => paths,
248        RunInput::LazyFrame(..) => &[],
249    }
250}
251
252/// The path to name on the screen drawn while the settings are read.
253pub(crate) fn named(input: &RunInput) -> Option<PathBuf> {
254    named_paths(input)
255        .first()
256        .map(|path| crate::loading::stdin::named(path))
257}
258
259/// The screen while the settings are slow to read. The theme and the glyph set are
260/// among them, so it uses neither: the terminal's own colors and plain ASCII.
261pub(crate) fn draw_waiting(frame: &mut Frame, path: Option<&Path>) {
262    let area = frame.area();
263    let mut lines = vec![Line::from("Reading settings...")];
264    if let Some(path) = path {
265        lines.push(Line::from(""));
266        lines.push(Line::from(crate::home::display_path(path)));
267    }
268    let height = (lines.len() as u16).min(area.height);
269    let top = area.y + area.height.saturating_sub(height) / 2;
270    frame.render_widget(
271        Paragraph::new(lines).centered(),
272        Rect {
273            y: top,
274            height,
275            ..area
276        },
277    );
278}
279
280/// The terminal's answer about its background, if it is in by the first frame: the
281/// one taken while the settings were read, else one already on `rx`. Never waits;
282/// everything else found on `rx` goes on `backlog`, in order.
283pub(crate) fn take_answer(
284    rx: &Receiver<AppEvent>,
285    answered: Option<ThemeMode>,
286    backlog: &mut Vec<AppEvent>,
287) -> Option<ThemeMode> {
288    let mut answered = answered;
289    while let Ok(event) = rx.try_recv() {
290        match event {
291            AppEvent::TerminalBackground(mode) => answered = Some(mode),
292            event => backlog.push(event),
293        }
294    }
295    answered
296}
297
298#[cfg(test)]
299mod tests;