use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_log::warn;
use serde::de::DeserializeOwned;
use crate::services::{ProjectRoot, RigHome};
use crate::workspace::ProjectWorkspace;
#[derive(Resource, Clone, Debug)]
pub struct Settings {
file: String,
tables: toml::Table,
problems: Vec<String>,
}
impl Default for Settings {
fn default() -> Self {
Self::empty("settings.toml".to_owned())
}
}
impl Settings {
pub fn from_toml(text: &str) -> Self {
Self::parse("settings.toml".to_owned(), text)
}
pub fn read(path: &Path) -> Self {
let file = path.display().to_string();
match std::fs::read_to_string(path) {
Ok(text) => Self::parse(file, &text),
Err(failure) if failure.kind() == ErrorKind::NotFound => Self::empty(file),
Err(failure) => {
let problem = format!("{file}: could not read: {failure}");
Self {
problems: vec![problem],
..Self::empty(file)
}
}
}
}
fn empty(file: String) -> Self {
Self {
file,
tables: toml::Table::new(),
problems: Vec::new(),
}
}
fn parse(file: String, text: &str) -> Self {
match text.parse::<toml::Table>() {
Ok(tables) => Self {
tables,
..Self::empty(file)
},
Err(failure) => {
let problem = format!("{file}: not TOML: {failure}");
Self {
problems: vec![problem],
..Self::empty(file)
}
}
}
}
pub fn get<T: DeserializeOwned + Default>(&self, section: &str) -> Result<T, String> {
let Some(table) = self.tables.get(section) else {
return Ok(T::default());
};
table
.clone()
.try_into()
.map_err(|failure| format!("{} [{section}]: {failure}", self.file))
}
pub fn problems(&self) -> &[String] {
&self.problems
}
}
pub trait AppSettingsExt {
fn add_setting<T: Resource + DeserializeOwned + Default>(&mut self, section: &str)
-> &mut Self;
fn add_project_setting<T: Resource + DeserializeOwned + Default>(
&mut self,
section: &str,
) -> &mut Self;
}
impl AppSettingsExt for App {
fn add_setting<T: Resource + DeserializeOwned + Default>(
&mut self,
section: &str,
) -> &mut Self {
let world = self.world_mut();
if !world.contains_resource::<Settings>() {
let settings = Settings::read(&RigHome::of(world).settings());
world.insert_resource(settings);
}
let mut settings = world.resource_mut::<Settings>();
let value = settings.get::<T>(section).unwrap_or_else(|problem| {
warn!("{problem}");
settings.problems.push(problem);
T::default()
});
self.insert_resource(value)
}
fn add_project_setting<T: Resource + DeserializeOwned + Default>(
&mut self,
section: &str,
) -> &mut Self {
self.add_setting::<T>(section);
if !self.world().contains_resource::<ProjectSettings>() {
self.init_resource::<ProjectSettings>()
.add_systems(PreStartup, read_project_settings);
}
self.world_mut()
.resource_mut::<ProjectSettings>()
.0
.push((section.to_owned(), insert_merged::<T>));
self
}
}
#[derive(Resource, Default)]
struct ProjectSettings(Vec<(String, Insert)>);
type Insert = fn(&mut World, toml::Value) -> Result<(), String>;
fn insert_merged<T: Resource + DeserializeOwned + Default>(
world: &mut World,
table: toml::Value,
) -> Result<(), String> {
let value = table
.try_into::<T>()
.map_err(|failure| failure.to_string())?;
world.insert_resource(value);
Ok(())
}
fn read_project_settings(world: &mut World) {
let root = world
.get_resource::<ProjectRoot>()
.map_or_else(|| ProjectRoot::default().0, |root| root.0.clone());
let workspace = world
.get_resource::<ProjectWorkspace>()
.cloned()
.unwrap_or_default();
let path: PathBuf = root.join(".rig").join("settings.toml");
let file = path.display().to_string();
let project = match workspace.0.read(&path, rig_tools::MAX_FILE_BYTES) {
Ok(contents) => Settings::parse(file, &String::from_utf8_lossy(&contents.bytes)),
Err(failure) if failure.kind() == ErrorKind::NotFound => return,
Err(failure) => Settings {
problems: vec![format!("{file}: could not read: {failure}")],
..Settings::empty(file)
},
};
let declared = world
.get_resource::<ProjectSettings>()
.map(|it| it.0.clone())
.unwrap_or_default();
let mut problems = project.problems.clone();
for (section, insert) in declared {
let Some(over) = project.tables.get(§ion) else {
continue;
};
let user = world
.get_resource::<Settings>()
.and_then(|settings| settings.tables.get(§ion).cloned());
let merged = match user {
Some(user) => merge(user, over.clone()),
None => over.clone(),
};
if let Err(failure) = insert(world, merged) {
problems.push(format!("{} [{section}]: {failure}", project.file));
}
}
for problem in &problems {
warn!("{problem}");
}
if let Some(mut settings) = world.get_resource_mut::<Settings>() {
settings.problems.extend(problems);
}
}
fn merge(base: toml::Value, over: toml::Value) -> toml::Value {
match (base, over) {
(toml::Value::Table(mut base), toml::Value::Table(over)) => {
for (key, value) in over {
let merged = match base.remove(&key) {
Some(was) => merge(was, value),
None => value,
};
base.insert(key, merged);
}
toml::Value::Table(base)
}
(_, over) => over,
}
}
#[cfg(test)]
mod tests;