Add an option to specify a custom config file to be used (#422)

Co-authored-by: Niklas Mohrin <dev@niklasmohrin.de>
This commit is contained in:
MHS-0 2025-05-31 23:13:39 +03:30 committed by GitHub
commit 9bb95ad11d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 236 additions and 72 deletions

View file

@ -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 <FILE> 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

View file

@ -174,8 +174,8 @@ impl Cache {
if let Ok(mtime) = metadata.modified() {
let now = SystemTime::now();
return now.duration_since(mtime).ok();
};
};
}
}
None
}

View file

@ -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<PathBuf>,
/// Use a pager to page output
#[arg(long = "pager", requires = "command_or_file")]
pub pager: bool,

View file

@ -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<RawConfig> {
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<Self> {
fn from_raw(raw_config: RawConfig, config_file_path: PathWithSource) -> Result<Self> {
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<Self> {
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<Self> {
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<Self> {
// 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<PathWithSource> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
// 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(),

View file

@ -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<ExitCode> {
// 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<ExitCode> {
// 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<ExitCode> {
{
// Cache is needed, but missing
return Ok(ExitCode::FAILURE);
};
}
// List cached commands and exit
if args.list {

View file

@ -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")?;

View file

@ -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",
}
)
}

View file

@ -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<str>) {
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]