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 61fc2c6..06b90b2 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" @@ -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/command/mod.rs b/src/app/command/mod.rs index b35a79f..c4f7cf2 100644 --- a/src/app/command/mod.rs +++ b/src/app/command/mod.rs @@ -218,6 +218,44 @@ 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::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 + /// + /// ```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::try_perform_async( + async move { tokio::fs::write(path, json).await }, + |_| 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..a21f35e --- /dev/null +++ b/src/app/persistence/mod.rs @@ -0,0 +1,78 @@ +//! Session persistence helpers for saving and loading application state. +//! +//! 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}; +//! +//! #[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"); +//! 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(); +//! tokio::fs::write(&path, &json).await.unwrap(); +//! +//! // Load it back +//! let loaded: AppState = load_state(&path).await.unwrap(); +//! assert_eq!(loaded, state); +//! # tokio::fs::remove_dir_all(&dir).await.unwrap(); +//! # }); +//! ``` + +use std::path::Path; + +use serde::de::DeserializeOwned; + +use crate::error::EnvisionError; + +/// Loads application state from a JSON file asynchronously. +/// +/// 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; +/// +/// #[derive(Deserialize)] +/// struct MyState { +/// count: i32, +/// } +/// +/// // Returns EnvisionError::Io for missing files +/// let result: Result = load_state("/nonexistent/path.json").await; +/// assert!(result.is_err()); +/// # }); +/// ``` +pub async fn load_state( + path: impl AsRef, +) -> Result { + let path = path.as_ref(); + let contents = tokio::fs::read_to_string(path).await?; + 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..f14c401 --- /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, +} + +#[tokio::test] +async 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()).await.unwrap(); + assert_eq!(loaded, state); +} + +#[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(_))); +} + +#[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()).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, EnvisionError::Config { .. })); +} + +#[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()).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, EnvisionError::Config { .. })); +} + +#[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()).await; + let err = result.unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains(&path_str), + "error message should contain path, got: {}", + msg + ); +} + +#[tokio::test] +async fn test_load_state_empty_file() { + let file = NamedTempFile::new().unwrap(); + + let result: Result = load_state(file.path()).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, EnvisionError::Config { .. })); +}