mod app_configuration;
mod app_directories;
mod config_error;
mod config_file_version;
mod ignored;
mod logging_config;
mod logging_error;
mod format_error;
mod level_error;
mod overrides;
pub mod path_resolver;
mod resolve_error;
mod rotation_error;
mod version_error;
pub use app_configuration::{AppConfiguration, DEFAULT_CONFIG_FILE};
pub use app_directories::AppDirectories;
pub use config_error::ConfigError;
pub use config_file_version::ConfigFileVersion;
pub use ignored::Ignored;
pub use level_error::LevelError;
pub use logging_config::{LoggingConfig, LoggingFormat};
pub use logging_error::LoggingError;
pub use overrides::Overrides;
pub use resolve_error::ResolveError;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use std::path::Path;
use std::{env, fs};
#[derive(Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub version: ConfigFileVersion,
#[serde(default)]
pub ignored: Ignored,
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)]
pub overrides: Overrides,
}
impl Display for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Config {{ version: {}, ignored: {}, logging: {}, overrides: {} }}",
self.version, self.ignored, self.logging, self.overrides
)
}
}
impl Default for Config {
fn default() -> Self {
Self {
version: ConfigFileVersion::V1,
ignored: Ignored::default(),
logging: LoggingConfig::default(),
overrides: Overrides::default(),
}
}
}
impl Config {
pub fn from_file(file_path: Option<&Path>) -> Result<Self, ConfigError> {
let app_directories = AppDirectories::load_directories();
if let Some(file_path) = file_path {
return Self::read_config_file(file_path, &app_directories);
}
let config_file = app_directories.config_dir.join(DEFAULT_CONFIG_FILE);
if let Ok(config_file) = path_resolver::resolve_path(&config_file)
&& fs::exists(&config_file).unwrap_or(false)
{
return Self::read_config_file(&config_file, &app_directories);
}
Ok(Self::default())
}
fn read_config_file(file_path: &Path, app_directories: &AppDirectories) -> Result<Self, ConfigError> {
let content = fs::read_to_string(file_path)?;
let mut config = toml::from_str::<Self>(&content)?;
let home_dir = env::home_dir().ok_or(ConfigError::UnableToFindHomeDirectory)?;
config.ignored.file = path_resolver::resolve_home_path(&config.ignored.file)?;
if config.ignored.file.is_relative() {
let parent_dir = file_path.parent().unwrap_or(home_dir.as_path());
config.ignored.file = parent_dir.join(config.ignored.file);
}
if let Some(file) = config.ignored.file.to_str() {
let path = Path::new(file);
config.ignored.file = path_resolver::resolve_home_path(path)?;
}
config.logging.logging_path = if let Some(path) = config.logging.logging_path {
Some(path_resolver::resolve_home_path(&path)?)
} else {
Some(path_resolver::resolve_home_path(&app_directories.log_dir)?)
};
if let Some(path) = &config.logging.logging_path
&& path.is_relative()
{
let parent_dir = file_path.parent().unwrap_or(home_dir.as_path());
config.logging.logging_path = Some(parent_dir.join(path));
}
if let Some(file) = config.overrides.file.to_str() {
let path = Path::new(file);
config.overrides.file = path_resolver::resolve_home_path(path)?;
}
Ok(config)
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::config::logging_config::{LoggingLevel, RotationType};
use std::path::PathBuf;
#[test]
fn toml_version_1_deserialization() {
let config_content = r#"
version = 1
[ignored]
file = "ignored_files.txt"
comment = 'c'
[logging]
level = "info"
file = "temp.log"
logging_path = "log_dir"
rotation = "daily"
format = "json"
max_log_files = 10
color_support = true
"#;
let config: Config = toml::from_str(config_content).expect("Failed to parse TOML");
assert_eq!(config.ignored.file, Path::new("ignored_files.txt"));
assert_eq!(config.ignored.comment, 'c');
assert_eq!(config.version, ConfigFileVersion::V1);
assert_eq!(config.logging.file, Some(PathBuf::from("temp.log")));
assert_eq!(config.logging.logging_path, Some(PathBuf::from("log_dir")));
assert_eq!(config.logging.rotation, RotationType::Daily);
assert_eq!(config.logging.format, LoggingFormat::Json);
assert_eq!(config.logging.max_log_files, 10);
assert!(config.logging.color_support);
assert_eq!(config.logging.level, LoggingLevel::Info);
}
#[test]
fn toml_version_1_deserialization_string_version() {
let allowed_versions = vec!["1", "v1", "V1"];
for version in allowed_versions {
let config_content = format!(
r#"
version = "{version}"
"#
);
let config: Config = toml::from_str(&config_content).expect("Failed to parse TOML");
assert_eq!(config.version, ConfigFileVersion::V1);
}
}
#[test]
fn toml_version_1_everything_is_optional() {
let config_content = "
version = 1
";
let default_config = Config::default();
let config: Config = toml::from_str(config_content).expect("Failed to parse TOML");
assert_eq!(config.version, ConfigFileVersion::V1);
assert_eq!(
config.logging.logging_path,
default_config.logging.logging_path
);
assert_eq!(config.logging.rotation, default_config.logging.rotation);
assert_eq!(
config.logging.max_log_files,
default_config.logging.max_log_files
);
assert_eq!(
config.logging.color_support,
default_config.logging.color_support
);
assert_eq!(config.logging.level, default_config.logging.level);
assert_eq!(config.ignored.file, default_config.ignored.file);
assert_eq!(config.ignored.comment, default_config.ignored.comment);
}
#[test]
fn toml_version_1_ignores_logging_level_case() {
let allowed_levels = vec![
LoggingLevel::Off,
LoggingLevel::Trace,
LoggingLevel::Debug,
LoggingLevel::Info,
LoggingLevel::Warn,
LoggingLevel::Error,
];
for level in allowed_levels {
let config_content = format!(
r#"
version = 1
[logging]
level = "{level}"
"#
);
let config: Config = toml::from_str(config_content.as_str()).expect("Failed to parse TOML");
assert_eq!(config.logging.level, level);
let config_content = format!(
r#"
version = 1
[logging]
level = "{}"
"#,
level.to_string().to_uppercase()
);
let config: Config = toml::from_str(config_content.as_str()).expect("Failed to parse TOML");
assert_eq!(config.logging.level, level);
let config_content = format!(
r#"
version = 1
[logging]
level = "{}"
"#,
level.to_string().to_lowercase()
);
let config: Config = toml::from_str(config_content.as_str()).expect("Failed to parse TOML");
assert_eq!(config.logging.level, level);
}
}
#[test]
fn toml_version_1_logging_level_can_use_numeric_value() {
let allowed_levels = vec![
LoggingLevel::Off,
LoggingLevel::Trace,
LoggingLevel::Debug,
LoggingLevel::Info,
LoggingLevel::Warn,
LoggingLevel::Error,
];
for level in allowed_levels {
let config_content = format!(
r"
version = 1
[logging]
level = {}
",
level as i64
);
let config: Config = toml::from_str(config_content.as_str()).expect("Failed to parse TOML");
assert_eq!(config.logging.level, level);
}
}
#[test]
fn toml_version_1_ignores_rotation_case() {
let rotations_types = vec![RotationType::Hourly, RotationType::Daily];
for rotation in rotations_types {
let config_content = format!(
r#"
version = 1
[logging]
rotation = "{rotation}"
"#
);
let config: Config = toml::from_str(config_content.as_str()).expect("Failed to parse TOML");
assert_eq!(config.logging.rotation, rotation);
let config_content = format!(
r#"
version = 1
[logging]
rotation = "{}"
"#,
rotation.to_string().to_uppercase()
);
let config: Config = toml::from_str(config_content.as_str()).expect("Failed to parse TOML");
assert_eq!(config.logging.rotation, rotation);
let config_content = format!(
r#"
version = 1
[logging]
rotation = "{}"
"#,
rotation.to_string().to_lowercase()
);
let config: Config = toml::from_str(config_content.as_str()).expect("Failed to parse TOML");
assert_eq!(config.logging.rotation, rotation);
}
}
#[test]
fn toml_version_1_rotation_can_use_numeric_value() {
let rotations_types = vec![RotationType::Hourly, RotationType::Daily];
for rotation in rotations_types {
let config_content = format!(
r"
version = 1
[logging]
rotation = {}
",
rotation as i64
);
let config: Config = toml::from_str(config_content.as_str()).expect("Failed to parse TOML");
assert_eq!(config.logging.rotation, rotation);
}
}
#[test]
fn toml_version_1_supported_version_strings() {
let version_text = vec![r#""1""#, r#""v1""#, r#""V1""#, "1"];
for version in version_text {
let config_content = format!(
r"
version = {version}
"
);
let config: Config = toml::from_str(config_content.as_str()).expect("Failed to parse TOML");
assert_eq!(config.version, ConfigFileVersion::V1);
}
}
#[test]
fn toml_version_1_ignores_logging_format_case() {
let format_types = vec![LoggingFormat::Compact, LoggingFormat::Json, LoggingFormat::Pretty];
for format in format_types {
let config_content = format!(
r#"
version = 1
[logging]
format = "{format}"
"#,
);
let config: Config = toml::from_str(config_content.as_str()).expect("Failed to parse TOML");
assert_eq!(config.logging.format, format);
let config_content = format!(
r#"
version = 1
[logging]
format = "{}"
"#,
format.to_string().to_uppercase()
);
let config: Config = toml::from_str(config_content.as_str()).expect("Failed to parse TOML");
assert_eq!(config.logging.format, format);
let config_content = format!(
r#"
version = 1
[logging]
format = "{}"
"#,
format.to_string().to_lowercase()
);
let config: Config = toml::from_str(config_content.as_str()).expect("Failed to parse TOML");
assert_eq!(config.logging.format, format);
}
}
#[test]
fn toml_version_1_logging_format_can_use_numeric_value() {
let format_types = vec![LoggingFormat::Compact, LoggingFormat::Json, LoggingFormat::Pretty];
for format in format_types {
let config_content = format!(
r"
version = 1
[logging]
format = {}
",
format as i64
);
let config: Config = toml::from_str(config_content.as_str()).expect("Failed to parse TOML");
assert_eq!(config.logging.format, format);
}
}
}