use std::fs;
use std::io::Write;
use std::path::PathBuf;
use crate::domain::ExportState;
use crate::error::ScraperError;
use dirs::cache_dir;
use tracing::{debug, info};
#[derive(Debug)]
pub struct StateStore {
domain: String,
cache_dir: PathBuf,
}
impl StateStore {
#[must_use]
pub fn new(domain: &str) -> Self {
let mut cache_dir = cache_dir().unwrap_or_else(|| PathBuf::from(".cache"));
cache_dir.push("rust-scraper");
cache_dir.push("state");
Self {
domain: domain.to_string(),
cache_dir,
}
}
pub fn set_cache_dir(&mut self, cache_dir: PathBuf) {
self.cache_dir = cache_dir;
}
#[must_use]
pub fn get_state_path(&self) -> PathBuf {
let mut path = self.cache_dir.clone();
path.push(format!("{}.json", self.domain));
path
}
pub fn load(&self) -> crate::error::Result<ExportState> {
let path = self.get_state_path();
if !path.exists() {
debug!("State file does not exist: {}", path.display());
let err = std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("State file not found: {}", path.display()),
);
return Err(ScraperError::Io(err));
}
let content = fs::read_to_string(&path).map_err(ScraperError::Io)?;
let state: ExportState =
serde_json::from_str(&content).map_err(ScraperError::Serialization)?;
debug!(
"Loaded state for domain {}: {} URLs processed",
self.domain,
state.processed_urls.len()
);
Ok(state)
}
pub fn save(&self, state: &ExportState) -> crate::error::Result<()> {
let path = self.get_state_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(ScraperError::Io)?; }
let json = serde_json::to_string_pretty(state).map_err(ScraperError::Serialization)?;
let mut temp_path = path.clone();
temp_path.set_extension("tmp");
let mut file = fs::File::create(&temp_path).map_err(ScraperError::Io)?;
file.write_all(json.as_bytes()).map_err(ScraperError::Io)?;
fs::rename(&temp_path, &path).map_err(ScraperError::Io)?;
debug!(
"Saved state for domain {}: {} URLs processed",
self.domain,
state.processed_urls.len()
);
Ok(())
}
pub fn mark_processed(&self, state: &mut ExportState, url: &str) {
state.mark_processed(url);
debug!("Marked URL as processed: {}", url);
}
#[must_use]
pub fn is_processed(&self, state: &ExportState, url: &str) -> bool {
let processed = state.is_processed(url);
debug!("URL {} processed: {}", url, processed);
processed
}
pub fn load_or_default(&self) -> crate::error::Result<ExportState> {
match self.load() {
Ok(state) => {
info!("Loaded existing state for domain: {}", self.domain);
Ok(state)
}
Err(ScraperError::Io(io_err)) => {
if io_err.kind() == std::io::ErrorKind::NotFound {
info!("Creating new state for domain: {}", self.domain);
Ok(ExportState::new(&self.domain))
} else {
Err(ScraperError::Io(io_err))
}
}
Err(e) => {
Err(e)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_state_store_creation() {
let store = StateStore::new("example.com");
assert_eq!(store.domain, "example.com");
assert!(store.get_state_path().ends_with("example.com.json"));
}
#[test]
fn test_state_path_generation() {
let store = StateStore::new("test.domain");
let path = store.get_state_path();
let path_str = path.to_string_lossy();
assert!(path_str.contains("rust-scraper/state/test.domain.json"));
}
#[test]
fn test_load_nonexistent_state() {
let store = StateStore::new("nonexistent");
let result = store.load();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn test_save_and_load_state() {
let dir = tempdir().unwrap();
let mut cache_dir = dir.path().to_path_buf();
cache_dir.push("rust-scraper/state");
let mut store = StateStore::new("test.com");
store.cache_dir = cache_dir.clone();
let mut state = ExportState::new("test.com");
state.mark_processed("https://test.com/page1");
state.mark_processed("https://test.com/page2");
let save_result = store.save(&state);
assert!(save_result.is_ok());
let loaded_state = store.load();
assert!(loaded_state.is_ok());
let loaded_state = loaded_state.unwrap();
assert_eq!(loaded_state.domain, "test.com");
assert_eq!(loaded_state.processed_urls.len(), 2);
assert!(loaded_state.is_processed("https://test.com/page1"));
assert!(loaded_state.is_processed("https://test.com/page2"));
}
#[test]
fn test_mark_processed() {
let store = StateStore::new("test.com");
let mut state = ExportState::new("test.com");
assert!(!store.is_processed(&state, "https://test.com/page1"));
store.mark_processed(&mut state, "https://test.com/page1");
assert!(store.is_processed(&state, "https://test.com/page1"));
store.mark_processed(&mut state, "https://test.com/page1");
assert_eq!(state.processed_urls.len(), 1);
}
#[test]
fn test_load_or_default_existing() {
let dir = tempdir().unwrap();
let mut cache_dir = dir.path().to_path_buf();
cache_dir.push("rust-scraper/state");
fs::create_dir_all(&cache_dir).unwrap();
let state_path = cache_dir.join("existing.com.json");
let mut file = File::create(&state_path).unwrap();
writeln!(
file,
r#"{{
"domain": "existing.com",
"processed_urls": ["https://existing.com/page1"],
"last_export": null,
"total_exported": 1
}}"#
)
.unwrap();
let mut store = StateStore::new("existing.com");
store.cache_dir = cache_dir;
let state = store.load_or_default().unwrap();
assert_eq!(state.domain, "existing.com");
assert_eq!(state.processed_urls.len(), 1);
}
#[test]
fn test_load_or_default_new() {
let dir = tempdir().unwrap();
let cache_dir = dir.path().to_path_buf();
let mut store = StateStore::new("new.com");
store.cache_dir = cache_dir;
let state = store.load_or_default().unwrap();
assert_eq!(state.domain, "new.com");
assert_eq!(state.processed_urls.len(), 0);
}
#[test]
fn test_atomic_save() {
let dir = tempdir().unwrap();
let mut cache_dir = dir.path().to_path_buf();
cache_dir.push("rust-scraper/state");
let mut store = StateStore::new("atomic.com");
store.cache_dir = cache_dir.clone();
let state = ExportState::new("atomic.com");
let result = store.save(&state);
assert!(result.is_ok());
let final_path = store.get_state_path();
assert!(final_path.exists());
let mut temp_path = final_path.clone();
temp_path.set_extension("tmp");
assert!(!temp_path.exists());
}
}