diff --git a/Cargo.toml b/Cargo.toml index 2266b7f..b489910 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,11 @@ version = "0.1.0" edition = "2024" [dependencies] +dotenvy = "^0" +ron = "^0" +toml = "^1" +serde_json = "^1" +serde = { version = "^1", features = ["derive"] } + +thiserror = "^2" +tracing-subscriber = { version = "^0", features = ["env-filter"] } diff --git a/src/app/config.rs b/src/app/config.rs new file mode 100644 index 0000000..22e160e --- /dev/null +++ b/src/app/config.rs @@ -0,0 +1,99 @@ +use std::{ env, path::PathBuf }; +use serde::{ Serialize, Deserialize }; + +use crate::app::parser::FromConfig; + +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct Config { + pub defaults: Defaults, + pub settings: Settings, + pub rules: Rules, +} + +// + +impl Config +{ + pub fn load() -> Self { FromConfig::load() } +} + +impl FromConfig for Config +{ + fn load() -> Self + { + let root = env::current_exe() + .expect("Unable to locate executable.").parent() + .expect("Unable to locate root directory.").to_owned(); + + // Removed expectation for dockerized-environments. + dotenvy::dotenv() + .map(|_| ()) + .or_else(|_| dotenvy::from_path(root.join(".env"))) + .ok(); //.expect("Unable to load .env file."); + + let assets_directory: PathBuf = env::var("RNV_ASSETS_DIRECTORY") + .ok() + .and_then(|v| v.parse().ok()) + .map(|p: PathBuf| root.join(p)) + .unwrap(); + + Self::deserialize_config( + assets_directory.join("config"), + &[ + ("ron", Self::from_ron), + ("toml", Self::from_toml), + ("json", Self::from_json), + ], + ).expect("Failed to deserialize config.") + } +} + +// + +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct Defaults { + pub songs_directory: String, + pub generic_directory: String, + + pub log_directory: String, + pub temp_directory: String, +} + +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct Settings { + pub debug: bool, + pub weight: f64, + pub volume: f64, + pub crossfade: f64, + pub weights: Weights, +} + +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct Weights { + pub news: NewsWeight, + pub genre: RecoveringWeight, + pub composite: RecoveringWeight, +} + +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct NewsWeight { + pub chance: f64, +} + +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct RecoveringWeight { + pub floor: f64, + pub recover_after: f64, +} + +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct Rules { + pub shuffle_mode: ShuffleMode, +} + +#[derive(Clone, Deserialize, Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum ShuffleMode { + Weighted, + StrictRandom, +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..8ca1ecb --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,7 @@ +pub mod state; +pub mod models; +pub mod config; + +mod parser; + +pub use parser::FromConfig; diff --git a/src/app/models.rs b/src/app/models.rs new file mode 100644 index 0000000..8e0fa7e --- /dev/null +++ b/src/app/models.rs @@ -0,0 +1,2 @@ + +pub struct Scheduler {} diff --git a/src/app/parser/mod.rs b/src/app/parser/mod.rs new file mode 100644 index 0000000..1bb0052 --- /dev/null +++ b/src/app/parser/mod.rs @@ -0,0 +1,3 @@ +mod parser; + +pub use parser::FromConfig; diff --git a/src/app/parser/parser.rs b/src/app/parser/parser.rs new file mode 100644 index 0000000..c85fdb7 --- /dev/null +++ b/src/app/parser/parser.rs @@ -0,0 +1,81 @@ +use std::path::Path; +use serde::Deserialize; +use thiserror::Error; + +/// A trait for types that can be loaded from a configuration file. +/// +/// Implementors must define [`load`](FromConfig::load), which is responsible for +/// resolving the config path and calling [`deserialize_config`](FromConfig::deserialize_config). +/// All parsing methods have default implementations supporting RON, TOML, and JSON, +/// and can be overridden if custom parsing behaviour is needed. +pub trait FromConfig +{ + /// Loads and deserializes the config into `Self`. + /// + /// Implementations should resolve the config file path and call + /// [`deserialize_config`](FromConfig::deserialize_config) with the desired formats. + fn load() -> Self; + + /// Deserializes a config file into a rust struct. + /// + /// Example: + /// ``` + /// let cfg: Config = Config::deserialize_config( + /// "path/to/config", + /// &[ + /// ("ron", Config::from_ron), + /// ("toml", Config::from_toml), + /// ("json", Config::from_json), + /// ], + /// )?; + /// ``` + fn deserialize_config(file: impl AsRef, formats: &[(&str, ParseFn)]) -> Result + where + T: for<'de> Deserialize<'de>, + { + let base = file.as_ref(); + + for (ext, parse) in formats + { + let path = base.with_extension(ext); + if path.exists() + { + let raw = std::fs::read_to_string(&path) + .map_err(ConfigError::Io)?; + return parse(&raw); + } + } + + Err(ConfigError::NotFound { path: file.as_ref().to_string_lossy().into() }) + } + + fn from_ron Deserialize<'de>>(s: &str) -> Result { + ron::from_str(s).map_err(ConfigError::Ron) + } + + fn from_toml Deserialize<'de>>(s: &str) -> Result { + toml::from_str(s).map_err(ConfigError::Toml) + } + + fn from_json Deserialize<'de>>(s: &str) -> Result { + serde_json::from_str(s).map_err(ConfigError::Json) + } +} + +// + +pub type ParseFn = fn(&str) -> Result; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("No config file found at '{path}.(ron|toml|json)'.")] + NotFound { path: String }, + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Ron parse error: {0}")] + Ron(#[from] ron::error::SpannedError), + #[error("TOML parse error: {0}")] + Toml(#[from] toml::de::Error), + #[error("JSON parse error: {0}")] + Json(#[from] serde_json::Error), +} diff --git a/src/app/state.rs b/src/app/state.rs new file mode 100644 index 0000000..7f56683 --- /dev/null +++ b/src/app/state.rs @@ -0,0 +1,29 @@ +use tracing_subscriber::{ + EnvFilter, + fmt::{ + SubscriberBuilder, + format::{ Format, DefaultFields } + } +}; + +use crate::app::{ + models::*, + config::Config +}; + +pub struct AppState { + pub config: Config, + pub scheduler: Scheduler +} + +impl AppState +{ + pub fn new(config: Config, scheduler: Scheduler) -> Self + { + Self { config, scheduler } + } + + pub fn get_tracer(filter: impl Into) -> SubscriberBuilder { + tracing_subscriber::fmt().with_env_filter(filter) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f2fe20d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod app; + +pub use app::{ config, state }; diff --git a/src/main.rs b/src/main.rs index 3033f62..db27212 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,13 @@ +use new_vegas_radio::app::{ config::Config, state::AppState, models::Scheduler }; -fn main() { +fn main() +{ + let cfg = Config::load(); + let _app = AppState::new(cfg.clone(), Scheduler {}); + if cfg.settings.debug { + AppState::get_tracer("new_vegas_radio=debug").init(); + } + + println!("{:?}", cfg); }