1use 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
22pub(crate) const GRACE: Duration = Duration::from_millis(25);
25
26pub struct Settings {
28 pub(crate) config: AppConfig,
29 pub(crate) theme: Theme,
30 pub(crate) input: RunInput,
31 pub(crate) opts: OpenOptions,
32 pub(crate) notes: Vec<String>,
35}
36
37pub(crate) fn read(input: RunInput, config: Option<AppConfig>) -> Result<Settings> {
40 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 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 let mut config = config;
113 let mut notes = Vec::new();
114 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 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 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
164pub(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 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
181pub(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
206fn 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
226pub(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
236pub(crate) fn named(input: &RunInput) -> Option<PathBuf> {
238 named_paths(input)
239 .first()
240 .map(|path| crate::stdin::named(path))
241}
242
243pub(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
264pub(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 #[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 #[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 #[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 #[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 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 #[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}