use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::domain::format::{AngleMode, Notation};
use crate::storage::errors::StorageResult;
use crate::theme::GlyphVariant;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PersistedState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settings: Option<PersistedSettings>,
#[serde(default)]
pub ui: UiState,
#[serde(default)]
pub variables: BTreeMap<String, PersistedValue>,
#[serde(default)]
pub history: Vec<PersistedEntry>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UiState {
#[serde(default)]
pub active_view: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glyphs: Option<GlyphVariant>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hints_visible: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PersistedValue {
pub value: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
}
impl<'de> Deserialize<'de> for PersistedValue {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Bare(f64),
Table {
value: f64,
#[serde(default)]
unit: Option<String>,
},
}
Ok(match Raw::deserialize(deserializer)? {
Raw::Bare(value) => PersistedValue { value, unit: None },
Raw::Table { value, unit } => PersistedValue { value, unit },
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PersistedSettings {
pub notation: Notation,
pub decimals: usize,
pub angle_mode: AngleMode,
pub decimal_separator: String,
#[serde(default = "default_trim_trailing_zeros")]
pub trim_trailing_zeros: bool,
}
fn default_trim_trailing_zeros() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PersistedEntry {
pub input: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
}
pub trait StateRepository {
fn load(&self) -> StorageResult<PersistedState>;
fn save(&self, state: &PersistedState) -> StorageResult<()>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_state_file_without_a_ui_section_still_loads() {
let legacy = "\
[settings]
notation = \"decimal\"
decimals = 3
angle_mode = \"deg\"
decimal_separator = \".\"
[variables]
g = 9.81
[[history]]
input = \"2+3\"
value = 5.0
";
let state: PersistedState = toml::from_str(legacy).unwrap();
assert_eq!(state.ui, UiState::default());
assert_eq!(state.ui.active_view, 0);
assert_eq!(state.ui.hints_visible, None);
assert!(state.settings.unwrap().trim_trailing_zeros);
assert_eq!(state.history.len(), 1);
}
#[test]
fn an_empty_state_file_loads_as_the_default() {
let state: PersistedState = toml::from_str("").unwrap();
assert!(state.settings.is_none());
assert!(state.variables.is_empty());
assert!(state.history.is_empty());
assert_eq!(state.ui, UiState::default());
}
#[test]
fn the_ui_section_round_trips() {
let state = PersistedState {
ui: UiState {
active_view: 2,
theme: Some("monochrome".to_string()),
glyphs: Some(GlyphVariant::Ascii),
hints_visible: Some(false),
},
..PersistedState::default()
};
let text = toml::to_string_pretty(&state).unwrap();
let loaded: PersistedState = toml::from_str(&text).unwrap();
assert_eq!(loaded.ui, state.ui);
}
#[test]
fn unset_ui_fields_are_left_out_of_the_written_file() {
let text = toml::to_string_pretty(&PersistedState::default()).unwrap();
assert!(!text.contains("theme"));
assert!(!text.contains("hints_visible"));
}
#[test]
fn a_bare_number_still_loads_as_a_dimensionless_variable() {
let state: PersistedState =
toml::from_str("[variables]\ng = 9.81\nr = 2.0\n").unwrap();
assert_eq!(
state.variables["g"],
PersistedValue {
value: 9.81,
unit: None,
},
);
assert_eq!(state.variables["r"].value, 2.0);
}
#[test]
fn a_unit_bearing_variable_loads_from_its_table() {
let state: PersistedState = toml::from_str(
"[variables]\nx = { value = 50.0, unit = \"kN\" }\n",
)
.unwrap();
assert_eq!(state.variables["x"].value, 50.0);
assert_eq!(state.variables["x"].unit.as_deref(), Some("kN"));
}
#[test]
fn variables_are_always_written_back_as_tables() {
let state = PersistedState {
variables: BTreeMap::from([(
"g".to_string(),
PersistedValue {
value: 9.81,
unit: None,
},
)]),
..PersistedState::default()
};
let text = toml::to_string_pretty(&state).unwrap();
let reloaded: PersistedState = toml::from_str(&text).unwrap();
assert_eq!(reloaded.variables["g"].value, 9.81);
}
}