use eyre::Result;
use serde::Deserialize;
use std::{
collections::HashMap,
fmt,
path::{Path, PathBuf},
};
use thiserror::Error;
use tracing::error;
const CONFIG: &str = include_str!("../.config/config.yaml");
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("failed to parse config: {}", source)]
Parse {
#[from]
source: config::ConfigError,
},
#[error("config dir cannot be identified")]
ConfigDirCannotBeIdentified,
#[error("failed to parse config: {}", source)]
Builder {
#[from]
source: ConfigBuilderError,
},
}
impl ConfigError {
pub fn parse(source: config::ConfigError) -> Self {
ConfigError::Parse { source }
}
pub fn builder(source: ConfigBuilderError) -> Self {
ConfigError::Builder { source }
}
}
#[derive(Error)]
#[non_exhaustive]
pub enum ConfigBuilderError {
#[error("failed to parse file {path:?}: {source}")]
FileParse {
source: Box<config::ConfigError>,
builder: ConfigBuilder,
path: PathBuf,
},
#[error("failed to deserialize config {path:?}: {source}")]
ConfigDeserialize {
source: Box<config::ConfigError>,
builder: ConfigBuilder,
path: PathBuf,
},
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct ViewConfig {
#[serde(default)]
pub default_fields: Vec<String>,
#[serde(default)]
pub fields: Vec<FieldConfig>,
#[serde(default)]
pub wide: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, Ord, PartialOrd, PartialEq)]
pub struct FieldConfig {
pub name: String,
#[serde(default)]
pub width: Option<usize>,
#[serde(default)]
pub min_width: Option<usize>,
#[serde(default)]
pub max_width: Option<usize>,
#[serde(default)]
pub json_pointer: Option<String>,
}
const fn _default_true() -> bool {
true
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Config {
#[serde(default)]
pub views: HashMap<String, ViewConfig>,
#[serde(default)]
pub command_hints: HashMap<String, HashMap<String, Vec<String>>>,
#[serde(default)]
pub hints: Vec<String>,
#[serde(default = "_default_true")]
pub enable_hints: bool,
}
pub struct ConfigBuilder {
sources: Vec<config::Config>,
}
impl ConfigBuilder {
pub fn add_source(mut self, source: impl AsRef<Path>) -> Result<Self, ConfigBuilderError> {
let config = match config::Config::builder()
.add_source(config::File::from(source.as_ref()))
.build()
{
Ok(config) => config,
Err(error) => {
return Err(ConfigBuilderError::FileParse {
source: Box::new(error),
builder: self,
path: source.as_ref().to_owned(),
});
}
};
if let Err(error) = config.clone().try_deserialize::<Config>() {
return Err(ConfigBuilderError::ConfigDeserialize {
source: Box::new(error),
builder: self,
path: source.as_ref().to_owned(),
});
}
self.sources.push(config);
Ok(self)
}
pub fn build(self) -> Result<Config, ConfigError> {
let mut config = config::Config::builder();
for source in self.sources {
config = config.add_source(source);
}
Ok(config.build()?.try_deserialize::<Config>()?)
}
}
impl fmt::Debug for ConfigBuilderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigBuilderError::FileParse { source, path, .. } => f
.debug_struct("FileParse")
.field("source", source)
.field("path", path)
.finish_non_exhaustive(),
ConfigBuilderError::ConfigDeserialize { source, path, .. } => f
.debug_struct("ConfigDeserialize")
.field("source", source)
.field("path", path)
.finish_non_exhaustive(),
}
}
}
impl Config {
pub fn builder() -> Result<ConfigBuilder, ConfigError> {
let default_config: config::Config = config::Config::builder()
.add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml))
.build()?;
Ok(ConfigBuilder {
sources: Vec::from([default_config]),
})
}
pub fn new() -> Result<Self, ConfigError> {
let default_config: config::Config = config::Config::builder()
.add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml))
.build()?;
let config_dir =
get_config_dir().ok_or_else(|| ConfigError::ConfigDirCannotBeIdentified)?;
let mut builder = ConfigBuilder {
sources: Vec::from([default_config]),
};
let config_files = [
("config.yaml", config::FileFormat::Yaml),
("views.yaml", config::FileFormat::Yaml),
];
let mut found_config = false;
for (file, _format) in &config_files {
if config_dir.join(file).exists() {
found_config = true;
builder = match builder.add_source(config_dir.join(file)) {
Ok(builder) => builder,
Err(ConfigBuilderError::FileParse { source, .. }) => {
return Err(ConfigError::parse(*source));
}
Err(ConfigBuilderError::ConfigDeserialize {
source,
builder,
path,
}) => {
error!(
"The file {path:?} could not be deserialized and will be ignored: {source}"
);
builder
}
}
}
}
if !found_config {
tracing::error!("No configuration file found. Application may not behave as expected");
}
builder.build()
}
}
fn get_config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|val| val.join("osc"))
}
impl fmt::Display for Config {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::Builder;
#[test]
fn test_parse_config() {
let mut config_file = Builder::new().suffix(".yaml").tempfile().unwrap();
const CONFIG_DATA: &str = r#"
views:
foo:
default_fields: ["a", "b", "c"]
bar:
fields:
- name: "b"
min_width: 1
command_hints:
res:
cmd:
- hint1
- hint2
hints:
- hint1
- hint2
enable_hints: true
"#;
write!(config_file, "{CONFIG_DATA}").unwrap();
let _cfg = Config::builder()
.unwrap()
.add_source(config_file.path())
.unwrap()
.build();
}
}