Skip to main content

bee_tui/
state.rs

1//! Persisted runtime state — the *operator-tweaked, app-written*
2//! file that's distinct from `config.toml` (operator-edited,
3//! app-read-only).
4//!
5//! XDG split: per <https://specifications.freedesktop.org/basedir-spec/>,
6//! `state` is the right home for "things the user expects to persist
7//! but doesn't manage by hand": last-active tab, last pane height,
8//! cursor positions on long lists. Keeping it separate from
9//! `config.toml` means a `git diff` of the operator's dotfiles only
10//! shows real config edits.
11//!
12//! Path resolution mirrors `config.rs`'s `get_data_dir` /
13//! `get_config_dir`:
14//! - `$BEE_TUI_STATE` env var (override for tests / sandboxing)
15//! - `ProjectDirs::state_dir()` (Linux: `~/.local/state/bee-tui/`)
16//! - `data_local_dir()` (macOS / Windows fallback — no XDG state dir
17//!   convention there; data dir is the closest match)
18//! - `./.state` as a last resort
19
20use std::fs;
21use std::path::PathBuf;
22
23use directories::ProjectDirs;
24use serde::{Deserialize, Serialize};
25use tracing::debug;
26
27/// Filename used inside the resolved state directory. Single-file
28/// design — the schema is small enough that a directory tree would
29/// be over-engineered.
30const STATE_FILENAME: &str = "state.toml";
31
32/// Bounds for the bottom log pane height. Below 4 the tab strip + a
33/// content row + borders don't fit; above 24 we steal too much from
34/// the active screen on a typical 80×24 terminal.
35pub const LOG_PANE_MIN_HEIGHT: u16 = 4;
36pub const LOG_PANE_MAX_HEIGHT: u16 = 24;
37pub const LOG_PANE_DEFAULT_HEIGHT: u16 = 10;
38
39/// Persisted across launches.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct State {
42    /// Height of the bottom log pane (in terminal lines, including
43    /// the tab strip + borders). Clamped at load time so a
44    /// hand-edited corrupt value can't break the layout.
45    #[serde(default = "default_log_pane_height")]
46    pub log_pane_height: u16,
47    /// Last tab the operator had focused on the log pane. Stored as
48    /// a kebab-case string (`errors`, `warning`, `info`, `debug`,
49    /// `bee-http`, `self-http`). Unknown values fall back to the
50    /// default tab silently.
51    #[serde(default = "default_active_tab")]
52    pub log_pane_active_tab: String,
53}
54
55impl Default for State {
56    fn default() -> Self {
57        Self {
58            log_pane_height: default_log_pane_height(),
59            log_pane_active_tab: default_active_tab(),
60        }
61    }
62}
63
64fn default_log_pane_height() -> u16 {
65    LOG_PANE_DEFAULT_HEIGHT
66}
67
68fn default_active_tab() -> String {
69    "self-http".to_string()
70}
71
72impl State {
73    /// Load from the resolved state path. Missing file → defaults
74    /// (no warning — first-run is normal). Parse error → defaults +
75    /// a tracing warning (corrupt file shouldn't block startup, but
76    /// the operator should see it). Returns `(State, source_path)`
77    /// so callers can save back to the same place on quit.
78    pub fn load() -> (Self, PathBuf) {
79        let path = state_path();
80        let raw = match fs::read_to_string(&path) {
81            Ok(s) => s,
82            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
83                debug!("no state file at {path:?}; starting from defaults");
84                return (Self::default(), path);
85            }
86            Err(e) => {
87                tracing::warn!("could not read state file {path:?}: {e}; using defaults");
88                return (Self::default(), path);
89            }
90        };
91        match toml::from_str::<State>(&raw) {
92            Ok(mut s) => {
93                s.clamp_in_place();
94                (s, path)
95            }
96            Err(e) => {
97                tracing::warn!(
98                    "state file {path:?} is malformed ({e}); using defaults — \
99                     fix or delete to persist new values"
100                );
101                (Self::default(), path)
102            }
103        }
104    }
105
106    /// Best-effort save. Failures are logged at warn-level but never
107    /// surfaced as errors to the operator — losing a pane-height
108    /// preference shouldn't block quit.
109    pub fn save(&self, path: &PathBuf) {
110        if let Some(parent) = path.parent()
111            && let Err(e) = fs::create_dir_all(parent)
112        {
113            tracing::warn!("could not create state dir {parent:?}: {e}");
114            return;
115        }
116        match toml::to_string_pretty(self) {
117            Ok(s) => {
118                if let Err(e) = fs::write(path, s) {
119                    tracing::warn!("could not write state to {path:?}: {e}");
120                }
121            }
122            Err(e) => tracing::warn!("could not serialize state: {e}"),
123        }
124    }
125
126    /// Force every field into its valid range. Pure — exposed for
127    /// tests so they can verify clamping without round-tripping
128    /// through the filesystem.
129    pub fn clamp_in_place(&mut self) {
130        self.log_pane_height = self
131            .log_pane_height
132            .clamp(LOG_PANE_MIN_HEIGHT, LOG_PANE_MAX_HEIGHT);
133    }
134}
135
136/// Resolve the state-file path. See module docs for the precedence
137/// chain.
138pub fn state_path() -> PathBuf {
139    if let Ok(s) = std::env::var("BEE_TUI_STATE") {
140        return PathBuf::from(s);
141    }
142    if let Some(proj_dirs) = ProjectDirs::from("com", "ethswarm-tools", env!("CARGO_PKG_NAME")) {
143        if let Some(state_dir) = proj_dirs.state_dir() {
144            return state_dir.join(STATE_FILENAME);
145        }
146        // macOS / Windows: no XDG state dir, fall back to the
147        // platform's per-app data directory.
148        return proj_dirs.data_local_dir().join(STATE_FILENAME);
149    }
150    PathBuf::from(".state").join(STATE_FILENAME)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn default_height_is_in_range() {
159        let s = State::default();
160        assert!(s.log_pane_height >= LOG_PANE_MIN_HEIGHT);
161        assert!(s.log_pane_height <= LOG_PANE_MAX_HEIGHT);
162    }
163
164    #[test]
165    fn clamp_low_height() {
166        let mut s = State {
167            log_pane_height: 1,
168            ..Default::default()
169        };
170        s.clamp_in_place();
171        assert_eq!(s.log_pane_height, LOG_PANE_MIN_HEIGHT);
172    }
173
174    #[test]
175    fn clamp_high_height() {
176        let mut s = State {
177            log_pane_height: 999,
178            ..Default::default()
179        };
180        s.clamp_in_place();
181        assert_eq!(s.log_pane_height, LOG_PANE_MAX_HEIGHT);
182    }
183
184    #[test]
185    fn round_trip_through_disk() {
186        let dir = tempdir();
187        let path = dir.join("state.toml");
188        let s = State {
189            log_pane_height: 14,
190            log_pane_active_tab: "errors".into(),
191        };
192        s.save(&path);
193        // Forcibly use a known path bypassing env var to keep this
194        // test isolated even when other tests run in parallel.
195        let raw = std::fs::read_to_string(&path).expect("save must produce a readable file");
196        let parsed: State = toml::from_str(&raw).expect("parse must succeed");
197        assert_eq!(parsed, s);
198    }
199
200    #[test]
201    fn missing_file_yields_defaults() {
202        let dir = tempdir();
203        let path = dir.join("does-not-exist.toml");
204        // Manually exercise the load path's missing-file branch by
205        // checking that read returns NotFound; the public load() is
206        // env-coupled so we mimic its behaviour here.
207        assert!(matches!(
208            std::fs::read_to_string(&path),
209            Err(ref e) if e.kind() == std::io::ErrorKind::NotFound
210        ));
211        // The State::default round-trip is the contract:
212        let s = State::default();
213        assert_eq!(s, State::default());
214    }
215
216    fn tempdir() -> PathBuf {
217        let mut p = std::env::temp_dir();
218        p.push(format!(
219            "bee-tui-state-test-{}",
220            std::time::SystemTime::now()
221                .duration_since(std::time::UNIX_EPOCH)
222                .map(|d| d.as_nanos())
223                .unwrap_or(0)
224        ));
225        std::fs::create_dir_all(&p).unwrap();
226        p
227    }
228}