Skip to main content

datui_lib/
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/// Read the settings: the configuration (unless one was given), the command line over
38/// it, `[cloud] env_files`, the log.
39pub(crate) fn read(input: RunInput, config: Option<AppConfig>) -> Result<Settings> {
40    // `--log-level`, then `-c log.level`, then `DATUI_LOG`; the files' `log.level` is
41    // read below, under all three.
42    let log_level = match &input {
43        RunInput::Cli(args) | RunInput::Host(args, _) => args.log_level.clone().or_else(|| {
44            args.config
45                .iter()
46                .rev()
47                .find(|o| o.key == "log.level")
48                .and_then(|o| o.value.as_str().map(str::to_string))
49        }),
50        _ => None,
51    }
52    .or_else(|| std::env::var("DATUI_LOG").ok());
53    let config = match config {
54        Some(config) => config,
55        None => load_config(&input)?,
56    };
57    crate::user_agent::configure(&config.http.user_agent);
58    let (input, config) = match input {
59        RunInput::Cli(args) => {
60            let mut config = config;
61            apply_args(&mut config, &args);
62            let opts = OpenOptions::from_args_and_config(&args, &config);
63            // Only the command line reads standard input: a host such as Python has
64            // its own, and it is not data.
65            let piped = crate::stdin::piped();
66            let paths = crate::stdin::paths_or_stdin(args.paths, piped);
67            if let Some(refused) = crate::stdin::refuse(&paths, piped) {
68                return Err(color_eyre::eyre::eyre!(refused));
69            }
70            if let Some(tee) = &opts.tee {
71                if !paths.iter().any(|path| crate::stdin::is_stdin(path)) {
72                    return Err(color_eyre::eyre::eyre!(
73                        "--tee records standard input: pipe data in, as in: some_logger | datui --tee run1.csv -"
74                    ));
75                }
76                if !opts.force && !crate::stdin::is_stdin(tee) && tee.exists() {
77                    return Err(color_eyre::eyre::eyre!(crate::tee::refusal(tee)));
78                }
79            }
80            if opts.follow && paths.is_empty() {
81                return Err(color_eyre::eyre::eyre!(
82                    "--follow needs a file to follow, or data piped in: some_logger | datui -f -"
83                ));
84            }
85            (RunInput::Paths(paths, opts), config)
86        }
87        RunInput::Host(args, frame) => {
88            let mut config = config;
89            apply_args(&mut config, &args);
90            let opts = OpenOptions::from_args_and_config(&args, &config);
91            match frame {
92                Some(lf) => (RunInput::LazyFrame(lf, opts), config),
93                None => {
94                    let paths = args.paths.into_iter().map(crate::stdin::as_file).collect();
95                    (RunInput::Paths(paths, opts), config)
96                }
97            }
98        }
99        RunInput::Paths(paths, opts) => {
100            let paths = paths.into_iter().map(crate::stdin::as_file).collect();
101            (RunInput::Paths(paths, opts), config)
102        }
103        input => (input, config),
104    };
105    let opts = match &input {
106        RunInput::Paths(_, o) | RunInput::LazyFrame(_, o) => o.clone(),
107        RunInput::Cli(_) | RunInput::Host(..) => unreachable!("resolved above"),
108    };
109    // The home screen has no `OpenOptions` of its own, so the CLI and environment S3
110    // overrides are folded into the config here, once, for discovery, listing and
111    // opens started from a listed bucket.
112    let mut config = config;
113    let mut notes = Vec::new();
114    // Variables from `[cloud] env_files` first, so everything below sees them.
115    if let Ok(dir) = std::env::current_dir() {
116        notes.extend(crate::cloud_env::load(&config.cloud, &dir));
117    }
118    config.cloud = opts.effective_cloud(&config.cloud);
119    // Said on stderr while it was the log: said again once the terminal is back.
120    notes.extend(config.theme.warnings());
121
122    let cache_dir = crate::cache::CacheManager::new(APP_NAME).ok();
123    notes.extend(logging::init(&logging::LogSettings::resolve(
124        config.log.file.as_deref(),
125        log_level.as_deref().or(config.log.level.as_deref()),
126        cache_dir.as_ref().map(|c| c.cache_dir()),
127    )));
128    for secret in [
129        &config.cloud.s3_access_key_id,
130        &config.cloud.s3_secret_access_key,
131    ]
132    .into_iter()
133    .flatten()
134    {
135        logging::keep_out_of_log(secret);
136    }
137    // A connection's keys come from variables it names, which need not look secret.
138    for connection in &config.cloud.connections {
139        for name in [
140            &connection.secret_access_key_env,
141            &connection.session_token_env,
142            &connection.account_key_env,
143        ]
144        .into_iter()
145        .flatten()
146        {
147            if let Some(value) = crate::cloud_env::var(name) {
148                logging::keep_out_of_log(&value);
149            }
150        }
151    }
152
153    let theme = Theme::from_config(&config.theme)
154        .or_else(|e| Theme::from_config(&AppConfig::default().theme).map_err(|_| e))?;
155    Ok(Settings {
156        config,
157        theme,
158        input,
159        opts,
160        notes,
161    })
162}
163
164/// The configuration file, read. From the command line its error says how to get
165/// past it; a library caller gets the error as it is.
166pub(crate) fn load_config(input: &RunInput) -> Result<AppConfig> {
167    let overrides = match input {
168        RunInput::Cli(args) | RunInput::Host(args, _) => args.config.as_slice(),
169        _ => &[],
170    };
171    AppConfig::load_with(APP_NAME, overrides).map_err(|e| match input {
172        // In full: a TOML parse error's later lines show the offending line and why.
173        RunInput::Cli(_) => color_eyre::eyre::eyre!(
174            "{e}\nFix the configuration and try again, or remove/rename the config file to \
175             use defaults."
176        ),
177        _ => e,
178    })
179}
180
181/// `input` with a leading `~` expanded in the paths its command line names: the
182/// datasets, `--format FILE`, `--dict` and `--temp-dir`. `--log-file` expands with
183/// `[log] file`.
184pub(crate) fn expand_home(input: RunInput) -> RunInput {
185    match input {
186        RunInput::Cli(mut args) => {
187            let spec = match args.format.as_mut() {
188                Some(crate::cli::FormatChoice::File(path)) => Some(path),
189                _ => None,
190            };
191            for path in args
192                .paths
193                .iter_mut()
194                .chain(spec)
195                .chain(args.dict.iter_mut())
196                .chain(args.temp_dir.as_mut())
197            {
198                *path = crate::config::expand_home(path);
199            }
200            RunInput::Cli(args)
201        }
202        input => input,
203    }
204}
205
206/// The command line's flags that set config keys, over the configuration (which
207/// `-c` is already in). The open's own flags go to `OpenOptions`.
208fn apply_args(config: &mut AppConfig, args: &Args) {
209    if let Some(nf) = args.number_format.as_deref() {
210        config.display.number_format = config.display.number_format.with_grouping_override(nf);
211    }
212    if let Some(row_numbers) = args.row_numbers {
213        config.display.row_numbers = row_numbers.into();
214    }
215    if let Some(mouse) = args.mouse {
216        config.display.mouse = mouse;
217    }
218    if let Some(rows) = args.sample_rows {
219        config.analysis.sample_rows = rows;
220    }
221    if let Some(path) = &args.log_file {
222        config.log.file = Some(path.to_string_lossy().into_owned());
223    }
224}
225
226/// The paths `input` names, if any.
227pub(crate) fn named_paths(input: &RunInput) -> &[PathBuf] {
228    match input {
229        RunInput::Cli(args) | RunInput::Host(args, None) => &args.paths,
230        RunInput::Host(_, Some(_)) => &[],
231        RunInput::Paths(paths, _) => paths,
232        RunInput::LazyFrame(..) => &[],
233    }
234}
235
236/// The path to name on the screen drawn while the settings are read.
237pub(crate) fn named(input: &RunInput) -> Option<PathBuf> {
238    named_paths(input)
239        .first()
240        .map(|path| crate::stdin::named(path))
241}
242
243/// The screen while the settings are slow to read. The theme and the glyph set are
244/// among them, so it uses neither: the terminal's own colors and plain ASCII.
245pub(crate) fn draw_waiting(frame: &mut Frame, path: Option<&Path>) {
246    let area = frame.area();
247    let mut lines = vec![Line::from("Reading settings...")];
248    if let Some(path) = path {
249        lines.push(Line::from(""));
250        lines.push(Line::from(crate::home::display_path(path)));
251    }
252    let height = (lines.len() as u16).min(area.height);
253    let top = area.y + area.height.saturating_sub(height) / 2;
254    frame.render_widget(
255        Paragraph::new(lines).centered(),
256        Rect {
257            y: top,
258            height,
259            ..area
260        },
261    );
262}
263
264/// The terminal's answer about its background, if it is in by the first frame: the
265/// one taken while the settings were read, else one already on `rx`. Never waits;
266/// everything else found on `rx` goes on `backlog`, in order.
267pub(crate) fn take_answer(
268    rx: &Receiver<AppEvent>,
269    answered: Option<ThemeMode>,
270    backlog: &mut Vec<AppEvent>,
271) -> Option<ThemeMode> {
272    let mut answered = answered;
273    while let Ok(event) = rx.try_recv() {
274        match event {
275            AppEvent::TerminalBackground(mode) => answered = Some(mode),
276            event => backlog.push(event),
277        }
278    }
279    answered
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use clap::Parser;
286
287    /// A terminal that never answers holds nothing up: with the sender alive and
288    /// nothing sent, the first frame's palette is settled at once.
289    #[test]
290    fn a_silent_terminal_does_not_hold_the_first_frame() {
291        let (tx, rx) = std::sync::mpsc::channel::<AppEvent>();
292        let mut backlog = Vec::new();
293        let started = std::time::Instant::now();
294        assert_eq!(take_answer(&rx, None, &mut backlog), None);
295        assert!(started.elapsed() < Duration::from_millis(20));
296        assert!(backlog.is_empty());
297        drop(tx);
298    }
299
300    /// An answer already in is taken; keys around it keep their order.
301    #[test]
302    fn an_answer_already_in_is_taken() {
303        use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
304        let (tx, rx) = std::sync::mpsc::channel::<AppEvent>();
305        let key = |c| {
306            AppEvent::Terminal(Event::Key(KeyEvent::new(
307                KeyCode::Char(c),
308                KeyModifiers::NONE,
309            )))
310        };
311        tx.send(key('j')).unwrap();
312        tx.send(AppEvent::TerminalBackground(ThemeMode::Light))
313            .unwrap();
314        tx.send(key('k')).unwrap();
315        let mut backlog = Vec::new();
316        assert_eq!(
317            take_answer(&rx, Some(ThemeMode::Dark), &mut backlog),
318            Some(ThemeMode::Light)
319        );
320        let typed: Vec<_> = backlog
321            .iter()
322            .map(|event| match event {
323                AppEvent::Terminal(Event::Key(key)) => key.code,
324                _ => KeyCode::Null,
325            })
326            .collect();
327        assert_eq!(typed, [KeyCode::Char('j'), KeyCode::Char('k')]);
328        assert_eq!(
329            take_answer(&rx, Some(ThemeMode::Dark), &mut Vec::new()),
330            Some(ThemeMode::Dark)
331        );
332    }
333
334    /// `--mouse=false` leaves the mouse to the terminal over a config that takes it,
335    /// `--mouse` takes it over one that does not, and no flag keeps the config's.
336    #[test]
337    fn the_mouse_flag_overrides_the_config() {
338        let after = |flags: &[&str], configured: bool| {
339            let args = Args::try_parse_from(std::iter::once("datui").chain(flags.iter().copied()))
340                .expect("parses");
341            let mut config = AppConfig::default();
342            config.display.mouse = configured;
343            apply_args(&mut config, &args);
344            config.display.mouse
345        };
346        assert!(!after(&["--mouse=false"], true));
347        assert!(after(&["--mouse"], false));
348        assert!(after(&["--mouse=true"], false));
349        assert!(after(&[], true));
350        assert!(!after(&[], false));
351    }
352
353    /// A key is read from the file, `-c` beats the file, and a flag beats `-c`.
354    #[test]
355    fn a_flag_beats_dash_c_which_beats_the_file() {
356        let dir = tempfile::tempdir().unwrap();
357        let file = dir.path().join("config.toml");
358        std::fs::write(
359            &file,
360            "[analysis]\nsample_rows = 10\n[display]\nrow_numbers_start = 5\n",
361        )
362        .unwrap();
363        let effective = |flags: &[&str]| {
364            let args = Args::try_parse_from(std::iter::once("datui").chain(flags.iter().copied()))
365                .expect("parses");
366            let mut config = AppConfig::load_from_file_with(&file, &args.config).unwrap();
367            apply_args(&mut config, &args);
368            (
369                config.analysis.sample_rows,
370                config.display.row_numbers_start,
371            )
372        };
373        assert_eq!(effective(&[]), (10, 5));
374        assert_eq!(effective(&["-c", "analysis.sample_rows=20"]), (20, 5));
375        assert_eq!(
376            effective(&["-c", "analysis.sample_rows=20", "--sample-rows", "30"]),
377            (30, 5)
378        );
379        // The last `-c` of a key wins, and keys it does not name keep the file's.
380        assert_eq!(
381            effective(&[
382                "-c",
383                "display.row_numbers_start=0",
384                "-c",
385                "display.row_numbers_start=2"
386            ]),
387            (10, 2)
388        );
389    }
390
391    /// A `-c` value of the right shape for its key but not for datui says it came
392    /// from `-c`.
393    #[test]
394    fn a_dash_c_the_config_cannot_read_is_named() {
395        let args =
396            Args::try_parse_from(["datui", "-c", "display.number_format=[1, 2]"]).expect("parses");
397        let dir = tempfile::tempdir().unwrap();
398        let error = AppConfig::load_from_file_with(&dir.path().join("none.toml"), &args.config)
399            .unwrap_err()
400            .to_string();
401        assert!(error.starts_with("-c:"), "{error}");
402    }
403
404    #[test]
405    fn the_command_line_s_paths_expand_a_leading_tilde() {
406        let home = dirs::home_dir().expect("a home directory");
407        let args = Args::try_parse_from([
408            "datui",
409            "~/a.csv",
410            "b.csv",
411            "--temp-dir",
412            "~/scratch",
413            "--format",
414            "~/l2feed.toml",
415            "--dict",
416            "~/car.dbc",
417        ])
418        .unwrap();
419        let RunInput::Cli(args) = expand_home(RunInput::Cli(Box::new(args))) else {
420            panic!("still the command line");
421        };
422        assert_eq!(args.paths, [home.join("a.csv"), PathBuf::from("b.csv")]);
423        assert_eq!(args.temp_dir, Some(home.join("scratch")));
424        assert_eq!(
425            args.format,
426            Some(crate::cli::FormatChoice::File(home.join("l2feed.toml")))
427        );
428        assert_eq!(args.dict, [home.join("car.dbc")]);
429    }
430}