use std::fs;
use std::path::Path;
use std::time::Duration;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::infrastructure::persistence::filesystem::file_lock::{FileLock, FileLockError};
#[derive(Debug, thiserror::Error)]
pub enum JsonFileError {
#[error("File not found: {path}")]
NotFound { path: String },
#[error("Lock conflict: another process is accessing {path}")]
Conflict { path: String },
#[error("Internal error: {0}")]
Internal(#[from] anyhow::Error),
}
pub struct JsonFileRepository {
pub lock_timeout: Duration,
}
impl JsonFileRepository {
const TEMP_FILE_EXTENSION: &'static str = "json.tmp";
#[must_use]
pub fn new(lock_timeout: Duration) -> Self {
Self { lock_timeout }
}
pub fn save<T: Serialize>(&self, file_path: &Path, entity: &T) -> Result<(), JsonFileError> {
Self::ensure_parent_dir(file_path)?;
let _lock = self.acquire_lock(file_path, "save")?;
let json_content = Self::serialize_to_json(entity)?;
Self::write_atomic(file_path, &json_content)?;
Ok(())
}
pub fn load<T: for<'de> Deserialize<'de>>(
&self,
file_path: &Path,
) -> Result<Option<T>, JsonFileError> {
if !file_path.exists() {
return Ok(None);
}
let _lock = self.acquire_lock(file_path, "load")?;
let content = Self::read_file_content(file_path)?;
let entity = Self::deserialize_from_json(&content, file_path)?;
Ok(Some(entity))
}
#[must_use]
pub fn exists(&self, file_path: &Path) -> bool {
file_path.exists()
}
pub fn delete(&self, file_path: &Path) -> Result<(), JsonFileError> {
if !file_path.exists() {
return Ok(());
}
let _lock = self.acquire_lock(file_path, "delete")?;
fs::remove_file(file_path)
.with_context(|| format!("Failed to delete file: {}", file_path.display()))
.map_err(JsonFileError::Internal)?;
Ok(())
}
fn ensure_parent_dir(file_path: &Path) -> Result<(), JsonFileError> {
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))
.map_err(JsonFileError::Internal)?;
}
Ok(())
}
fn write_atomic(file_path: &Path, content: &str) -> Result<(), JsonFileError> {
let temp_path = file_path.with_extension(Self::TEMP_FILE_EXTENSION);
fs::write(&temp_path, content)
.with_context(|| format!("Failed to write temporary file: {}", temp_path.display()))
.map_err(JsonFileError::Internal)?;
#[cfg(unix)]
{
use std::fs::OpenOptions;
let file = OpenOptions::new()
.write(true)
.open(&temp_path)
.with_context(|| {
format!(
"Failed to open temporary file for fsync: {}",
temp_path.display()
)
})
.map_err(JsonFileError::Internal)?;
file.sync_all()
.with_context(|| format!("Failed to fsync temporary file: {}", temp_path.display()))
.map_err(JsonFileError::Internal)?;
}
fs::rename(&temp_path, file_path)
.with_context(|| {
format!(
"Failed to rename {} to {}",
temp_path.display(),
file_path.display()
)
})
.map_err(JsonFileError::Internal)
}
fn acquire_lock(&self, file_path: &Path, operation: &str) -> Result<FileLock, JsonFileError> {
FileLock::acquire(file_path, self.lock_timeout)
.map_err(|e| Self::convert_lock_error(e, file_path, operation))
}
fn serialize_to_json<T: Serialize>(entity: &T) -> Result<String, JsonFileError> {
serde_json::to_string_pretty(entity)
.context("Failed to serialize entity to JSON")
.map_err(JsonFileError::Internal)
}
fn read_file_content(file_path: &Path) -> Result<String, JsonFileError> {
fs::read_to_string(file_path)
.with_context(|| format!("Failed to read file: {}", file_path.display()))
.map_err(JsonFileError::Internal)
}
fn deserialize_from_json<T: for<'de> Deserialize<'de>>(
content: &str,
file_path: &Path,
) -> Result<T, JsonFileError> {
serde_json::from_str(content)
.with_context(|| format!("Failed to deserialize JSON from: {}", file_path.display()))
.map_err(JsonFileError::Internal)
}
fn convert_lock_error(
error: FileLockError,
file_path: &Path,
operation: &str,
) -> JsonFileError {
match error {
FileLockError::AcquisitionTimeout { .. } | FileLockError::LockHeldByProcess { .. } => {
JsonFileError::Conflict {
path: file_path.display().to_string(),
}
}
_ => JsonFileError::Internal(anyhow::Error::from(error).context(format!(
"Lock operation failed during '{}' for: {}",
operation,
file_path.display()
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::fixtures::TestEntity;
use rstest::rstest;
use std::error::Error as StdError;
use std::path::PathBuf;
use tempfile::TempDir;
struct TestRepositoryScenario {
temp_dir: TempDir,
repo: JsonFileRepository,
file_name: String,
}
impl TestRepositoryScenario {
fn new() -> Self {
Self {
temp_dir: TempDir::new().expect("Failed to create temporary directory for test"),
repo: JsonFileRepository::new(Duration::from_secs(10)),
file_name: "test.json".to_string(),
}
}
fn with_timeout(timeout: Duration) -> Self {
Self {
temp_dir: TempDir::new().expect("Failed to create temporary directory for test"),
repo: JsonFileRepository::new(timeout),
file_name: "test.json".to_string(),
}
}
fn for_timeout_test() -> Self {
Self::with_timeout(Duration::from_millis(100))
}
#[allow(dead_code)]
fn for_success_test() -> Self {
Self::with_timeout(Duration::from_secs(10))
}
fn with_file_name(mut self, name: impl Into<String>) -> Self {
self.file_name = name.into();
self
}
#[allow(dead_code)]
fn repo(&self) -> &JsonFileRepository {
&self.repo
}
fn file_path(&self) -> PathBuf {
self.temp_dir.path().join(&self.file_name)
}
fn save<T: Serialize>(&self, entity: &T) -> Result<(), JsonFileError> {
self.repo.save(&self.file_path(), entity)
}
fn load<T: for<'de> Deserialize<'de>>(&self) -> Result<Option<T>, JsonFileError> {
self.repo.load(&self.file_path())
}
fn exists(&self) -> bool {
self.repo.exists(&self.file_path())
}
fn delete(&self) -> Result<(), JsonFileError> {
self.repo.delete(&self.file_path())
}
}
fn assert_atomic_write_completed(file_path: &Path) {
let temp_file = file_path.with_extension(JsonFileRepository::TEMP_FILE_EXTENSION);
assert!(
!temp_file.exists(),
"Temporary file should be cleaned up after atomic write: {temp_file:?}"
);
assert!(
file_path.exists(),
"Target file should exist after atomic write: {file_path:?}"
);
}
fn assert_json_structure_valid<T: for<'de> Deserialize<'de>>(
file_path: &Path,
) -> serde_json::Value {
let json_content = fs::read_to_string(file_path).expect("Should be able to read JSON file");
let parsed: serde_json::Value =
serde_json::from_str(&json_content).expect("File should contain valid JSON");
let _typed: T = serde_json::from_value(parsed.clone())
.expect("JSON should deserialize to expected type");
parsed
}
fn assert_is_conflict_error<T: std::fmt::Debug>(result: Result<T, JsonFileError>) {
assert!(result.is_err(), "Expected conflict error, got Ok result");
let err = result.expect_err("Already verified result is Err");
assert!(
matches!(err, JsonFileError::Conflict { .. }),
"Expected Conflict error, got: {err:?}"
);
}
#[allow(dead_code)]
fn assert_is_internal_error<T: std::fmt::Debug>(result: Result<T, JsonFileError>) {
assert!(result.is_err(), "Expected internal error, got Ok result");
let err = result.expect_err("Already verified result is Err");
assert!(
matches!(err, JsonFileError::Internal(_)),
"Expected Internal error, got: {err:?}"
);
}
#[test]
fn it_should_create_repository_with_custom_timeout() {
let timeout = Duration::from_secs(30);
let repo = JsonFileRepository::new(timeout);
assert_eq!(repo.lock_timeout, timeout);
}
#[test]
fn it_should_save_and_load_entity_successfully() {
let scenario = TestRepositoryScenario::new();
let entity = TestEntity::new("test-123", 42);
scenario
.save(&entity)
.expect("Failed to save entity to file");
let loaded: Option<TestEntity> = scenario.load().expect("Failed to load entity from file");
assert!(loaded.is_some());
assert_eq!(loaded.expect("Entity should exist in file"), entity);
}
#[test]
fn it_should_return_none_when_loading_nonexistent_file() {
let scenario = TestRepositoryScenario::new().with_file_name("nonexistent.json");
let result: Option<TestEntity> = scenario.load().expect("Failed to load from file");
assert!(result.is_none());
}
#[test]
fn it_should_check_if_file_exists() {
let scenario = TestRepositoryScenario::new();
let entity = TestEntity::new("test", 100);
assert!(!scenario.exists());
scenario
.save(&entity)
.expect("Failed to save entity to file");
assert!(scenario.exists());
}
#[test]
fn it_should_delete_file_successfully() {
let scenario = TestRepositoryScenario::new();
let entity = TestEntity::new("test", 100);
scenario
.save(&entity)
.expect("Failed to save entity to file");
assert!(scenario.exists());
scenario.delete().expect("Failed to delete file");
assert!(!scenario.exists());
}
#[test]
fn it_should_delete_nonexistent_file_without_error() {
let scenario = TestRepositoryScenario::new().with_file_name("nonexistent.json");
scenario
.delete()
.expect("Failed to delete nonexistent file");
}
#[rstest]
#[case("entity.json", "root directory")]
#[case("nested/entity.json", "single nested directory")]
#[case("nested/deep/entity.json", "double nested directory")]
#[case("very/deep/nested/path/entity.json", "deep nested path")]
fn it_should_create_parent_directories_automatically(
#[case] file_path: &str,
#[case] description: &str,
) {
let scenario = TestRepositoryScenario::new().with_file_name(file_path);
let entity = TestEntity::new("test", 100);
let result = scenario.save(&entity);
assert!(
result.is_ok(),
"Failed to save to {description}: {result:?}"
);
assert!(scenario.exists(), "File should exist in {description}");
let file_path = scenario.file_path();
assert!(
file_path
.parent()
.expect("File path should have parent directory")
.exists(),
"Parent directory should exist for {description}"
);
}
#[test]
fn it_should_overwrite_existing_file() {
let scenario = TestRepositoryScenario::new();
let entity1 = TestEntity::new("first", 1);
let entity2 = TestEntity::new("second", 2);
scenario
.save(&entity1)
.expect("Failed to save first entity version");
scenario
.save(&entity2)
.expect("Failed to save second entity version");
let loaded: TestEntity = scenario
.load()
.expect("Failed to load entity from file")
.expect("Entity should exist in file");
assert_eq!(loaded, entity2);
}
#[test]
fn it_should_use_atomic_writes() {
let scenario = TestRepositoryScenario::new();
let entity = TestEntity::new("test", 100);
scenario
.save(&entity)
.expect("Failed to save entity to file");
assert_atomic_write_completed(&scenario.file_path());
}
#[test]
fn it_should_preserve_json_structure() {
let scenario = TestRepositoryScenario::new();
let entity = TestEntity::new("test", 100);
scenario
.save(&entity)
.expect("Failed to save entity to file");
let json = assert_json_structure_valid::<TestEntity>(&scenario.file_path());
assert!(json.is_object());
assert_eq!(json["id"], "test");
assert_eq!(json["value"], 100);
}
#[test]
fn it_should_handle_concurrent_access_with_locking() {
let scenario = TestRepositoryScenario::for_timeout_test();
let entity = TestEntity::new("test", 100);
scenario
.save(&entity)
.expect("Failed to save entity to file");
let _lock = FileLock::acquire(&scenario.file_path(), Duration::from_secs(5))
.expect("Failed to acquire lock for test");
let result: Result<Option<TestEntity>, JsonFileError> = scenario.load();
assert_is_conflict_error(result);
}
#[test]
fn it_should_return_conflict_error_on_lock_timeout() {
let scenario = TestRepositoryScenario::with_timeout(Duration::from_millis(50));
let entity = TestEntity::new("test", 100);
scenario
.save(&entity)
.expect("Failed to save entity to file");
let _lock = FileLock::acquire(&scenario.file_path(), Duration::from_secs(5))
.expect("Failed to acquire lock for test");
let result = scenario.save(&entity);
assert_is_conflict_error(result);
}
#[test]
fn it_should_display_error_messages_correctly() {
let not_found = JsonFileError::NotFound {
path: "/path/to/file.json".to_string(),
};
let message = not_found.to_string();
assert!(
message.contains("File not found"),
"Should clearly state the problem"
);
assert!(
message.contains("/path/to/file.json"),
"Should include the file path for context"
);
let conflict = JsonFileError::Conflict {
path: "/path/to/file.json".to_string(),
};
let message = conflict.to_string();
assert!(
message.contains("Lock conflict"),
"Should clearly state lock issue"
);
assert!(
message.contains("another process"),
"Should explain the conflict source"
);
assert!(
message.contains("/path/to/file.json"),
"Should include the file path for context"
);
let internal = JsonFileError::Internal(anyhow::anyhow!("test error"));
let message = internal.to_string();
assert!(
message.contains("Internal error"),
"Should indicate internal error category"
);
assert!(
message.contains("test error"),
"Should preserve the underlying error message"
);
}
#[test]
fn it_should_preserve_error_source_chain() {
let io_error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
let anyhow_error = anyhow::Error::from(io_error).context("operation failed");
let json_error = JsonFileError::Internal(anyhow_error);
let mut source = json_error.source();
let mut chain_length = 0;
while let Some(err) = source {
chain_length += 1;
source = err.source();
}
assert!(
chain_length >= 2,
"Error chain should have at least 2 levels"
);
}
#[test]
fn it_should_preserve_full_error_context_chain_with_operation_context() {
let io_error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
let anyhow_error = anyhow::Error::from(io_error)
.context("Failed to write to temporary file")
.context("Lock operation failed during 'save' for: /data/entity.json");
let json_error = JsonFileError::Internal(anyhow_error);
let mut source = json_error.source();
let mut chain_messages = Vec::new();
while let Some(err) = source {
chain_messages.push(err.to_string());
source = err.source();
}
assert!(
chain_messages.len() >= 2,
"Error chain should preserve multiple context levels, found: {}",
chain_messages.len()
);
assert!(
chain_messages
.iter()
.any(|m| m.contains("Lock operation failed during 'save'")),
"Should preserve operation context in error chain"
);
assert!(
chain_messages
.iter()
.any(|m| m.contains("Failed to write to temporary file")),
"Should preserve intermediate context in error chain"
);
assert!(
chain_messages.iter().any(|m| m.contains("access denied")),
"Should preserve root cause in error chain"
);
}
}