From 3f0c5a1c814f026ac96273f4d4f7f3e3d146b2ef Mon Sep 17 00:00:00 2001 From: Vadim Storozhilov Date: Tue, 7 Jul 2026 13:54:20 +0500 Subject: [PATCH 1/4] feat: persist session state to ~/.reqsh_state.json - Add Serialize/Deserialize to Request and Method - Add save/load methods to ShellState using serde_json - Restore state on startup, fallback to empty state on error - Skip last_request during serialization as it is transient - Add unit tests for state serialization roundtrip Closes #12 --- Cargo.toml | 2 +- src/main.rs | 8 ++++- src/request.rs | 5 +-- src/state.rs | 91 +++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 845ccbb..485df16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,5 +14,5 @@ colored = "3.1.1" dirs = "6.0.0" reqwest = { version = "0.13.2", features = ["blocking", "query"] } rustyline = "18.0.0" -serde = "1.0.228" +serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" diff --git a/src/main.rs b/src/main.rs index 2aeddce..a780d25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,9 @@ fn run_repl(ctx: Rc>) { match handle(cmd, &mut state, rl.history()) { Ok(ControlFlow::Continue) => {} Ok(ControlFlow::Exit) => { + if let Err(e) = state.save() { + eprintln!("Failed to save state: {}", e); + } break; } Err(e) => { @@ -83,6 +86,9 @@ fn run_repl(ctx: Rc>) { Parsed::Exit => { println!("{}", "Bye!".dimmed()); + if let Err(e) = ctx.borrow().save() { + eprintln!("Failed to save state: {}", e); + } break; } }, @@ -155,7 +161,7 @@ fn main() { match args.as_slice() { [] => { - let ctx = Rc::new(RefCell::new(ShellState::new())); + let ctx = Rc::new(RefCell::new(ShellState::load())); run_repl(ctx); } diff --git a/src/request.rs b/src/request.rs index 3cbebcc..76051ca 100644 --- a/src/request.rs +++ b/src/request.rs @@ -1,6 +1,7 @@ +use serde::{Deserialize, Serialize}; use std::collections::HashMap; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Request { pub method: Method, pub path: String, @@ -33,7 +34,7 @@ impl Request { } } -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub enum Method { GET, POST, diff --git a/src/state.rs b/src/state.rs index 7a909b8..0e73151 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,11 +1,15 @@ -use std::collections::HashMap; +use std::{collections::HashMap, fs, path::PathBuf}; + +use serde::{Deserialize, Serialize}; use crate::request::Request; +#[derive(Serialize, Deserialize)] pub struct ShellState { base_url: Option, headers: HashMap, variables: HashMap, + #[serde(skip)] last_request: Option, saved_requests: HashMap, timeout_secs: Option, @@ -43,6 +47,36 @@ impl ShellState { Ok(()) } + fn state_file_path() -> PathBuf { + let home = dirs::home_dir().expect("could not determine home directory"); + home.join(".reqsh_state.json") + } + + pub fn save(&self) -> Result<(), String> { + let path = Self::state_file_path(); + let json = serde_json::to_string_pretty(self) + .map_err(|e| format!("Failed to serialize state: {}", e))?; + fs::write(&path, json).map_err(|e| format!("Failed to write state file: {}", e))?; + Ok(()) + } + + pub fn load() -> Self { + let path = Self::state_file_path(); + if path.exists() { + let contents = fs::read_to_string(&path).unwrap_or_default(); + match serde_json::from_str(&contents) { + Ok(state) => return state, + Err(e) => { + eprintln!( + "Warning: Failed to parse state file: {}. Starting fresh.", + e + ); + } + } + } + Self::new() + } + pub fn get_request(&self, name: &str) -> Option<&Request> { self.saved_requests.get(name) } @@ -183,4 +217,59 @@ mod test { assert!(saved.is_some()); assert_eq!(saved.unwrap().path, "/test"); } + + #[test] + fn save_and_load_state_roundtrip() { + use crate::request::Method; + use std::fs; + + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join("reqsh_test_state.json"); + + let _ = fs::remove_file(&temp_file); + + let mut state = ShellState::new(); + state.set_base_url("https://api.test.com"); + state.set_header("Auth".to_string(), "Token123".to_string()); + state.set_variable("user".to_string(), "admin".to_string()); + state.set_timeout(60); + + let req = crate::request::Request::new(Method::POST, "/login".to_string()); + state.set_last_request(req); + state.save_request("login_req".to_string()).unwrap(); + + let json = serde_json::to_string_pretty(&state).unwrap(); + fs::write(&temp_file, &json).unwrap(); + + let loaded_json = fs::read_to_string(&temp_file).unwrap(); + let loaded_state: ShellState = serde_json::from_str(&loaded_json).unwrap(); + + assert_eq!(loaded_state.get_base_url(), Some("https://api.test.com")); + assert_eq!( + loaded_state.get_headers().get("Auth"), + Some(&"Token123".to_string()) + ); + assert_eq!( + loaded_state.get_variable("user"), + Some(&"admin".to_string()) + ); + assert_eq!(loaded_state.get_timeout(), Some(60)); + + assert!(loaded_state.get_request("login_req").is_some()); + + assert!(loaded_state.last_request.is_none()); + + let _ = fs::remove_file(&temp_file); + } + + #[test] + fn load_missing_file_returns_default_state() { + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join("reqsh_nonexistent_state.json"); + let _ = std::fs::remove_file(&temp_file); + let contents = ""; + let state: Result = serde_json::from_str(contents); + + assert!(state.is_err()); + } } From 663222298baa56901ef36e2cd9b32970cbefe34d Mon Sep 17 00:00:00 2001 From: Vadim Storozhilov Date: Wed, 8 Jul 2026 00:48:41 +0500 Subject: [PATCH 2/4] fix: add saving for interrupted, eof and error --- src/main.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index a780d25..53b1420 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,16 +100,24 @@ fn run_repl(ctx: Rc>) { } Err(ReadlineError::Interrupted) => { + if let Err(e) = ctx.borrow().save() { + eprintln!("Failed to save state: {}", e); + } println!("^C"); continue; } Err(ReadlineError::Eof) => { - println!(); + if let Err(e) = ctx.borrow().save() { + eprintln!("Failed to save state: {}", e); + } break; } Err(err) => { + if let Err(e) = ctx.borrow().save() { + eprintln!("Failed to save state: {}", e); + } eprintln!("Error: {:?}", err); break; } From 29bd7072acbf14d6299cc43635d803659e04cdbc Mon Sep 17 00:00:00 2001 From: Vadim Storozhilov Date: Wed, 8 Jul 2026 00:50:25 +0500 Subject: [PATCH 3/4] fix: remove skipping last request --- src/state.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/state.rs b/src/state.rs index 0e73151..c8a6a4c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -9,7 +9,6 @@ pub struct ShellState { base_url: Option, headers: HashMap, variables: HashMap, - #[serde(skip)] last_request: Option, saved_requests: HashMap, timeout_secs: Option, From a12657909fc5b63c24b88e9cf36ceb38802bfe6b Mon Sep 17 00:00:00 2001 From: Vadim Storozhilov Date: Wed, 8 Jul 2026 01:51:19 +0500 Subject: [PATCH 4/4] fix: fix test --- src/state.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/state.rs b/src/state.rs index c8a6a4c..051b7b9 100644 --- a/src/state.rs +++ b/src/state.rs @@ -256,8 +256,6 @@ mod test { assert!(loaded_state.get_request("login_req").is_some()); - assert!(loaded_state.last_request.is_none()); - let _ = fs::remove_file(&temp_file); }