use super::types::{IdempotencyEntry, RequestFingerprint, ScopedKey};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use thiserror::Error;
pub const DEFAULT_TTL_SECONDS: u64 = 7 * 24 * 60 * 60;
pub const DEFAULT_CAPACITY: usize = 10_000;
#[derive(Debug, Error)]
pub enum IdempotencyError {
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("JSON serialization error: {0}")]
JsonError(#[from] serde_json::Error),
#[error("Fingerprint mismatch: different request with same idempotency key")]
FingerprintMismatch,
#[error("Store error: {0}")]
StoreError(String),
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IdempotencyStore {
entries: HashMap<String, IdempotencyEntry>,
#[serde(skip)]
capacity: usize,
#[serde(skip)]
store_path: PathBuf,
}
impl IdempotencyStore {
pub fn new() -> Result<Self, IdempotencyError> {
let store_path = Self::default_store_path()?;
Self::load_or_create(store_path, DEFAULT_CAPACITY)
}
pub fn with_path(store_path: PathBuf) -> Result<Self, IdempotencyError> {
Self::load_or_create(store_path, DEFAULT_CAPACITY)
}
fn default_store_path() -> Result<PathBuf, IdempotencyError> {
let project_dirs = directories::ProjectDirs::from("", "", "slack-rs")
.ok_or_else(|| IdempotencyError::StoreError("Cannot find config directory".into()))?;
let config_dir = project_dirs.config_dir();
if !config_dir.exists() {
fs::create_dir_all(config_dir)?;
}
Ok(config_dir.join("idempotency_store.json"))
}
fn load_or_create(store_path: PathBuf, capacity: usize) -> Result<Self, IdempotencyError> {
if store_path.exists() {
let content = fs::read_to_string(&store_path)?;
let mut store: IdempotencyStore = serde_json::from_str(&content)?;
store.store_path = store_path;
store.capacity = capacity;
store.gc()?;
Ok(store)
} else {
let store = IdempotencyStore {
entries: HashMap::new(),
capacity,
store_path,
};
if let Some(parent) = store.store_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut options = fs::OpenOptions::new();
options.write(true).create(true).mode(0o600);
options.open(&store.store_path)?;
}
#[cfg(not(unix))]
{
fs::write(&store.store_path, "{}")?;
}
store.save()?;
Ok(store)
}
}
pub fn get(&self, key: &ScopedKey) -> Option<&IdempotencyEntry> {
let key_str = key.to_string();
self.entries.get(&key_str).filter(|e| !e.is_expired())
}
pub fn check(
&self,
key: &ScopedKey,
fingerprint: &RequestFingerprint,
) -> Result<Option<serde_json::Value>, IdempotencyError> {
if let Some(entry) = self.get(key) {
if entry.fingerprint == *fingerprint {
Ok(Some(entry.response.clone()))
} else {
Err(IdempotencyError::FingerprintMismatch)
}
} else {
Ok(None)
}
}
pub fn put(
&mut self,
key: ScopedKey,
fingerprint: RequestFingerprint,
response: serde_json::Value,
) -> Result<(), IdempotencyError> {
self.gc()?;
let entry = IdempotencyEntry::new(fingerprint, response, DEFAULT_TTL_SECONDS);
let key_str = key.to_string();
self.entries.insert(key_str, entry);
self.save()
}
fn gc(&mut self) -> Result<(), IdempotencyError> {
self.entries.retain(|_, entry| !entry.is_expired());
if self.entries.len() > self.capacity {
let mut entries: Vec<_> = self
.entries
.iter()
.map(|(k, v)| (k.clone(), v.created_at))
.collect();
entries.sort_by_key(|(_, created_at)| *created_at);
let to_remove = self.entries.len() - self.capacity;
for (key, _) in entries.iter().take(to_remove) {
self.entries.remove(key);
}
}
Ok(())
}
fn save(&self) -> Result<(), IdempotencyError> {
let content = serde_json::to_string_pretty(&self)?;
fs::write(&self.store_path, content)?;
#[cfg(unix)]
{
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
let perms = Permissions::from_mode(0o600);
fs::set_permissions(&self.store_path, perms)?;
}
Ok(())
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.entries.len()
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
fn create_test_store() -> (IdempotencyStore, TempDir) {
let temp_dir = TempDir::new().unwrap();
let store_path = temp_dir.path().join("test_store.json");
let store = IdempotencyStore::with_path(store_path).unwrap();
(store, temp_dir)
}
#[test]
fn test_store_creation() {
let (store, _temp) = create_test_store();
assert_eq!(store.len(), 0);
assert!(store.is_empty());
}
#[test]
fn test_put_and_get() {
let (mut store, _temp) = create_test_store();
let key = ScopedKey::new(
"T123".into(),
"U456".into(),
"chat.postMessage".into(),
"test-key".into(),
);
let mut params = serde_json::Map::new();
params.insert("channel".into(), json!("C123"));
params.insert("text".into(), json!("hello"));
let fingerprint = RequestFingerprint::from_params(¶ms);
let response = json!({"ok": true, "ts": "1234567890.123456"});
store
.put(key.clone(), fingerprint.clone(), response.clone())
.unwrap();
let result = store.check(&key, &fingerprint).unwrap();
assert_eq!(result, Some(response));
}
#[test]
fn test_fingerprint_mismatch() {
let (mut store, _temp) = create_test_store();
let key = ScopedKey::new(
"T123".into(),
"U456".into(),
"chat.postMessage".into(),
"test-key".into(),
);
let mut params1 = serde_json::Map::new();
params1.insert("channel".into(), json!("C123"));
params1.insert("text".into(), json!("hello"));
let fingerprint1 = RequestFingerprint::from_params(¶ms1);
let response = json!({"ok": true, "ts": "1234567890.123456"});
store.put(key.clone(), fingerprint1, response).unwrap();
let mut params2 = serde_json::Map::new();
params2.insert("channel".into(), json!("C123"));
params2.insert("text".into(), json!("different"));
let fingerprint2 = RequestFingerprint::from_params(¶ms2);
let result = store.check(&key, &fingerprint2);
assert!(matches!(result, Err(IdempotencyError::FingerprintMismatch)));
}
#[test]
fn test_gc_expired_entries() {
let (mut store, _temp) = create_test_store();
let key = ScopedKey::new(
"T123".into(),
"U456".into(),
"chat.postMessage".into(),
"test-key".into(),
);
let mut params = serde_json::Map::new();
params.insert("channel".into(), json!("C123"));
let fingerprint = RequestFingerprint::from_params(¶ms);
let response = json!({"ok": true});
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let entry = IdempotencyEntry {
fingerprint: fingerprint.clone(),
response,
created_at: now - 10,
expires_at: now - 5, };
store.entries.insert(key.to_string(), entry);
assert_eq!(store.len(), 1);
store.gc().unwrap();
assert_eq!(store.len(), 0);
}
#[test]
fn test_gc_capacity_limit() {
let (mut store, _temp) = create_test_store();
store.capacity = 3;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
for i in 0..5 {
let key = ScopedKey::new(
"T123".into(),
"U456".into(),
"chat.postMessage".into(),
format!("key-{}", i),
);
let mut params = serde_json::Map::new();
params.insert("i".into(), json!(i));
let fingerprint = RequestFingerprint::from_params(¶ms);
let response = json!({"ok": true, "i": i});
let entry = IdempotencyEntry {
fingerprint,
response,
created_at: now + i,
expires_at: now + DEFAULT_TTL_SECONDS + i,
};
let key_str = key.to_string();
store.entries.insert(key_str, entry);
}
store.gc().unwrap();
assert_eq!(store.len(), 3);
let key0 = ScopedKey::new(
"T123".into(),
"U456".into(),
"chat.postMessage".into(),
"key-0".into(),
);
assert!(store.get(&key0).is_none());
}
#[test]
fn test_persistence() {
let temp_dir = TempDir::new().unwrap();
let store_path = temp_dir.path().join("test_store.json");
let key = ScopedKey::new(
"T123".into(),
"U456".into(),
"chat.postMessage".into(),
"test-key".into(),
);
let mut params = serde_json::Map::new();
params.insert("channel".into(), json!("C123"));
let fingerprint = RequestFingerprint::from_params(¶ms);
let response = json!({"ok": true});
{
let mut store = IdempotencyStore::with_path(store_path.clone()).unwrap();
store
.put(key.clone(), fingerprint.clone(), response.clone())
.unwrap();
}
{
let store = IdempotencyStore::with_path(store_path).unwrap();
let result = store.check(&key, &fingerprint).unwrap();
assert_eq!(result, Some(response));
}
}
}