//! How to handle config files and arguments. // TODO: Break this apart or do something a bit smarter. pub mod args; pub mod config; mod error; use std::{ convert::TryInto, fs, io::Write, path::{Path, PathBuf}, str::FromStr, time::{Duration, Instant}, }; use anyhow::{Context, Result}; pub use config::Config; use config::style::Styles; use data::TemperatureType; pub(crate) use error::{OptionError, OptionResult}; use hashbrown::{HashMap, HashSet}; use indexmap::IndexSet; use regex::Regex; #[cfg(feature = "battery")] use starship_battery::Manager; use self::{ args::BottomArgs, config::{IgnoreList, StringOrNum, layout::Row}, }; use crate::{ app::{filter::Filter, layout_manager::*, *}, canvas::components::time_graph::LegendPosition, constants::*, utils::data_units::DataUnit, widgets::*, }; macro_rules! is_flag_enabled { ($flag_name:ident, $arg:expr, $config:expr) => { if $arg.$flag_name { true } else if let Some(flags) = &$config.flags { flags.$flag_name.unwrap_or(false) } else { false } }; ($cmd_flag:literal, $cfg_flag:ident, $matches:expr, $config:expr) => { if $matches.get_flag($cmd_flag) { true } else if let Some(flags) = &$config.flags { flags.$cfg_flag.unwrap_or(false) } else { false } }; } /// A new version if [`is_flag_enabled`] which instead expects the user to pass in `config_section`, which is /// the section the flag is located, rather than defaulting to `config.flags` where `config` is passed in. macro_rules! is_flag_enabled_new { ($flag_name:ident, $arg:expr, $config_section:expr) => { if $arg.$flag_name { true } else if let Some(options) = &$config_section { options.$flag_name.unwrap_or(false) } else { false } }; } /// The default config file sub-path. const DEFAULT_CONFIG_FILE_LOCATION: &str = "bottom/bottom.toml"; /// Returns the config path to use. If `override_config_path` is specified, then /// we will use that. If not, then return the "default" config path, which is: /// /// - If a path already exists at `/bottom/bottom.toml`, then use that for /// legacy reasons. /// - Otherwise, use `/bottom/bottom.toml`. /// /// For more details on this, see [dirs](https://docs.rs/dirs/latest/dirs/fn.config_dir.html)' /// documentation. /// /// XXX: For macOS, we additionally will manually check `$XDG_CONFIG_HOME` as well first /// before falling back to `dirs`. fn get_config_path(override_config_path: Option<&Path>) -> Option { if let Some(conf_loc) = override_config_path { return Some(conf_loc.to_path_buf()); } else if let Some(home_path) = dirs::home_dir() { let mut old_home_path = home_path; old_home_path.push(".config/"); old_home_path.push(DEFAULT_CONFIG_FILE_LOCATION); if let Ok(res) = old_home_path.try_exists() { if res { // We used to create it at `/DEFAULT_CONFIG_FILE_PATH`, but changed it // to be more correct later. However, for legacy reasons, if it already exists, // use the old one. return Some(old_home_path); } } } let config_path = dirs::config_dir().map(|mut path| { path.push(DEFAULT_CONFIG_FILE_LOCATION); path }); if cfg!(target_os = "macos") { if let Ok(xdg_config_path) = std::env::var("XDG_CONFIG_HOME") { if !xdg_config_path.is_empty() { // If XDG_CONFIG_HOME exists and is non-empty, _but_ we previously used the Library-based path // for a config and it exists, then use that instead for backwards-compatibility. if let Some(old_macos_path) = &config_path { if let Ok(res) = old_macos_path.try_exists() { if res { return config_path; } } } // Otherwise, try and use the XDG_CONFIG_HOME-based path. let mut cfg_path = PathBuf::new(); cfg_path.push(xdg_config_path); cfg_path.push(DEFAULT_CONFIG_FILE_LOCATION); return Some(cfg_path); } } } config_path } fn create_config_at_path(path: &Path) -> anyhow::Result { if let Some(parent_path) = path.parent() { fs::create_dir_all(parent_path)?; } let mut file = fs::File::create(path)?; file.write_all(CONFIG_TEXT.as_bytes())?; Ok(Config::default()) } /// Get the config at `config_path`. If there is no config file at the specified /// path, it will try to create a new file with the default settings, and return /// the default config. /// /// We're going to use the following behaviour on when we'll return an error rather /// than just "silently" continuing on: /// - If the user passed in a path explicitly, then we will be loud and error out. /// - If the user does NOT pass in a path explicitly, then just show a warning, /// but continue. This is in case they do not want to write a default config file at /// the XDG locations, for example. pub(crate) fn get_or_create_config(config_path: Option<&Path>) -> anyhow::Result { let adjusted_config_path = get_config_path(config_path); match &adjusted_config_path { Some(path) => { if let Ok(config_string) = fs::read_to_string(path) { Ok(toml_edit::de::from_str(&config_string)?) } else { match create_config_at_path(path) { Ok(cfg) => Ok(cfg), Err(err) => { if config_path.is_some() { Err(err.context(format!( "bottom could not create a new config file at '{}'.", path.display() ))) } else { indoc::eprintdoc!( "Note: bottom couldn't create a default config file at '{}', and the \ application has fallen back to the default configuration. Caused by: {err} ", path.display() ); Ok(Config::default()) } } } } } None => { // If we somehow don't have any config path, then just assume the default config // but don't write to any file. // // TODO: For now, just print a message to stderr indicating this. In the future, // probably show in-app (too). eprintln!( "Note: bottom couldn't find a location to create or read a config file, so \ the application has fallen back to the default configuration. \ This could be for a variety of reasons, such as issues with file permissions." ); Ok(Config::default()) } } } /// Initialize the app. pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomLayout, Styles)> { use BottomWidgetType::*; // Since everything takes a reference, but we want to take ownership here to // drop matches/config later... let args = &args; let config = &config; let styling = Styles::new(args, config)?; let (widget_layout, default_widget_id, default_widget_type_option) = get_widget_layout(args, config) .context("Found an issue while trying to build the widget layout.")?; let retention_ms = get_retention(args, config)?; let autohide_time = is_flag_enabled!(autohide_time, args.general, config); let default_time_value = get_default_time_value(args, config, retention_ms)?; let use_basic_mode = is_flag_enabled!(basic, args.general, config); let expanded = is_flag_enabled!(expanded, args.general, config); // For processes let is_grouped = is_flag_enabled!(group_processes, args.process, config); let is_case_sensitive = is_flag_enabled!(case_sensitive, args.process, config); let is_match_whole_word = is_flag_enabled!(whole_word, args.process, config); let is_use_regex = is_flag_enabled!(regex, args.process, config); let is_default_tree = is_flag_enabled!(tree, args.process, config); let is_default_command = is_flag_enabled!(process_command, args.process, config); #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] let is_advanced_kill = !(is_flag_enabled!(disable_advanced_kill, args.process, config)); #[cfg(target_os = "linux")] let hide_k_threads = is_flag_enabled!(hide_k_threads, args.process, config); let process_memory_as_value = is_flag_enabled!(process_memory_as_value, args.process, config); let is_default_tree_collapsed = is_flag_enabled!(tree_collapse, args.process, config); // For CPU let default_cpu_selection = get_default_cpu_selection(args, config); let mut widget_map = HashMap::new(); let mut cpu_state_map: HashMap = HashMap::new(); let mut mem_state_map: HashMap = HashMap::new(); let mut net_state_map: HashMap = HashMap::new(); let mut proc_state_map: HashMap = HashMap::new(); let mut temp_state_map: HashMap = HashMap::new(); let mut disk_state_map: HashMap = HashMap::new(); let mut battery_state_map: HashMap = HashMap::new(); let autohide_timer = if autohide_time { Some(Instant::now()) } else { None }; let mut initial_widget_id: u64 = default_widget_id; let mut initial_widget_type = Proc; let is_custom_layout = config.row.is_some(); let mut used_widget_set = HashSet::new(); let network_unit_type = get_network_unit_type(args, config); let network_scale_type = get_network_scale_type(args, config); let network_use_binary_prefix = is_flag_enabled!(network_use_binary_prefix, args.network, config); let proc_columns: Option> = { config.processes.as_ref().and_then(|cfg| { if cfg.columns.is_empty() { None } else { // TODO: Should we be using an indexmap? Or maybe allow dupes. Some(IndexSet::from_iter( cfg.columns.iter().map(ProcWidgetColumn::from), )) } }) }; let network_legend_position = get_network_legend_position(args, config)?; let memory_legend_position = get_memory_legend_position(args, config)?; // TODO: Can probably just reuse the options struct. let app_config_fields = AppConfigFields { update_rate: get_update_rate(args, config)?, temperature_type: get_temperature(args, config) .context("Update 'temperature_type' in your config file.")?, show_average_cpu: get_show_average_cpu(args, config), use_dot: is_flag_enabled!(dot_marker, args.general, config), cpu_left_legend: is_flag_enabled!(cpu_left_legend, args.cpu, config), use_current_cpu_total: is_flag_enabled!(current_usage, args.process, config), unnormalized_cpu: is_flag_enabled!(unnormalized_cpu, args.process, config), get_process_threads: is_flag_enabled_new!(get_threads, args.process, config.processes), use_basic_mode, default_time_value, time_interval: get_time_interval(args, config, retention_ms)?, hide_time: is_flag_enabled!(hide_time, args.general, config), autohide_time, use_old_network_legend: is_flag_enabled!(use_old_network_legend, args.network, config), table_gap: u16::from(!(is_flag_enabled!(hide_table_gap, args.general, config))), disable_click: is_flag_enabled!(disable_click, args.general, config), disable_keys: is_flag_enabled!(disable_keys, args.general, config), enable_gpu: get_enable_gpu(args, config), enable_cache_memory: get_enable_cache_memory(args, config), show_table_scroll_position: is_flag_enabled!( show_table_scroll_position, args.general, config ), #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] is_advanced_kill, #[cfg(target_os = "linux")] hide_k_threads, memory_legend_position, network_legend_position, network_scale_type, network_unit_type, network_use_binary_prefix, retention_ms, dedicated_average_row: get_dedicated_avg_row(config), default_tree_collapse: is_default_tree_collapsed, }; let table_config = ProcTableConfig { is_case_sensitive, is_match_whole_word, is_use_regex, show_memory_as_values: process_memory_as_value, is_command: is_default_command, }; for row in &widget_layout.rows { for col in &row.children { for col_row in &col.children { for widget in &col_row.children { widget_map.insert(widget.widget_id, widget.clone()); if let Some(default_widget_type) = &default_widget_type_option { if !is_custom_layout || use_basic_mode { match widget.widget_type { BasicCpu => { if let Cpu = *default_widget_type { initial_widget_id = widget.widget_id; initial_widget_type = Cpu; } } BasicMem => { if let Mem = *default_widget_type { initial_widget_id = widget.widget_id; initial_widget_type = Cpu; } } BasicNet => { if let Net = *default_widget_type { initial_widget_id = widget.widget_id; initial_widget_type = Cpu; } } _ => { if *default_widget_type == widget.widget_type { initial_widget_id = widget.widget_id; initial_widget_type = widget.widget_type.clone(); } } } } } used_widget_set.insert(widget.widget_type.clone()); match widget.widget_type { Cpu => { cpu_state_map.insert( widget.widget_id, CpuWidgetState::new( &app_config_fields, default_cpu_selection, default_time_value, autohide_timer, &styling, ), ); } Mem => { mem_state_map.insert( widget.widget_id, MemWidgetState::init(default_time_value, autohide_timer), ); } Net => { net_state_map.insert( widget.widget_id, NetWidgetState::init(default_time_value, autohide_timer), ); } Proc => { let mode = if is_grouped { ProcWidgetMode::Grouped } else if is_default_tree { ProcWidgetMode::Tree(TreeCollapsed::new(is_default_tree_collapsed)) } else { ProcWidgetMode::Normal }; proc_state_map.insert( widget.widget_id, ProcWidgetState::new( &app_config_fields, mode, table_config, &styling, &proc_columns, ), ); } Disk => { disk_state_map.insert( widget.widget_id, DiskTableWidget::new( &app_config_fields, &styling, config.disk.as_ref().and_then(|cfg| cfg.columns.as_deref()), ), ); } Temp => { temp_state_map.insert( widget.widget_id, TempWidgetState::new(&app_config_fields, &styling), ); } Battery => { battery_state_map .insert(widget.widget_id, BatteryWidgetState::default()); } _ => {} } } } } } let basic_table_widget_state = if use_basic_mode { Some(match initial_widget_type { Proc | Disk | Temp => BasicTableWidgetState { currently_displayed_widget_type: initial_widget_type, currently_displayed_widget_id: initial_widget_id, left_tlc: None, left_brc: None, right_tlc: None, right_brc: None, }, _ => BasicTableWidgetState { currently_displayed_widget_type: Proc, currently_displayed_widget_id: DEFAULT_WIDGET_ID, left_tlc: None, left_brc: None, right_tlc: None, right_brc: None, }, }) } else { None }; let use_mem = used_widget_set.get(&Mem).is_some() || used_widget_set.get(&BasicMem).is_some(); let used_widgets = UsedWidgets { use_cpu: used_widget_set.get(&Cpu).is_some() || used_widget_set.get(&BasicCpu).is_some(), use_mem, use_cache: use_mem && get_enable_cache_memory(args, config), use_gpu: get_enable_gpu(args, config), use_net: used_widget_set.get(&Net).is_some() || used_widget_set.get(&BasicNet).is_some(), use_proc: used_widget_set.get(&Proc).is_some(), use_disk: used_widget_set.get(&Disk).is_some(), use_temp: used_widget_set.get(&Temp).is_some(), use_battery: used_widget_set.get(&Battery).is_some(), }; let (disk_name_filter, disk_mount_filter) = { match &config.disk { Some(cfg) => { let df = get_ignore_list(&cfg.name_filter) .context("Update 'disk.name_filter' in your config file")?; let mf = get_ignore_list(&cfg.mount_filter) .context("Update 'disk.mount_filter' in your config file")?; (df, mf) } None => (None, None), } }; let temp_sensor_filter = match &config.temperature { Some(cfg) => get_ignore_list(&cfg.sensor_filter) .context("Update 'temperature.sensor_filter' in your config file")?, None => None, }; let net_interface_filter = match &config.network { Some(cfg) => get_ignore_list(&cfg.interface_filter) .context("Update 'network.interface_filter' in your config file")?, None => None, }; let states = AppWidgetStates { cpu_state: CpuState::init(cpu_state_map), mem_state: MemState::init(mem_state_map), net_state: NetState::init(net_state_map), proc_state: ProcState::init(proc_state_map), temp_state: TempState::init(temp_state_map), disk_state: DiskState::init(disk_state_map), battery_state: AppBatteryState::init(battery_state_map), basic_table_widget_state, }; let current_widget = widget_map.get(&initial_widget_id).unwrap().clone(); let filters = DataFilters { disk_filter: disk_name_filter, mount_filter: disk_mount_filter, temp_filter: temp_sensor_filter, net_filter: net_interface_filter, }; let is_expanded = expanded && !use_basic_mode; Ok(( App::new( app_config_fields, states, widget_map, current_widget, used_widgets, filters, is_expanded, ), widget_layout, styling, )) } fn get_widget_layout( args: &BottomArgs, config: &Config, ) -> OptionResult<(BottomLayout, u64, Option)> { let cpu_left_legend = is_flag_enabled!(cpu_left_legend, args.cpu, config); let (default_widget_type, mut default_widget_count) = get_default_widget_and_count(args, config)?; let mut default_widget_id = 1; let bottom_layout = if is_flag_enabled!(basic, args.general, config) { default_widget_id = DEFAULT_WIDGET_ID; BottomLayout::init_basic_default(get_use_battery(args, config)) } else { let ref_row: Vec; // Required to handle reference let rows = match &config.row { Some(r) => r, None => { // This cannot (like it really shouldn't) fail! ref_row = toml_edit::de::from_str::(if get_use_battery(args, config) { DEFAULT_BATTERY_LAYOUT } else { DEFAULT_LAYOUT })? .row .unwrap(); &ref_row } }; let mut iter_id = 0; // A lazy way of forcing unique IDs *shrugs* let mut total_height_ratio = 0; let mut ret_bottom_layout = BottomLayout { rows: rows .iter() .map(|row| { row.convert_row_to_bottom_row( &mut iter_id, &mut total_height_ratio, &mut default_widget_id, &default_widget_type, &mut default_widget_count, cpu_left_legend, ) .map_err(|err| OptionError::config(err.to_string())) }) .collect::>>()?, total_row_height_ratio: total_height_ratio, }; // Confirm that we have at least ONE widget left - if not, error out! if iter_id > 0 { ret_bottom_layout.get_movement_mappings(); ret_bottom_layout } else { return Err(OptionError::config( "have at least one widget under the '[[row]]' section.", )); } }; Ok((bottom_layout, default_widget_id, default_widget_type)) } #[inline] fn try_parse_ms(s: &str) -> Result { Ok(if let Ok(val) = humantime::parse_duration(s) { val.as_millis().try_into().map_err(|_| ())? } else if let Ok(val) = s.parse::() { val } else { return Err(()); }) } macro_rules! parse_arg_value { ($to_try:expr, $flag:literal) => { $to_try.map_err(|_| OptionError::invalid_arg_value($flag)) }; } macro_rules! parse_config_value { ($to_try:expr, $setting:literal) => { $to_try.map_err(|_| OptionError::invalid_config_value($setting)) }; } macro_rules! parse_ms_option { ($arg_expr:expr, $config_expr:expr, $default_value:expr, $setting:literal, $low:expr, $high:expr $(,)?) => {{ use humantime::format_duration; if let Some(to_parse) = $arg_expr { let value = parse_arg_value!(try_parse_ms(to_parse), $setting)?; if let Some(limit) = $low { if value < limit { return Err(OptionError::arg(format!( "'--{}' must be greater than {}", $setting, format_duration(Duration::from_millis(limit)) ))); } } if let Some(limit) = $high { if value > limit { return Err(OptionError::arg(format!( "'--{}' must be less than {}", $setting, format_duration(Duration::from_millis(limit)) ))); } } Ok(value) } else if let Some(to_parse) = $config_expr { let value = match to_parse { StringOrNum::String(s) => parse_config_value!(try_parse_ms(s), $setting)?, StringOrNum::Num(n) => *n, }; if let Some(limit) = $low { if value < limit { return Err(OptionError::arg(format!( "'{}' must be greater than {}", $setting, format_duration(Duration::from_millis(limit)) ))); } } if let Some(limit) = $high { if value > limit { return Err(OptionError::arg(format!( "'{}' must be less than {}", $setting, format_duration(Duration::from_millis(limit)) ))); } } Ok(value) } else { Ok($default_value) } }}; } /// How quickly we update data. #[inline] fn get_update_rate(args: &BottomArgs, config: &Config) -> OptionResult { const DEFAULT_REFRESH_RATE_IN_MILLISECONDS: u64 = 1000; parse_ms_option!( &args.general.rate, config.flags.as_ref().and_then(|flags| flags.rate.as_ref()), DEFAULT_REFRESH_RATE_IN_MILLISECONDS, "rate", Some(250), None, ) } fn get_temperature(args: &BottomArgs, config: &Config) -> OptionResult { if args.temperature.fahrenheit { return Ok(TemperatureType::Fahrenheit); } else if args.temperature.kelvin { return Ok(TemperatureType::Kelvin); } else if args.temperature.celsius { return Ok(TemperatureType::Celsius); } else if let Some(flags) = &config.flags { if let Some(temp_type) = &flags.temperature_type { return parse_config_value!(TemperatureType::from_str(temp_type), "temperature_type"); } } Ok(TemperatureType::Celsius) } /// Yes, this function gets whether to show average CPU (true) or not (false). fn get_show_average_cpu(args: &BottomArgs, config: &Config) -> bool { if args.cpu.hide_avg_cpu { return false; } else if let Some(flags) = &config.flags { if let Some(avg_cpu) = flags.hide_avg_cpu { return !avg_cpu; } } true } // I hate this too. fn get_default_cpu_selection(args: &BottomArgs, config: &Config) -> config::cpu::CpuDefault { match &args.cpu.default_cpu_entry { Some(default) => match default { args::CpuDefault::All => config::cpu::CpuDefault::All, args::CpuDefault::Average => config::cpu::CpuDefault::Average, }, None => config.cpu.as_ref().map(|c| c.default).unwrap_or_default(), } } fn get_dedicated_avg_row(config: &Config) -> bool { config .flags .as_ref() .and_then(|flags| flags.average_cpu_row) .unwrap_or(false) } #[inline] fn get_default_time_value( args: &BottomArgs, config: &Config, retention_ms: u64, ) -> OptionResult { const DEFAULT_TIME_MILLISECONDS: u64 = 60 * 1000; // Defaults to 1 min. parse_ms_option!( &args.general.default_time_value, config .flags .as_ref() .and_then(|flags| flags.default_time_value.as_ref()), DEFAULT_TIME_MILLISECONDS, "default_time_value", Some(30000), Some(retention_ms), ) } #[inline] fn get_time_interval(args: &BottomArgs, config: &Config, retention_ms: u64) -> OptionResult { const TIME_CHANGE_MILLISECONDS: u64 = 15 * 1000; // How much to increment each time parse_ms_option!( &args.general.time_delta, config .flags .as_ref() .and_then(|flags| flags.time_delta.as_ref()), TIME_CHANGE_MILLISECONDS, "time_delta", Some(1000), Some(retention_ms), ) } fn get_default_widget_and_count( args: &BottomArgs, config: &Config, ) -> OptionResult<(Option, u64)> { let widget_type = if let Some(widget_type) = &args.general.default_widget_type { let parsed_widget = parse_arg_value!(widget_type.parse(), "default_widget_type")?; if let BottomWidgetType::Empty = parsed_widget { None } else { Some(parsed_widget) } } else if let Some(flags) = &config.flags { if let Some(widget_type) = &flags.default_widget_type { let parsed_widget = parse_config_value!(widget_type.parse(), "default_widget_type")?; if let BottomWidgetType::Empty = parsed_widget { None } else { Some(parsed_widget) } } else { None } } else { None }; let widget_count: Option = if let Some(widget_count) = args.general.default_widget_count { Some(widget_count.into()) } else { config.flags.as_ref().and_then(|flags| { flags .default_widget_count .map(|widget_count| widget_count.into()) }) }; match (widget_type, widget_count) { (Some(widget_type), Some(widget_count)) => { let widget_count = widget_count.try_into().map_err(|_| OptionError::other( "set your widget count to be at most 18446744073709551615.".to_string() ))?; Ok((Some(widget_type), widget_count)) } (Some(widget_type), None) => Ok((Some(widget_type), 1)), (None, Some(_widget_count)) => Err(OptionError::other( "cannot set 'default_widget_count' by itself, it must be used with 'default_widget_type'.".to_string(), )), (None, None) => Ok((None, 1)) } } #[cfg(feature = "battery")] fn get_use_battery(args: &BottomArgs, config: &Config) -> bool { // TODO: Move this so it's dynamic in the app itself and automatically hide if // there are no batteries? if let Ok(battery_manager) = Manager::new() { if let Ok(batteries) = battery_manager.batteries() { if batteries.count() == 0 { return false; } } } if args.battery.battery { return true; } else if let Some(flags) = &config.flags { if let Some(battery) = flags.battery { return battery; } } false } #[cfg(not(feature = "battery"))] fn get_use_battery(_args: &BottomArgs, _config: &Config) -> bool { false } #[cfg(feature = "gpu")] fn get_enable_gpu(args: &BottomArgs, config: &Config) -> bool { if args.gpu.disable_gpu { return false; } !config .flags .as_ref() .and_then(|f| f.disable_gpu) .unwrap_or(false) } #[cfg(not(feature = "gpu"))] fn get_enable_gpu(_: &BottomArgs, _: &Config) -> bool { false } #[cfg(not(target_os = "windows"))] fn get_enable_cache_memory(args: &BottomArgs, config: &Config) -> bool { if args.memory.enable_cache_memory { return true; } else if let Some(flags) = &config.flags { if let Some(enable_cache_memory) = flags.enable_cache_memory { return enable_cache_memory; } } false } #[cfg(target_os = "windows")] fn get_enable_cache_memory(_args: &BottomArgs, _config: &Config) -> bool { false } fn get_ignore_list(ignore_list: &Option) -> OptionResult> { if let Some(ignore_list) = ignore_list { let list: Result, _> = ignore_list .list .iter() .map(|name| { let escaped_string: String; let res = format!( "{}{}{}{}", if ignore_list.whole_word { "^" } else { "" }, if ignore_list.case_sensitive { "" } else { "(?i)" }, if ignore_list.regex { name } else { escaped_string = regex::escape(name); &escaped_string }, if ignore_list.whole_word { "$" } else { "" }, ); Regex::new(&res) }) .collect(); let list = list.map_err(|err| OptionError::config(err.to_string()))?; Ok(Some(Filter::new(ignore_list.is_list_ignored, list))) } else { Ok(None) } } fn get_network_unit_type(args: &BottomArgs, config: &Config) -> DataUnit { if args.network.network_use_bytes { return DataUnit::Byte; } else if let Some(flags) = &config.flags { if let Some(network_use_bytes) = flags.network_use_bytes { if network_use_bytes { return DataUnit::Byte; } } } DataUnit::Bit } fn get_network_scale_type(args: &BottomArgs, config: &Config) -> AxisScaling { if args.network.network_use_log { return AxisScaling::Log; } else if let Some(flags) = &config.flags { if let Some(network_use_log) = flags.network_use_log { if network_use_log { return AxisScaling::Log; } } } AxisScaling::Linear } fn get_retention(args: &BottomArgs, config: &Config) -> OptionResult { const DEFAULT_RETENTION_MS: u64 = 600 * 1000; // Keep 10 minutes of data. parse_ms_option!( &args.general.retention, config .flags .as_ref() .and_then(|flags| flags.retention.as_ref()), DEFAULT_RETENTION_MS, "retention", None, None, ) } fn get_network_legend_position( args: &BottomArgs, config: &Config, ) -> OptionResult> { let result = if let Some(s) = &args.network.network_legend { match s.to_ascii_lowercase().trim() { "none" => None, position => Some(parse_arg_value!(position.parse(), "network_legend")?), } } else if let Some(flags) = &config.flags { if let Some(s) = &flags.network_legend { match s.to_ascii_lowercase().trim() { "none" => None, position => Some(parse_config_value!(position.parse(), "network_legend")?), } } else { Some(LegendPosition::default()) } } else { Some(LegendPosition::default()) }; Ok(result) } fn get_memory_legend_position( args: &BottomArgs, config: &Config, ) -> OptionResult> { let result = if let Some(s) = &args.memory.memory_legend { match s.to_ascii_lowercase().trim() { "none" => None, position => Some(parse_arg_value!(position.parse(), "memory_legend")?), } } else if let Some(flags) = &config.flags { if let Some(s) = &flags.memory_legend { match s.to_ascii_lowercase().trim() { "none" => None, position => Some(parse_config_value!(position.parse(), "memory_legend")?), } } else { Some(LegendPosition::default()) } } else { Some(LegendPosition::default()) }; Ok(result) } #[cfg(test)] mod test { use clap::Parser; use super::{Config, get_time_interval}; use crate::{ app::App, args::BottomArgs, options::{ config::flags::GeneralConfig, get_default_time_value, get_retention, get_update_rate, try_parse_ms, }, }; #[test] fn verify_try_parse_ms() { let a = "100s"; let b = "100"; let c = "1 min"; let d = "1 hour 1 min"; assert_eq!(try_parse_ms(a), Ok(100 * 1000)); assert_eq!(try_parse_ms(b), Ok(100)); assert_eq!(try_parse_ms(c), Ok(60 * 1000)); assert_eq!(try_parse_ms(d), Ok(3660 * 1000)); let a_bad = "1 test"; let b_bad = "-100"; assert!(try_parse_ms(a_bad).is_err()); assert!(try_parse_ms(b_bad).is_err()); } #[test] fn matches_human_times() { let config = Config::default(); { let delta_args = vec!["btm", "--time_delta", "2 min"]; let args = BottomArgs::parse_from(delta_args); assert_eq!( get_time_interval(&args, &config, 60 * 60 * 1000), Ok(2 * 60 * 1000) ); } { let default_time_args = vec!["btm", "--default_time_value", "300s"]; let args = BottomArgs::parse_from(default_time_args); assert_eq!( get_default_time_value(&args, &config, 60 * 60 * 1000), Ok(5 * 60 * 1000) ); } } #[test] fn matches_number_times() { let config = Config::default(); { let delta_args = vec!["btm", "--time_delta", "120000"]; let args = BottomArgs::parse_from(delta_args); assert_eq!( get_time_interval(&args, &config, 60 * 60 * 1000), Ok(2 * 60 * 1000) ); } { let default_time_args = vec!["btm", "--default_time_value", "300000"]; let args = BottomArgs::parse_from(default_time_args); assert_eq!( get_default_time_value(&args, &config, 60 * 60 * 1000), Ok(5 * 60 * 1000) ); } } #[test] fn config_human_times() { let args = BottomArgs::parse_from(["btm"]); let mut config = Config::default(); let flags = GeneralConfig { time_delta: Some("2 min".to_string().into()), default_time_value: Some("300s".to_string().into()), rate: Some("1s".to_string().into()), retention: Some("10m".to_string().into()), ..Default::default() }; config.flags = Some(flags); assert_eq!( get_time_interval(&args, &config, 60 * 60 * 1000), Ok(2 * 60 * 1000) ); assert_eq!( get_default_time_value(&args, &config, 60 * 60 * 1000), Ok(5 * 60 * 1000) ); assert_eq!(get_update_rate(&args, &config), Ok(1000)); assert_eq!(get_retention(&args, &config), Ok(600000)); } #[test] fn config_number_times_as_string() { let args = BottomArgs::parse_from(["btm"]); let mut config = Config::default(); let flags = GeneralConfig { time_delta: Some("120000".to_string().into()), default_time_value: Some("300000".to_string().into()), rate: Some("1000".to_string().into()), retention: Some("600000".to_string().into()), ..Default::default() }; config.flags = Some(flags); assert_eq!( get_time_interval(&args, &config, 60 * 60 * 1000), Ok(2 * 60 * 1000) ); assert_eq!( get_default_time_value(&args, &config, 60 * 60 * 1000), Ok(5 * 60 * 1000) ); assert_eq!(get_update_rate(&args, &config), Ok(1000)); assert_eq!(get_retention(&args, &config), Ok(600000)); } #[test] fn config_number_times_as_num() { let args = BottomArgs::parse_from(["btm"]); let mut config = Config::default(); let flags = GeneralConfig { time_delta: Some(120000.into()), default_time_value: Some(300000.into()), rate: Some(1000.into()), retention: Some(600000.into()), ..Default::default() }; config.flags = Some(flags); assert_eq!( get_time_interval(&args, &config, 60 * 60 * 1000), Ok(2 * 60 * 1000) ); assert_eq!( get_default_time_value(&args, &config, 60 * 60 * 1000), Ok(5 * 60 * 1000) ); assert_eq!(get_update_rate(&args, &config), Ok(1000)); assert_eq!(get_retention(&args, &config), Ok(600000)); } fn create_app(args: BottomArgs) -> App { let config = Config::default(); super::init_app(args, config).unwrap().0 } // TODO: There's probably a better way to create clap options AND unify together // to avoid the possibility of typos/mixing up. Use proc macros to unify on // one struct? #[test] fn verify_cli_options_build() { let app = crate::args::build_cmd(); let default_app = create_app(BottomArgs::parse_from(["btm"])); // Skip battery since it's tricky to test depending on the platform/features // we're testing with. let skip = ["help", "version", "celsius", "battery", "generate_schema"]; for arg in app.get_arguments().collect::>() { let arg_name = arg .get_long_and_visible_aliases() .unwrap() .first() .unwrap() .to_owned(); if !arg.get_action().takes_values() && !skip.contains(&arg_name) { let arg = format!("--{arg_name}"); let arguments = vec!["btm", &arg]; let args = BottomArgs::parse_from(arguments); let testing_app = create_app(args); if (default_app.app_config_fields == testing_app.app_config_fields) && default_app.is_expanded == testing_app.is_expanded && default_app .states .proc_state .widget_states .iter() .zip(testing_app.states.proc_state.widget_states.iter()) .all(|(a, b)| a.1.test_equality(b.1)) { panic!("failed on {arg_name}"); } } } } /// This one has slightly more complex behaviour due to `dirs` not respecting XDG on macOS, so we manually /// handle it. However, to ensure backwards-compatibility, we also have to do some special cases. #[cfg(target_os = "macos")] #[test] fn test_get_config_path_macos() { use std::path::PathBuf; use super::{DEFAULT_CONFIG_FILE_LOCATION, get_config_path}; // Case three: no previous config, no XDG var. // SAFETY: This is fine, this is just a test, and no other test affects env vars. unsafe { std::env::remove_var("XDG_CONFIG_HOME"); } let case_1 = dirs::config_dir() .map(|mut path| { path.push(DEFAULT_CONFIG_FILE_LOCATION); path }) .unwrap(); // Skip this test if the file already exists. if !case_1.exists() { assert_eq!(get_config_path(None), Some(case_1)); } // Case two: no previous config, XDG var exists. // SAFETY: This is fine, this is just a test, and no other test affects env vars. unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp"); } let mut case_2 = PathBuf::new(); case_2.push("/tmp"); case_2.push(DEFAULT_CONFIG_FILE_LOCATION); // Skip this test if the file already exists. if !case_2.exists() { assert_eq!(get_config_path(None), Some(case_2)); } // Case one: old non-XDG exists already, XDG var exists. // let case_3 = case_1; // assert_eq!(get_config_path(None), Some(case_1)); } }