use std::fs;
use std::path::PathBuf;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use tracing::debug;
const STATE_FILENAME: &str = "state.toml";
pub const LOG_PANE_MIN_HEIGHT: u16 = 4;
pub const LOG_PANE_MAX_HEIGHT: u16 = 24;
pub const LOG_PANE_DEFAULT_HEIGHT: u16 = 10;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct State {
#[serde(default = "default_log_pane_height")]
pub log_pane_height: u16,
#[serde(default = "default_active_tab")]
pub log_pane_active_tab: String,
}
impl Default for State {
fn default() -> Self {
Self {
log_pane_height: default_log_pane_height(),
log_pane_active_tab: default_active_tab(),
}
}
}
fn default_log_pane_height() -> u16 {
LOG_PANE_DEFAULT_HEIGHT
}
fn default_active_tab() -> String {
"self-http".to_string()
}
impl State {
pub fn load() -> (Self, PathBuf) {
let path = state_path();
let raw = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
debug!("no state file at {path:?}; starting from defaults");
return (Self::default(), path);
}
Err(e) => {
tracing::warn!("could not read state file {path:?}: {e}; using defaults");
return (Self::default(), path);
}
};
match toml::from_str::<State>(&raw) {
Ok(mut s) => {
s.clamp_in_place();
(s, path)
}
Err(e) => {
tracing::warn!(
"state file {path:?} is malformed ({e}); using defaults — \
fix or delete to persist new values"
);
(Self::default(), path)
}
}
}
pub fn save(&self, path: &PathBuf) {
if let Some(parent) = path.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
tracing::warn!("could not create state dir {parent:?}: {e}");
return;
}
match toml::to_string_pretty(self) {
Ok(s) => {
if let Err(e) = fs::write(path, s) {
tracing::warn!("could not write state to {path:?}: {e}");
}
}
Err(e) => tracing::warn!("could not serialize state: {e}"),
}
}
pub fn clamp_in_place(&mut self) {
self.log_pane_height = self
.log_pane_height
.clamp(LOG_PANE_MIN_HEIGHT, LOG_PANE_MAX_HEIGHT);
}
}
pub fn state_path() -> PathBuf {
if let Ok(s) = std::env::var("BEE_TUI_STATE") {
return PathBuf::from(s);
}
if let Some(proj_dirs) = ProjectDirs::from("com", "ethswarm-tools", env!("CARGO_PKG_NAME")) {
if let Some(state_dir) = proj_dirs.state_dir() {
return state_dir.join(STATE_FILENAME);
}
return proj_dirs.data_local_dir().join(STATE_FILENAME);
}
PathBuf::from(".state").join(STATE_FILENAME)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_height_is_in_range() {
let s = State::default();
assert!(s.log_pane_height >= LOG_PANE_MIN_HEIGHT);
assert!(s.log_pane_height <= LOG_PANE_MAX_HEIGHT);
}
#[test]
fn clamp_low_height() {
let mut s = State {
log_pane_height: 1,
..Default::default()
};
s.clamp_in_place();
assert_eq!(s.log_pane_height, LOG_PANE_MIN_HEIGHT);
}
#[test]
fn clamp_high_height() {
let mut s = State {
log_pane_height: 999,
..Default::default()
};
s.clamp_in_place();
assert_eq!(s.log_pane_height, LOG_PANE_MAX_HEIGHT);
}
#[test]
fn round_trip_through_disk() {
let dir = tempdir();
let path = dir.join("state.toml");
let s = State {
log_pane_height: 14,
log_pane_active_tab: "errors".into(),
};
s.save(&path);
let raw = std::fs::read_to_string(&path).expect("save must produce a readable file");
let parsed: State = toml::from_str(&raw).expect("parse must succeed");
assert_eq!(parsed, s);
}
#[test]
fn missing_file_yields_defaults() {
let dir = tempdir();
let path = dir.join("does-not-exist.toml");
assert!(matches!(
std::fs::read_to_string(&path),
Err(ref e) if e.kind() == std::io::ErrorKind::NotFound
));
let s = State::default();
assert_eq!(s, State::default());
}
fn tempdir() -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"bee-tui-state-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&p).unwrap();
p
}
}