//! Response history: every exchange is saved under the project's data folder //! (IDEA keeps them in `.idea/httpRequests`), newest first, capped. use crate::exec::{HttpResponse, PreparedRequest}; use ide_core::{IdeError, IdeResult, IpcPath}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use specta::Type; use std::path::PathBuf; pub const MAX_ENTRIES: usize = 100; const INDEX: &str = "index.json"; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] #[serde(rename = "HttpHistoryEntry")] pub struct HistoryEntry { pub id: String, /// Unix epoch, ms. pub time_ms: f64, pub name: Option, pub method: String, pub url: String, /// `None` when the request failed. pub status: Option, pub total_ms: Option, /// The `.http` file the request came from. pub file: Option, pub error: Option, } /// One request with its outcome. #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct HttpExchange { pub entry: HistoryEntry, pub request: PreparedRequest, pub response: Option, } pub struct History { dir: PathBuf, lock: Mutex<()>, } impl History { pub fn new(dir: PathBuf) -> Self { Self { dir, lock: Mutex::new(()) } } fn read_index(&self) -> Vec { std::fs::read_to_string(self.dir.join(INDEX)) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } fn file(&self, id: &str) -> IdeResult { if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { return Err(IdeError::invalid("invalid history id")); } Ok(self.dir.join(format!("{id}.json"))) } pub fn list(&self) -> Vec { let _g = self.lock.lock(); self.read_index() } pub fn get(&self, id: &str) -> IdeResult { let text = std::fs::read_to_string(self.file(id)?)?; serde_json::from_str(&text).map_err(|e| IdeError::internal(format!("corrupt history entry: {e}"))) } /// Saves `exchange`, dropping the oldest entries beyond [`MAX_ENTRIES`]. pub fn add(&self, exchange: &HttpExchange) -> IdeResult<()> { let _g = self.lock.lock(); std::fs::create_dir_all(&self.dir)?; let json = serde_json::to_string(exchange).map_err(|e| IdeError::internal(e.to_string()))?; std::fs::write(self.file(&exchange.entry.id)?, json)?; let mut index = self.read_index(); index.insert(0, exchange.entry.clone()); for old in index.drain(MAX_ENTRIES.min(index.len())..) { if let Ok(p) = self.file(&old.id) { let _ = std::fs::remove_file(p); } } self.write_index(&index) } fn write_index(&self, index: &[HistoryEntry]) -> IdeResult<()> { let json = serde_json::to_string_pretty(index).map_err(|e| IdeError::internal(e.to_string()))?; std::fs::write(self.dir.join(INDEX), json)?; Ok(()) } pub fn clear(&self) -> IdeResult<()> { let _g = self.lock.lock(); for e in self.read_index() { if let Ok(p) = self.file(&e.id) { let _ = std::fs::remove_file(p); } } if self.dir.exists() { self.write_index(&[])?; } Ok(()) } } #[cfg(test)] mod tests { use super::*; fn exchange(id: &str) -> HttpExchange { HttpExchange { entry: HistoryEntry { id: id.into(), time_ms: 1.0, name: None, method: "GET".into(), url: "http://x".into(), status: Some(200), total_ms: Some(3.0), file: None, error: None, }, request: PreparedRequest { name: None, method: "GET".into(), url: "http://x".into(), headers: vec![], body: None, }, response: None, } } #[test] fn stores_newest_first_and_caps() { let tmp = tempfile::tempdir().unwrap(); let h = History::new(tmp.path().join("h")); for i in 0..MAX_ENTRIES + 3 { h.add(&exchange(&format!("e{i}"))).unwrap(); } let list = h.list(); assert_eq!(list.len(), MAX_ENTRIES); assert_eq!(list[0].id, format!("e{}", MAX_ENTRIES + 2)); assert!(h.get("e0").is_err()); assert_eq!(h.get("e5").unwrap().entry.id, "e5"); assert!(h.get("../x").is_err()); h.clear().unwrap(); assert!(h.list().is_empty()); } }