Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

Expand Down
38 changes: 38 additions & 0 deletions src/app/command/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,44 @@ impl<M> Command<M> {
}
}

/// 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<String> = Command::save_state(&state, "/tmp/state.json");
/// ```
#[cfg(feature = "serialization")]
pub fn save_state<S: serde::Serialize>(
state: &S,
path: impl Into<std::path::PathBuf>,
) -> Command<M>
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<Item = Command<M>>) -> Self {
let mut actions = Vec::new();
Expand Down
4 changes: 4 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,17 @@
mod command;
mod command_core;
mod model;
#[cfg(feature = "serialization")]
pub mod persistence;
mod runtime;
mod runtime_core;
mod subscription;
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,
Expand Down
78 changes: 78 additions & 0 deletions src/app/persistence/mod.rs
Original file line number Diff line number Diff line change
@@ -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<MyState, _> = load_state("/nonexistent/path.json").await;
/// assert!(result.is_err());
/// # });
/// ```
pub async fn load_state<S: DeserializeOwned>(
path: impl AsRef<Path>,
) -> Result<S, EnvisionError> {
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;
83 changes: 83 additions & 0 deletions src/app/persistence/tests.rs
Original file line number Diff line number Diff line change
@@ -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<TestState, _> = 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<TestState, _> = 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<TestState, _> = 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<TestState, _> = 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<TestState, _> = load_state(file.path()).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, EnvisionError::Config { .. }));
}
Loading