tealdeer/src/config.rs
2026-02-21 00:26:40 +01:00

826 lines
26 KiB
Rust

use std::{
env, fmt,
fs::{self, File},
io::{ErrorKind, Write},
path::{Path, PathBuf},
sync::LazyLock,
time::Duration,
};
use anyhow::{anyhow, bail, ensure, Context, Result};
use app_dirs::{get_app_root, AppDataType};
use clap::ValueEnum;
use serde::Serialize as _;
use serde_derive::{Deserialize, Serialize};
use yansi::{Color, Style};
use crate::{
extensions::Dedup as _,
types::{PathSource, PlatformType},
};
pub const CONFIG_FILE_NAME: &str = "config.toml";
pub const MAX_CACHE_AGE: Duration = Duration::from_secs(2_592_000); // 30 days
const DEFAULT_UPDATE_INTERVAL_HOURS: u64 = MAX_CACHE_AGE.as_secs() / 3600; // 30 days
const SUPPORTED_TLS_BACKENDS: &[RawTlsBackend] = &[
#[cfg(feature = "native-tls")]
RawTlsBackend::NativeTls,
#[cfg(feature = "rustls-with-webpki-roots")]
RawTlsBackend::RustlsWithWebpkiRoots,
#[cfg(feature = "rustls-with-native-roots")]
RawTlsBackend::RustlsWithNativeRoots,
];
pub(crate) fn supported_tls_backends_string() -> String {
SUPPORTED_TLS_BACKENDS
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<String>>()
.join(", ")
}
fn default_underline() -> bool {
false
}
fn default_bold() -> bool {
false
}
fn default_italic() -> bool {
false
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "lowercase")]
pub enum RawColor {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Purple, // Backwards compatibility with ansi_term (until tealdeer 1.5.0)
Cyan,
White,
Ansi(u8),
Rgb { r: u8, g: u8, b: u8 },
}
impl From<RawColor> for Color {
fn from(raw_color: RawColor) -> Self {
match raw_color {
RawColor::Black => Self::Black,
RawColor::Red => Self::Red,
RawColor::Green => Self::Green,
RawColor::Yellow => Self::Yellow,
RawColor::Blue => Self::Blue,
RawColor::Magenta | RawColor::Purple => Self::Magenta,
RawColor::Cyan => Self::Cyan,
RawColor::White => Self::White,
RawColor::Ansi(num) => Self::Fixed(num),
RawColor::Rgb { r, g, b } => Self::Rgb(r, g, b),
}
}
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
struct RawStyle {
pub foreground: Option<RawColor>,
pub background: Option<RawColor>,
#[serde(default = "default_underline")]
pub underline: bool,
#[serde(default = "default_bold")]
pub bold: bool,
#[serde(default = "default_italic")]
pub italic: bool,
}
#[allow(clippy::derivable_impls)] // Explicitly control defaults
impl Default for RawStyle {
fn default() -> Self {
Self {
foreground: None,
background: None,
underline: false,
bold: false,
italic: false,
}
}
}
impl From<RawStyle> for Style {
fn from(raw_style: RawStyle) -> Self {
let mut style = Self::default();
if let Some(foreground) = raw_style.foreground {
style = style.fg(Color::from(foreground));
}
if let Some(background) = raw_style.background {
style = style.bg(Color::from(background));
}
if raw_style.underline {
style = style.underline();
}
if raw_style.bold {
style = style.bold();
}
if raw_style.italic {
style = style.italic();
}
style
}
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
struct RawStyleConfig {
#[serde(default)]
pub description: RawStyle,
#[serde(default)]
pub command_name: RawStyle,
#[serde(default)]
pub example_text: RawStyle,
#[serde(default)]
pub example_code: RawStyle,
#[serde(default)]
pub example_variable: RawStyle,
}
impl From<&RawStyleConfig> for StyleConfig {
fn from(raw_style_config: &RawStyleConfig) -> Self {
Self {
command_name: raw_style_config.command_name.into(),
description: raw_style_config.description.into(),
example_text: raw_style_config.example_text.into(),
example_code: raw_style_config.example_code.into(),
example_variable: raw_style_config.example_variable.into(),
}
}
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
struct RawDisplayConfig {
#[serde(default)]
pub compact: bool,
#[serde(default)]
pub use_pager: bool,
#[serde(default)]
pub show_title: bool,
}
impl From<&RawDisplayConfig> for DisplayConfig {
fn from(raw_display_config: &RawDisplayConfig) -> Self {
Self {
compact: raw_display_config.compact,
use_pager: raw_display_config.use_pager,
show_title: raw_display_config.show_title,
}
}
}
/// Serde doesn't support default values yet (tracking issue:
/// <https://github.com/serde-rs/serde/issues/368>), so we need to wrap
/// `DEFAULT_UPDATE_INTERVAL_HOURS` in a function to be able to use
/// `#[serde(default = ...)]`
const fn default_auto_update_interval_hours() -> u64 {
DEFAULT_UPDATE_INTERVAL_HOURS
}
fn default_archive_source() -> String {
"https://github.com/tldr-pages/tldr/releases/latest/download/".to_owned()
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct RawUpdatesConfig {
#[serde(default)]
pub auto_update: bool,
#[serde(default = "default_auto_update_interval_hours")]
pub auto_update_interval_hours: u64,
#[serde(default = "default_archive_source")]
pub archive_source: String,
#[serde(default)]
pub tls_backend: RawTlsBackend,
#[serde(default)]
pub download_languages: Option<Vec<String>>,
}
impl Default for RawUpdatesConfig {
fn default() -> Self {
Self {
auto_update: false,
auto_update_interval_hours: DEFAULT_UPDATE_INTERVAL_HOURS,
archive_source: default_archive_source(),
tls_backend: RawTlsBackend::default(),
download_languages: None,
}
}
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
struct RawDirectoriesConfig {
#[serde(default)]
pub cache_dir: Option<PathBuf>,
#[serde(default)]
pub custom_pages_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum RawPlatformType {
Current,
All,
MacOs, // alias for Platform(PlatformType::OsX)
#[serde(untagged)]
Platform(PlatformType),
}
impl RawPlatformType {
pub fn flatten(raw_platforms: impl IntoIterator<Item = Self>) -> Vec<PlatformType> {
let mut flattened = Vec::new();
for raw_platform in raw_platforms {
match raw_platform {
RawPlatformType::Current => flattened.push(PlatformType::current()),
RawPlatformType::Platform(platform) => flattened.push(platform),
RawPlatformType::MacOs => flattened.push(PlatformType::OsX),
RawPlatformType::All => flattened.extend(PlatformType::value_variants()),
}
}
flattened.clear_duplicates();
flattened
}
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
struct RawSearchConfig {
pub languages: Option<Vec<String>>,
pub platforms: Option<Vec<RawPlatformType>>,
}
impl<'a> From<&'a RawSearchConfig> for SearchConfig<'a> {
fn from(raw_search_config: &'a RawSearchConfig) -> Self {
let languages = raw_search_config
.languages
.as_ref()
.map_or_else(get_languages_from_env, |langs| {
langs.iter().map(|lang| Language(lang)).collect()
});
let platforms = if let Some(raw_platforms) = raw_search_config.platforms.as_ref() {
RawPlatformType::flatten(raw_platforms.iter().copied())
} else {
RawPlatformType::flatten([
RawPlatformType::Current,
RawPlatformType::Platform(PlatformType::Common),
RawPlatformType::All,
])
};
Self {
languages,
platforms,
}
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
struct RawConfig {
style: RawStyleConfig,
display: RawDisplayConfig,
updates: RawUpdatesConfig,
directories: RawDirectoriesConfig,
search: RawSearchConfig,
}
impl Default for RawConfig {
fn default() -> Self {
let mut raw_config = RawConfig {
style: RawStyleConfig::default(),
display: RawDisplayConfig::default(),
updates: RawUpdatesConfig::default(),
directories: RawDirectoriesConfig::default(),
search: RawSearchConfig::default(),
};
// Set default config
raw_config.style.example_text.foreground = Some(RawColor::Green);
raw_config.style.command_name.foreground = Some(RawColor::Cyan);
raw_config.style.example_code.foreground = Some(RawColor::Cyan);
raw_config.style.example_variable.foreground = Some(RawColor::Cyan);
raw_config.style.example_variable.underline = true;
raw_config
}
}
#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
pub struct StyleConfig {
pub description: Style,
pub command_name: Style,
pub example_text: Style,
pub example_code: Style,
pub example_variable: Style,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct DisplayConfig {
pub compact: bool,
pub use_pager: bool,
pub show_title: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdatesConfig<'a> {
pub auto_update: bool,
pub auto_update_interval: Duration,
pub archive_source: &'a str,
pub tls_backend: TlsBackend,
pub download_languages: Vec<Language<'a>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PathWithSource {
pub path: PathBuf,
pub source: PathSource,
}
impl PathWithSource {
pub fn path(&self) -> &Path {
&self.path
}
}
impl fmt::Display for PathWithSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} ({})", self.path.display(), self.source)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirectoriesConfig {
pub cache_dir: PathWithSource,
pub custom_pages_dir: Option<PathWithSource>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SearchConfig<'a> {
pub languages: Vec<Language<'a>>,
pub platforms: Vec<PlatformType>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Language<'a>(pub &'a str);
fn get_languages<'a>(
env_lang: Option<&'a str>,
env_language: Option<&'a str>,
) -> Vec<Language<'a>> {
// Language list according to
// https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md#language
let Some(env_lang) = env_lang else {
return vec![Language("en")];
};
// Create an iterator that contains $LANGUAGE (':' separated list) followed by $LANG (single language)
let locales = env_language.unwrap_or("").split(':').chain([env_lang]);
let mut lang_list = Vec::new();
for locale in locales {
// Language plus country code (e.g. `en_US`)
if locale.len() >= 5 && locale.chars().nth(2) == Some('_') {
lang_list.push(Language(&locale[..5]));
}
// Language code only (e.g. `en`)
if locale.len() >= 2 && locale != "POSIX" {
lang_list.push(Language(&locale[..2]));
}
}
lang_list.push(Language("en"));
lang_list.clear_duplicates();
lang_list
}
pub fn get_languages_from_env<'a>() -> Vec<Language<'a>> {
static LANG: LazyLock<Option<String>> = LazyLock::new(|| std::env::var("LANG").ok());
static LANGUAGE: LazyLock<Option<String>> = LazyLock::new(|| std::env::var("LANGUAGE").ok());
get_languages(
LANG.as_ref().map(String::as_str),
LANGUAGE.as_ref().map(String::as_str),
)
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum RawTlsBackend {
/// Native TLS (`SChannel` on Windows, Secure Transport on macOS and OpenSSL otherwise)
NativeTls,
/// Rustls with `WebPKI` roots.
RustlsWithWebpkiRoots,
/// Rustls with native roots.
RustlsWithNativeRoots,
}
impl Default for RawTlsBackend {
fn default() -> Self {
*SUPPORTED_TLS_BACKENDS.first().unwrap()
}
}
impl std::fmt::Display for RawTlsBackend {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
self.serialize(f)
}
}
/// Allows choosing a `reqwest`'s TLS backend. Available TLS backends:
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum TlsBackend {
/// Native TLS (`SChannel` on Windows, Secure Transport on macOS and OpenSSL otherwise)
#[cfg(feature = "native-tls")]
NativeTls,
/// Rustls with `WebPKI` roots.
#[cfg(feature = "rustls-with-webpki-roots")]
RustlsWithWebpkiRoots,
/// Rustls with native roots.
#[cfg(feature = "rustls-with-native-roots")]
RustlsWithNativeRoots,
}
impl TryFrom<RawTlsBackend> for TlsBackend {
type Error = anyhow::Error;
fn try_from(raw: RawTlsBackend) -> Result<Self, Self::Error> {
match raw {
#[cfg(feature = "native-tls")]
RawTlsBackend::NativeTls => Ok(TlsBackend::NativeTls),
#[cfg(feature = "rustls-with-webpki-roots")]
RawTlsBackend::RustlsWithWebpkiRoots => Ok(TlsBackend::RustlsWithWebpkiRoots),
#[cfg(feature = "rustls-with-native-roots")]
RawTlsBackend::RustlsWithNativeRoots => Ok(TlsBackend::RustlsWithNativeRoots),
// when compiling without all TLS backend features, we want to handle config error.
#[allow(unreachable_patterns)]
_ => Err(anyhow!(
"Unsupported TLS backend: {}. This tealdeer build has support for the following options: {}",
raw,
supported_tls_backends_string(),
))
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Config<'a> {
pub style: StyleConfig,
pub display: DisplayConfig,
pub updates: UpdatesConfig<'a>,
pub directories: DirectoriesConfig,
pub search: SearchConfig<'a>,
pub file_path: PathWithSource,
}
impl<'a> Config<'a> {
/// Convert a `RawConfig` to a high-level `Config`.
///
/// For this, some values need to be converted to other types and some
/// defaults need to be set (sometimes based on env variables).
fn from_raw(raw_config: &'a RawConfig, config_file_path: PathWithSource) -> Result<Self> {
let style = (&raw_config.style).into();
let display = (&raw_config.display).into();
let search: SearchConfig<'a> = (&raw_config.search).into();
let updates = UpdatesConfig {
auto_update: raw_config.updates.auto_update,
auto_update_interval: Duration::from_secs(
raw_config.updates.auto_update_interval_hours * 3600,
),
archive_source: &raw_config.updates.archive_source,
tls_backend: raw_config.updates.tls_backend.try_into()?,
download_languages: raw_config.updates.download_languages.as_ref().map_or_else(
|| search.languages.clone(),
|languages| languages.iter().map(|lang| Language(lang)).collect(),
),
};
let relative_path_root = config_file_path
.path()
.parent()
.context("Failed to get config directory")?;
// Determine directories config. For this, we need to take some
// additional factory into account, like env variables, or the
// user config.
let cache_dir_env_var = "TEALDEER_CACHE_DIR";
let cache_dir = if let Ok(env_var) = env::var(cache_dir_env_var) {
// For backwards compatibility reasons, the cache directory can be
// overridden using an env variable. This is deprecated and will be
// phased out in the future.
eprintln!("Warning: The ${cache_dir_env_var} env variable is deprecated, use the `cache_dir` option in the config file instead.");
PathWithSource {
path: PathBuf::from(env_var),
source: PathSource::EnvVar,
}
} else if let Some(config_value) = &raw_config.directories.cache_dir {
// If the user explicitly configured a cache directory, use that.
PathWithSource {
// Resolve possible relative path. It would be nicer to clean up the path, but Rust stdlib
// does not give any method for that that does not need the paths to exist.
path: relative_path_root.join(config_value),
source: PathSource::ConfigFile,
}
} else if let Ok(default_dir) = get_app_root(AppDataType::UserCache, &crate::APP_INFO) {
// Otherwise, fall back to the default user cache directory.
PathWithSource {
path: default_dir,
source: PathSource::OsConvention,
}
} else {
// If everything fails, give up
bail!("Could not determine user cache directory");
};
let custom_pages_dir = raw_config
.directories
.custom_pages_dir
.as_ref()
.map(|path| PathWithSource {
// Resolve possible relative path.
path: relative_path_root.join(path),
source: PathSource::ConfigFile,
})
.or_else(|| {
get_app_root(AppDataType::UserData, &crate::APP_INFO)
.map(|path| {
// Note: The `join("")` call ensures that there's a trailing slash
PathWithSource {
path: path.join("pages").join(""),
source: PathSource::OsConvention,
}
})
.ok()
});
let directories = DirectoriesConfig {
cache_dir,
custom_pages_dir,
};
Ok(Self {
style,
display,
updates,
directories,
search,
file_path: config_file_path,
})
}
}
/// The [`ConfigLoader`] is used to load a [`Config`] from a file.
///
/// Since the rich [`Config`] keeps references to [`RawConfig`], the raw config needs to be kept alive outside of the
/// [`Config`]. The [`ConfigLoader`] thus offers the following flow:
/// 1. Read a raw config using [`ConfigLoader::read`] or [`ConfigLoader::read_default_path`].
/// 2. Validate the contents to a [`Config`] that borrows the [`ConfigLoader`].
pub struct ConfigLoader {
raw: RawConfig,
path: PathWithSource,
}
impl ConfigLoader {
fn read_internal(path: PathWithSource, allow_not_found: bool) -> Result<Self> {
match fs::read_to_string(&path.path) {
Ok(content) => Ok(Self {
raw: toml::from_str(&content).with_context(|| {
format!(
"Could not parse config file contents as toml from {}.",
path.path.display()
)
})?,
path,
}),
Err(e) if allow_not_found && e.kind() == ErrorKind::NotFound => Ok(Self {
raw: RawConfig::default(),
path,
}),
Err(e) => Err(e).context(format!(
"Could not read config file contents from {}.",
path.path().display()
)),
}
}
/// Create a loader that uses the config at `path`.
pub fn read(path: PathBuf) -> Result<Self> {
Self::read_internal(
PathWithSource {
path,
source: PathSource::Cli,
},
false,
)
}
/// Create a loader that uses the default config file location. If no file is present at the default location, the
/// default configuration is used.
pub fn read_default_path() -> Result<Self> {
let path = get_default_config_path().context("Could not determine default config path.")?;
Self::read_internal(path, true)
}
/// Parse the read [`RawConfig`] into a [`Config`].
pub fn load(&self) -> Result<Config<'_>> {
Config::from_raw(&self.raw, self.path.clone())
.context("Could not process raw config into rich config")
}
}
/// Return the path to the config directory.
///
/// The config dir path can be overridden using the `TEALDEER_CONFIG_DIR` env
/// variable. Otherwise, the user config directory is returned.
///
/// Note that this function does not verify whether the directory at that
/// location exists, or is a directory.
pub fn get_config_dir() -> Result<(PathBuf, PathSource)> {
// Allow overriding the config directory by setting the
// $TEALDEER_CONFIG_DIR env variable.
if let Ok(value) = env::var("TEALDEER_CONFIG_DIR") {
return Ok((PathBuf::from(value), PathSource::EnvVar));
}
// Otherwise, fall back to the user config directory.
let dirs = get_app_root(AppDataType::UserConfig, &crate::APP_INFO)
.context("Failed to determine the user config directory")?;
Ok((dirs, PathSource::OsConvention))
}
/// Return the path to the config file.
///
/// Note that this function does not verify whether the file at that location
/// exists, or is a file.
pub fn get_default_config_path() -> Result<PathWithSource> {
let (config_dir, source) = get_config_dir()?;
let config_file_path = config_dir.join(CONFIG_FILE_NAME);
Ok(PathWithSource {
path: config_file_path,
source,
})
}
/// Create default config file.
/// path: Can be specified to create the config in that path instead of
/// the default path.
pub fn make_default_config(path: Option<&Path>) -> Result<PathBuf> {
let config_file_path = if let Some(p) = path {
p.into()
} else {
let (config_dir, _) = get_config_dir()?;
// Ensure that config directory exists
if config_dir.exists() {
ensure!(
config_dir.is_dir(),
"Config directory could not be created: {} already exists but is not a directory",
config_dir.to_string_lossy(),
);
} else {
fs::create_dir_all(&config_dir).context("Could not create config directory")?;
}
config_dir.join(CONFIG_FILE_NAME)
};
// Ensure that a config file doesn't get overwritten
ensure!(
!config_file_path.is_file(),
"A configuration file already exists at {}, no action was taken.",
config_file_path.to_str().unwrap()
);
// Create default config
let serialized_config =
toml::to_string(&RawConfig::default()).context("Failed to serialize default config")?;
// Write default config
let mut config_file =
File::create(&config_file_path).context("Could not create config file")?;
let _wc = config_file
.write(serialized_config.as_bytes())
.context("Could not write to config file")?;
Ok(config_file_path)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn serialize_deserialize() {
let raw_config = RawConfig::default();
let serialized = toml::to_string(&raw_config).unwrap();
let deserialized: RawConfig = toml::from_str(&serialized).unwrap();
assert_eq!(raw_config, deserialized);
}
#[test]
fn relative_path_resolution() {
let mut raw_config = RawConfig::default();
raw_config.directories.cache_dir = Some("../cache".into());
raw_config.directories.custom_pages_dir = Some("../custom_pages".into());
let config = Config::from_raw(
&raw_config,
PathWithSource {
path: PathBuf::from("/path/to/config/config.toml"),
source: PathSource::OsConvention,
},
)
.unwrap();
assert_eq!(
config.directories.cache_dir.path(),
Path::new("/path/to/config/../cache")
);
assert_eq!(
config.directories.custom_pages_dir.unwrap().path(),
Path::new("/path/to/config/../custom_pages")
);
}
mod language {
use super::*;
#[test]
fn missing_lang_env() {
let lang_list = get_languages(None, Some("de:fr"));
assert_eq!(lang_list, [Language("en")]);
let lang_list = get_languages(None, None);
assert_eq!(lang_list, [Language("en")]);
}
#[test]
fn missing_language_env() {
let lang_list = get_languages(Some("de"), None);
assert_eq!(lang_list, [Language("de"), Language("en")]);
}
#[test]
fn preference_order() {
let lang_list = get_languages(Some("de"), Some("fr:cn"));
assert_eq!(
lang_list,
[
Language("fr"),
Language("cn"),
Language("de"),
Language("en")
]
);
}
#[test]
fn country_code_expansion() {
let lang_list = get_languages(Some("pt_BR"), None);
assert_eq!(
lang_list,
[Language("pt_BR"), Language("pt"), Language("en")]
);
}
#[test]
fn with_encoding() {
let lang_list = get_languages(Some("de_DE.UTF-8"), None);
assert_eq!(
lang_list,
[Language("de_DE"), Language("de"), Language("en")]
);
}
#[test]
fn ignore_posix_and_c() {
let lang_list = get_languages(Some("POSIX"), None);
assert_eq!(lang_list, [Language("en")]);
let lang_list = get_languages(Some("C"), None);
assert_eq!(lang_list, [Language("en")]);
}
#[test]
fn no_duplicates() {
let lang_list = get_languages(Some("de"), Some("fr:de:cn:de"));
assert_eq!(
lang_list,
[
Language("fr"),
Language("de"),
Language("cn"),
Language("en")
]
);
}
}
}