diff --git a/docs/src/usage.txt b/docs/src/usage.txt index a4a5fcb..33e6020 100644 --- a/docs/src/usage.txt +++ b/docs/src/usage.txt @@ -18,6 +18,7 @@ Options: -u, --update Update the local cache --no-auto-update If auto update is configured, disable it for this run -c, --clear-cache Clear the local cache + --config-path Override config file location --pager Use a pager to page output -r, --raw Display the raw markdown instead of rendering it -q, --quiet Suppress informational messages diff --git a/src/cache.rs b/src/cache.rs index 76f0abe..c1308f2 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -174,8 +174,8 @@ impl Cache { if let Ok(mtime) = metadata.modified() { let now = SystemTime::now(); return now.duration_since(mtime).ok(); - }; - }; + } + } None } diff --git a/src/cli.rs b/src/cli.rs index 0ba9de9..55fe74f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -74,6 +74,10 @@ pub(crate) struct Cli { #[arg(short = 'c', long = "clear-cache")] pub clear_cache: bool, + /// Override config file location + #[arg(long = "config-path", value_name = "FILE")] + pub config_path: Option, + /// Use a pager to page output #[arg(long = "pager", requires = "command_or_file")] pub pager: bool, diff --git a/src/config.rs b/src/config.rs index 3d82eea..be41dd2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,13 +1,13 @@ use std::{ - env, fmt, fs, - io::{Read, Write}, + env, fmt, + fs::{self, File}, + io::{ErrorKind, Read, Write}, path::{Path, PathBuf}, time::Duration, }; use anyhow::{anyhow, bail, ensure, Context, Result}; use app_dirs::{get_app_root, AppDataType}; -use log::debug; use serde::Serialize as _; use serde_derive::{Deserialize, Serialize}; use yansi::{Color, Style}; @@ -254,6 +254,14 @@ impl RawConfig { fn new() -> Self { Self::default() } + + fn load(mut config: impl Read) -> Result { + let mut content = String::new(); + config + .read_to_string(&mut content) + .context("Failed to read from config file")?; + toml::from_str(&content).context("Failed to parse TOML config file") + } } impl Default for RawConfig { @@ -276,7 +284,7 @@ impl Default for RawConfig { } } -#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] pub struct StyleConfig { pub description: Style, pub command_name: Style, @@ -366,6 +374,7 @@ pub struct Config { pub display: DisplayConfig, pub updates: UpdatesConfig, pub directories: DirectoriesConfig, + pub file_path: PathWithSource, } impl Config { @@ -373,10 +382,14 @@ impl 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: RawConfig, relative_path_root: &Path) -> Result { + fn from_raw(raw_config: RawConfig, config_file_path: PathWithSource) -> Result { let style = raw_config.style.into(); let display = raw_config.display.into(); let updates = raw_config.updates.try_into()?; + 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 @@ -438,48 +451,48 @@ impl Config { display, updates, directories, + file_path: config_file_path, }) } - pub fn load(enable_styles: bool) -> Result { - debug!("Loading config"); + /// Load and read the config file from the given path into + /// a [Config] and return it. + /// + /// path: The path to the config file. + pub fn load(path: &Path) -> Result { + let raw_config = RawConfig::load(File::open(path)?)?; + let config = Self::from_raw( + raw_config, + PathWithSource { + path: path.into(), + source: PathSource::Cli, + }, + ) + .context("Could not process raw config")?; + + Ok(config) + } + + /// Load and read the config file from the default path into + /// a [Config] and return it. + pub fn load_default_path() -> Result { // Determine path - let (config_file_path, _) = get_config_path().context("Could not determine config path")?; + let config_file_path = + get_default_config_path().context("Could not determine config path")?; - // Load raw config - let raw_config: RawConfig = if config_file_path.exists() && config_file_path.is_file() { - let mut config_file = fs::File::open(&config_file_path).with_context(|| { - format!("Failed to open config file path at {:?}", &config_file_path) - })?; - let mut contents = String::new(); - config_file.read_to_string(&mut contents).with_context(|| { - format!("Failed to read from config file at {:?}", &config_file_path) - })?; - toml::from_str(&contents).with_context(|| { - format!("Failed to parse TOML config file at {config_file_path:?}") - })? - } else { - RawConfig::new() + let raw_config = match File::open(config_file_path.path()) { + Ok(file) => RawConfig::load(file)?, + Err(e) if e.kind() == ErrorKind::NotFound => RawConfig::default(), + Err(e) => { + return Err(e).context(format!( + "Failed to open config file at {}", + config_file_path.path().display() + )); + } }; - - // Safe to unwrap, it's a file path, so it should have a directory component - let config_file_dir = config_file_path.parent().unwrap(); - - // Convert to config, resolve relative paths from the config file dir - let mut config = - Self::from_raw(raw_config, config_file_dir).context("Could not process raw config")?; - - // Potentially override styles - if !enable_styles { - config.style = StyleConfig { - command_name: Style::default(), - description: Style::default(), - example_text: Style::default(), - example_code: Style::default(), - example_variable: Style::default(), - }; - } + let config = + Self::from_raw(raw_config, config_file_path).context("Could not process raw config")?; Ok(config) } @@ -497,7 +510,7 @@ pub fn get_config_dir() -> Result<(PathBuf, PathSource)> { // $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) @@ -509,29 +522,39 @@ pub fn get_config_dir() -> Result<(PathBuf, PathSource)> { /// /// Note that this function does not verify whether the file at that location /// exists, or is a file. -pub fn get_config_path() -> Result<(PathBuf, PathSource)> { +pub fn get_default_config_path() -> Result { let (config_dir, source) = get_config_dir()?; let config_file_path = config_dir.join(CONFIG_FILE_NAME); - Ok((config_file_path, source)) + Ok(PathWithSource { + path: config_file_path, + source, + }) } /// Create default config file. -pub fn make_default_config() -> Result { - 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(), - ); +/// 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 { + let config_file_path = if let Some(p) = path { + p.into() } else { - fs::create_dir_all(&config_dir).context("Could not create config directory")?; - } + 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 - let config_file_path = config_dir.join(CONFIG_FILE_NAME); ensure!( !config_file_path.is_file(), "A configuration file already exists at {}, no action was taken.", @@ -544,7 +567,7 @@ pub fn make_default_config() -> Result { // Write default config let mut config_file = - fs::File::create(&config_file_path).context("Could not create 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")?; @@ -566,7 +589,14 @@ fn test_relative_path_resolution() { 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, Path::new("/path/to/config")).unwrap(); + 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(), diff --git a/src/main.rs b/src/main.rs index cfd00db..99403b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,8 @@ use std::{ use anyhow::{anyhow, Context, Result}; use app_dirs::AppInfo; use clap::Parser; +use config::StyleConfig; +use log::debug; mod cache; mod cli; @@ -50,7 +52,7 @@ mod utils; use crate::{ cache::{Cache, CacheFreshness, PageLookupResult, TLDR_PAGES_DIR}, cli::Cli, - config::{get_config_dir, get_config_path, make_default_config, Config, PathWithSource}, + config::{get_config_dir, make_default_config, Config, PathWithSource}, extensions::Dedup, output::print_page, types::{ColorOptions, PlatformType}, @@ -153,10 +155,7 @@ fn show_paths(config: &Config) { } }, ); - let config_path = get_config_path().map_or_else( - |e| format!("[Error: {e}]"), - |(path, _)| path.display().to_string(), - ); + let config_path = config.file_path.to_string(); let cache_dir = config.directories.cache_dir.to_string(); let pages_dir = { let mut path = config.directories.cache_dir.path.clone(); @@ -175,8 +174,8 @@ fn show_paths(config: &Config) { println!("Custom pages dir: {custom_pages_dir}"); } -fn create_config() -> Result<()> { - let config_file_path = make_default_config().context("Could not create seed config")?; +fn create_config(path: Option<&Path>) -> Result<()> { + let config_file_path = make_default_config(path).context("Could not create seed config")?; eprintln!( "Successfully created seed config file here: {}", config_file_path.to_str().unwrap() @@ -279,7 +278,18 @@ fn main() -> ExitCode { fn try_main(args: Cli, enable_styles: bool) -> Result { // Look up config file, if none is found fall back to default config. - let config = Config::load(enable_styles).context("Could not load config")?; + debug!("Loading config"); + let mut config = match &args.config_path { + Some(path) if !args.seed_config => { + Config::load(path).context("Could not load config from given path")? + } + _ => Config::load_default_path().context("Could not load config from default path")?, + }; + + // Override styles if needed + if !enable_styles { + config.style = StyleConfig::default(); + } let custom_pages_dir = config .directories @@ -313,7 +323,7 @@ fn try_main(args: Cli, enable_styles: bool) -> Result { // Create a basic config and exit if args.seed_config { - create_config()?; + create_config(args.config_path.as_deref())?; return Ok(ExitCode::SUCCESS); } @@ -345,7 +355,7 @@ fn try_main(args: Cli, enable_styles: bool) -> Result { { // Cache is needed, but missing return Ok(ExitCode::FAILURE); - }; + } // List cached commands and exit if args.list { diff --git a/src/output.rs b/src/output.rs index abef1db..046abe8 100644 --- a/src/output.rs +++ b/src/output.rs @@ -71,7 +71,7 @@ pub fn print_page( !config.display.compact, ) .context("Could not write to stdout")?; - }; + } // We're done outputting data, flush stdout now! handle.flush().context("Could not flush stdout")?; diff --git a/src/types.rs b/src/types.rs index 416d267..69c7599 100644 --- a/src/types.rs +++ b/src/types.rs @@ -205,6 +205,8 @@ pub enum PathSource { EnvVar, /// Config file ConfigFile, + /// CLI argument override + Cli, } impl fmt::Display for PathSource { @@ -216,6 +218,7 @@ impl fmt::Display for PathSource { Self::OsConvention => "OS convention", Self::EnvVar => "env variable", Self::ConfigFile => "config file", + Self::Cli => "command line argument", } ) } diff --git a/tests/lib.rs b/tests/lib.rs index 420eb08..172c1c5 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -70,6 +70,24 @@ impl TestEnv { .expect("Failed to append to config file."); } + fn create_secondary_config(self) -> Self { + self.append_to_secondary_config(format!( + "directories.cache_dir = '{}'\n", + self.cache_dir().to_str().unwrap(), + )); + self + } + + fn append_to_secondary_config(&self, content: impl AsRef) { + File::options() + .create(true) + .append(true) + .open(self.config_dir().join("config-secondary.toml")) + .expect("Failed to open config file") + .write_all(content.as_ref().as_bytes()) + .expect("Failed to append to config file."); + } + fn remove_initial_config(self) -> Self { let _ = fs::remove_file(self.config_dir().join("config.toml")); self @@ -183,6 +201,61 @@ fn test_cannot_build_without_tls_feature() { let _ = TestEnv::new().no_default_features().command(); } +#[test] +fn test_load_the_correct_config() { + let testenv = TestEnv::new() + .install_default_cache() + .create_secondary_config(); + testenv.append_to_secondary_config(include_str!("style-config.toml")); + + let expected_default = include_str!("rendered/inkscape-default.expected"); + let expected_with_config = include_str!("rendered/inkscape-with-config.expected"); + + testenv + .command() + .args(["--color", "always", "inkscape-v2"]) + .assert() + .success() + .stdout(diff(expected_default)); + + testenv + .command() + .args([ + "--color", + "always", + "--config-path", + testenv + .config_dir() + .join("config-secondary.toml") + .to_str() + .unwrap(), + "inkscape-v2", + ]) + .assert() + .success() + .stdout(diff(expected_with_config)); +} + +#[test] +fn test_fail_on_custom_config_path_is_directory() { + let testenv = TestEnv::new(); + let error = if cfg!(windows) { + "Access is denied" + } else { + "Is a directory" + }; + testenv + .command() + .args([ + "--config-path", + testenv.config_dir().to_str().unwrap(), + "sl", + ]) + .assert() + .failure() + .stderr(contains(error)); +} + #[test] fn test_missing_cache() { TestEnv::new() @@ -438,8 +511,9 @@ fn test_setup_seed_config() { .failure() .stderr(contains("A configuration file already exists")); - let testenv = testenv.remove_initial_config(); + assert!(testenv.config_dir().join("config.toml").is_file()); + let testenv = testenv.remove_initial_config(); testenv .command() .args(["--seed-config"]) @@ -448,6 +522,48 @@ fn test_setup_seed_config() { .stderr(contains("Successfully created seed config file here")); assert!(testenv.config_dir().join("config.toml").is_file()); + + // Create parent directories as needed for the default config path. + fs::remove_dir_all(testenv.config_dir()).unwrap(); + testenv + .command() + .args(["--seed-config"]) + .assert() + .success() + .stderr(contains("Successfully created seed config file here")); + + assert!(testenv.config_dir().join("config.toml").is_file()); + + // Write the default config to --config-path if specified by the user + // at the same time. + let custom_config_path = testenv.config_dir().join("config_custom.toml"); + testenv + .command() + .args([ + "--seed-config", + "--config-path", + custom_config_path.to_str().unwrap(), + ]) + .assert() + .success() + .stderr(contains("Successfully created seed config file here")); + + assert!(custom_config_path.is_file()); + + // DON'T create parent directories for a custom config path. + fs::remove_dir_all(testenv.config_dir()).unwrap(); + testenv + .command() + .args([ + "--seed-config", + "--config-path", + custom_config_path.to_str().unwrap(), + ]) + .assert() + .failure() + .stderr(contains("Could not create config file")); + + assert!(!custom_config_path.is_file()); } #[test]