From 26547df1e263d36551f16e63a7a88fcaedf88fc8 Mon Sep 17 00:00:00 2001 From: Ryan O'Neill Date: Sun, 1 Mar 2026 20:44:12 -0800 Subject: [PATCH 1/3] Add session persistence helpers for save/load state Add load_state(path) function that reads a JSON file and deserializes into any DeserializeOwned type. Add Command::save_state() that serializes state to JSON and writes to disk. Both behind the serialization feature flag. Uses structured EnvisionError::Config variant for deserialization errors. Co-Authored-By: Claude Opus 4.6 --- src/app/command/mod.rs | 56 ++++++++++++++++++++++++ src/app/mod.rs | 4 ++ src/app/persistence/mod.rs | 72 +++++++++++++++++++++++++++++++ src/app/persistence/tests.rs | 83 ++++++++++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+) create mode 100644 src/app/persistence/mod.rs create mode 100644 src/app/persistence/tests.rs diff --git a/src/app/command/mod.rs b/src/app/command/mod.rs index b35a79f..b213cbc 100644 --- a/src/app/command/mod.rs +++ b/src/app/command/mod.rs @@ -218,6 +218,62 @@ impl Command { } } + /// Creates a command that saves application state to a JSON file. + /// + /// Serializes the state to JSON synchronously, then writes the file + /// asynchronously via tokio. If serialization fails, returns + /// [`Command::none`] (the error is silently dropped since serialization + /// failures are programming errors rather than runtime errors). + /// + /// # Example + /// + /// ```rust + /// use envision::app::Command; + /// use serde::Serialize; + /// + /// #[derive(Serialize)] + /// struct MyState { count: i32 } + /// + /// let state = MyState { count: 42 }; + /// let cmd: Command = Command::save_state(&state, "/tmp/state.json"); + /// ``` + /// Creates a command that saves application state to a JSON file. + /// + /// Serializes the state to JSON and writes it to the specified path + /// synchronously via a callback. If serialization fails, returns + /// [`Command::none`]. + /// + /// # Example + /// + /// ```rust + /// use envision::app::Command; + /// use serde::Serialize; + /// + /// #[derive(Serialize)] + /// struct MyState { count: i32 } + /// + /// let state = MyState { count: 42 }; + /// let cmd: Command = Command::save_state(&state, "/tmp/state.json"); + /// ``` + #[cfg(feature = "serialization")] + pub fn save_state( + state: &S, + path: impl Into, + ) -> Command + where + M: Send + 'static, + { + let json = match serde_json::to_string(state) { + Ok(json) => json, + Err(_) => return Command::none(), + }; + let path = path.into(); + Command::perform(move || { + let _ = std::fs::write(path, json); + None + }) + } + /// Combines multiple commands into one. pub fn combine(commands: impl IntoIterator>) -> Self { let mut actions = Vec::new(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 1dcb3f3..b1f421f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -81,6 +81,8 @@ mod command; mod command_core; mod model; +#[cfg(feature = "serialization")] +pub mod persistence; mod runtime; mod runtime_core; mod subscription; @@ -88,6 +90,8 @@ mod update; pub use command::{BoxedError, Command, CommandHandler}; pub use model::App; +#[cfg(feature = "serialization")] +pub use persistence::load_state; pub use runtime::{Runtime, RuntimeConfig}; pub use subscription::{ batch, interval_immediate, terminal_events, tick, BatchSubscription, BoxedSubscription, diff --git a/src/app/persistence/mod.rs b/src/app/persistence/mod.rs new file mode 100644 index 0000000..d5708bc --- /dev/null +++ b/src/app/persistence/mod.rs @@ -0,0 +1,72 @@ +//! Session persistence helpers for saving and loading application state. +//! +//! This module provides convenience functions for serializing application state +//! to JSON files and deserializing it back. All functions require the +//! `serialization` feature. +//! +//! # Example +//! +//! ```rust +//! use envision::app::persistence::load_state; +//! use serde::{Deserialize, Serialize}; +//! +//! #[derive(Serialize, Deserialize, Default, PartialEq, Debug)] +//! struct AppState { +//! counter: i32, +//! name: String, +//! } +//! +//! // Save state to a file +//! let dir = std::env::temp_dir().join("envision_doc_test"); +//! std::fs::create_dir_all(&dir).unwrap(); +//! let path = dir.join("state.json"); +//! let state = AppState { counter: 42, name: "test".into() }; +//! let json = serde_json::to_string(&state).unwrap(); +//! std::fs::write(&path, &json).unwrap(); +//! +//! // Load it back +//! let loaded: AppState = load_state(&path).unwrap(); +//! assert_eq!(loaded, state); +//! # std::fs::remove_dir_all(&dir).unwrap(); +//! ``` + +use std::path::Path; + +use serde::de::DeserializeOwned; + +use crate::error::EnvisionError; + +/// Loads application state from a JSON file. +/// +/// Reads the file at `path`, deserializes it as JSON, and returns the +/// deserialized state. Returns [`EnvisionError::Io`] for file system errors +/// and [`EnvisionError::Config`] for deserialization errors. +/// +/// # Example +/// +/// ```rust +/// use envision::app::persistence::load_state; +/// use serde::Deserialize; +/// +/// #[derive(Deserialize)] +/// struct MyState { +/// count: i32, +/// } +/// +/// // Returns EnvisionError::Io for missing files +/// let result: Result = load_state("/nonexistent/path.json"); +/// assert!(result.is_err()); +/// ``` +pub fn load_state(path: impl AsRef) -> Result { + let path = path.as_ref(); + let contents = std::fs::read_to_string(path)?; + serde_json::from_str(&contents).map_err(|e| { + EnvisionError::config( + path.display().to_string(), + format!("failed to deserialize state: {}", e), + ) + }) +} + +#[cfg(test)] +mod tests; diff --git a/src/app/persistence/tests.rs b/src/app/persistence/tests.rs new file mode 100644 index 0000000..60b009d --- /dev/null +++ b/src/app/persistence/tests.rs @@ -0,0 +1,83 @@ +use std::io::Write; + +use serde::{Deserialize, Serialize}; +use tempfile::NamedTempFile; + +use super::*; + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +struct TestState { + counter: i32, + name: String, +} + +#[test] +fn test_load_state_success() { + let state = TestState { + counter: 42, + name: "hello".into(), + }; + let json = serde_json::to_string(&state).unwrap(); + + let mut file = NamedTempFile::new().unwrap(); + file.write_all(json.as_bytes()).unwrap(); + + let loaded: TestState = load_state(file.path()).unwrap(); + assert_eq!(loaded, state); +} + +#[test] +fn test_load_state_file_not_found() { + let result: Result = load_state("/nonexistent/path/state.json"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, EnvisionError::Io(_))); +} + +#[test] +fn test_load_state_invalid_json() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"not valid json {{{").unwrap(); + + let result: Result = load_state(file.path()); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, EnvisionError::Config { .. })); +} + +#[test] +fn test_load_state_wrong_shape() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"{\"x\": 1, \"y\": 2}").unwrap(); + + let result: Result = load_state(file.path()); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, EnvisionError::Config { .. })); +} + +#[test] +fn test_load_state_error_message_contains_path() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"invalid").unwrap(); + let path_str = file.path().display().to_string(); + + let result: Result = load_state(file.path()); + let err = result.unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains(&path_str), + "error message should contain path, got: {}", + msg + ); +} + +#[test] +fn test_load_state_empty_file() { + let file = NamedTempFile::new().unwrap(); + + let result: Result = load_state(file.path()); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, EnvisionError::Config { .. })); +} From 42352d9826302cc1e7b67af12bd9bab9fa4d5833 Mon Sep 17 00:00:00 2001 From: Ryan O'Neill Date: Sun, 1 Mar 2026 20:46:53 -0800 Subject: [PATCH 2/3] Use async tokio::fs::write for save_state instead of sync std::fs Add tokio "fs" feature to enable tokio::fs::write for async file I/O in Command::save_state. File write errors are reported to the runtime's error channel via try_perform_async. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 2 +- src/app/command/mod.rs | 32 +++++++------------------------- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 61fc2c6..95d30e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ tracing = { version = "0.1", optional = true } arboard = { version = "3", optional = true } unicode-width = "0.2" compact_str = "0.8" -tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } +tokio = { version = "1", features = ["sync", "rt", "macros", "time", "fs"] } tokio-stream = "0.1" tokio-util = "0.7" async-stream = "0.3" diff --git a/src/app/command/mod.rs b/src/app/command/mod.rs index b213cbc..c4f7cf2 100644 --- a/src/app/command/mod.rs +++ b/src/app/command/mod.rs @@ -221,27 +221,9 @@ impl Command { /// Creates a command that saves application state to a JSON file. /// /// Serializes the state to JSON synchronously, then writes the file - /// asynchronously via tokio. If serialization fails, returns - /// [`Command::none`] (the error is silently dropped since serialization - /// failures are programming errors rather than runtime errors). - /// - /// # Example - /// - /// ```rust - /// use envision::app::Command; - /// use serde::Serialize; - /// - /// #[derive(Serialize)] - /// struct MyState { count: i32 } - /// - /// let state = MyState { count: 42 }; - /// let cmd: Command = Command::save_state(&state, "/tmp/state.json"); - /// ``` - /// Creates a command that saves application state to a JSON file. - /// - /// Serializes the state to JSON and writes it to the specified path - /// synchronously via a callback. If serialization fails, returns - /// [`Command::none`]. + /// asynchronously via `tokio::fs::write`. If serialization fails, + /// returns [`Command::none`]. File write errors are reported to the + /// runtime's error channel (see [`Runtime::take_errors`](crate::Runtime::take_errors)). /// /// # Example /// @@ -268,10 +250,10 @@ impl Command { Err(_) => return Command::none(), }; let path = path.into(); - Command::perform(move || { - let _ = std::fs::write(path, json); - None - }) + Command::try_perform_async( + async move { tokio::fs::write(path, json).await }, + |_| None, + ) } /// Combines multiple commands into one. From f8c8a79b82a61fe9185071caf99e7869b5e13127 Mon Sep 17 00:00:00 2001 From: Ryan O'Neill Date: Sun, 1 Mar 2026 20:57:04 -0800 Subject: [PATCH 3/3] Make load_state async using tokio::fs::read_to_string load_state now uses tokio::fs::read_to_string for non-blocking file I/O, consistent with save_state's use of tokio::fs::write. All persistence tests updated to #[tokio::test]. Added tokio-test dev-dependency for async doc test support. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 12 ++++++++++++ Cargo.toml | 1 + src/app/persistence/mod.rs | 32 +++++++++++++++++++------------- src/app/persistence/tests.rs | 36 ++++++++++++++++++------------------ 4 files changed, 50 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aa3fed1..e5c5e0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -430,6 +430,7 @@ dependencies = [ "tempfile", "tokio", "tokio-stream", + "tokio-test", "tokio-util", "tracing", "unicode-width 0.2.0", @@ -1474,6 +1475,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-util" version = "0.7.18" diff --git a/Cargo.toml b/Cargo.toml index 95d30e4..06b90b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ pretty_assertions = "1.4" tempfile = "3.24.0" criterion = { version = "0.5", features = ["html_reports"] } proptest = "~1.8" +tokio-test = "0.4" # Note: fastbreak is used for spec validation via CLI, not as a cargo dependency # Run `fastbreak check` to validate specs/main.fbrk diff --git a/src/app/persistence/mod.rs b/src/app/persistence/mod.rs index d5708bc..a21f35e 100644 --- a/src/app/persistence/mod.rs +++ b/src/app/persistence/mod.rs @@ -1,12 +1,13 @@ //! Session persistence helpers for saving and loading application state. //! -//! This module provides convenience functions for serializing application state -//! to JSON files and deserializing it back. All functions require the +//! This module provides async convenience functions for serializing application +//! state to JSON files and deserializing it back. All functions require the //! `serialization` feature. //! //! # Example //! //! ```rust +//! # tokio_test::block_on(async { //! use envision::app::persistence::load_state; //! use serde::{Deserialize, Serialize}; //! @@ -18,16 +19,17 @@ //! //! // Save state to a file //! let dir = std::env::temp_dir().join("envision_doc_test"); -//! std::fs::create_dir_all(&dir).unwrap(); +//! tokio::fs::create_dir_all(&dir).await.unwrap(); //! let path = dir.join("state.json"); //! let state = AppState { counter: 42, name: "test".into() }; //! let json = serde_json::to_string(&state).unwrap(); -//! std::fs::write(&path, &json).unwrap(); +//! tokio::fs::write(&path, &json).await.unwrap(); //! //! // Load it back -//! let loaded: AppState = load_state(&path).unwrap(); +//! let loaded: AppState = load_state(&path).await.unwrap(); //! assert_eq!(loaded, state); -//! # std::fs::remove_dir_all(&dir).unwrap(); +//! # tokio::fs::remove_dir_all(&dir).await.unwrap(); +//! # }); //! ``` use std::path::Path; @@ -36,15 +38,16 @@ use serde::de::DeserializeOwned; use crate::error::EnvisionError; -/// Loads application state from a JSON file. +/// Loads application state from a JSON file asynchronously. /// -/// Reads the file at `path`, deserializes it as JSON, and returns the -/// deserialized state. Returns [`EnvisionError::Io`] for file system errors -/// and [`EnvisionError::Config`] for deserialization errors. +/// Reads the file at `path` using `tokio::fs`, deserializes it as JSON, and +/// returns the deserialized state. Returns [`EnvisionError::Io`] for file +/// system errors and [`EnvisionError::Config`] for deserialization errors. /// /// # Example /// /// ```rust +/// # tokio_test::block_on(async { /// use envision::app::persistence::load_state; /// use serde::Deserialize; /// @@ -54,12 +57,15 @@ use crate::error::EnvisionError; /// } /// /// // Returns EnvisionError::Io for missing files -/// let result: Result = load_state("/nonexistent/path.json"); +/// let result: Result = load_state("/nonexistent/path.json").await; /// assert!(result.is_err()); +/// # }); /// ``` -pub fn load_state(path: impl AsRef) -> Result { +pub async fn load_state( + path: impl AsRef, +) -> Result { let path = path.as_ref(); - let contents = std::fs::read_to_string(path)?; + let contents = tokio::fs::read_to_string(path).await?; serde_json::from_str(&contents).map_err(|e| { EnvisionError::config( path.display().to_string(), diff --git a/src/app/persistence/tests.rs b/src/app/persistence/tests.rs index 60b009d..f14c401 100644 --- a/src/app/persistence/tests.rs +++ b/src/app/persistence/tests.rs @@ -11,8 +11,8 @@ struct TestState { name: String, } -#[test] -fn test_load_state_success() { +#[tokio::test] +async fn test_load_state_success() { let state = TestState { counter: 42, name: "hello".into(), @@ -22,47 +22,47 @@ fn test_load_state_success() { let mut file = NamedTempFile::new().unwrap(); file.write_all(json.as_bytes()).unwrap(); - let loaded: TestState = load_state(file.path()).unwrap(); + let loaded: TestState = load_state(file.path()).await.unwrap(); assert_eq!(loaded, state); } -#[test] -fn test_load_state_file_not_found() { - let result: Result = load_state("/nonexistent/path/state.json"); +#[tokio::test] +async fn test_load_state_file_not_found() { + let result: Result = load_state("/nonexistent/path/state.json").await; assert!(result.is_err()); let err = result.unwrap_err(); assert!(matches!(err, EnvisionError::Io(_))); } -#[test] -fn test_load_state_invalid_json() { +#[tokio::test] +async fn test_load_state_invalid_json() { let mut file = NamedTempFile::new().unwrap(); file.write_all(b"not valid json {{{").unwrap(); - let result: Result = load_state(file.path()); + let result: Result = load_state(file.path()).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!(matches!(err, EnvisionError::Config { .. })); } -#[test] -fn test_load_state_wrong_shape() { +#[tokio::test] +async fn test_load_state_wrong_shape() { let mut file = NamedTempFile::new().unwrap(); file.write_all(b"{\"x\": 1, \"y\": 2}").unwrap(); - let result: Result = load_state(file.path()); + let result: Result = load_state(file.path()).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!(matches!(err, EnvisionError::Config { .. })); } -#[test] -fn test_load_state_error_message_contains_path() { +#[tokio::test] +async fn test_load_state_error_message_contains_path() { let mut file = NamedTempFile::new().unwrap(); file.write_all(b"invalid").unwrap(); let path_str = file.path().display().to_string(); - let result: Result = load_state(file.path()); + let result: Result = load_state(file.path()).await; let err = result.unwrap_err(); let msg = err.to_string(); assert!( @@ -72,11 +72,11 @@ fn test_load_state_error_message_contains_path() { ); } -#[test] -fn test_load_state_empty_file() { +#[tokio::test] +async fn test_load_state_empty_file() { let file = NamedTempFile::new().unwrap(); - let result: Result = load_state(file.path()); + let result: Result = load_state(file.path()).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!(matches!(err, EnvisionError::Config { .. }));