use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
time::{Duration, SystemTime},
};
use crate::ui::ViewMode;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentState {
pub scroll_offset: usize,
pub last_search: String,
#[serde(skip)]
pub view_mode: ViewMode,
#[serde(default = "SystemTime::now")]
pub last_accessed: SystemTime,
}
impl Default for DocumentState {
fn default() -> Self {
Self {
scroll_offset: 0,
last_search: String::new(),
view_mode: ViewMode::Document,
last_accessed: SystemTime::now(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StateManager {
documents: HashMap<String, DocumentState>,
}
impl StateManager {
pub fn new() -> Self {
Self {
documents: HashMap::new(),
}
}
pub fn load() -> Result<Self> {
let state_path = Self::state_file_path()?;
if !state_path.exists() {
return Ok(Self::new());
}
let contents = fs::read_to_string(&state_path).context("Failed to read state file")?;
let mut manager: StateManager =
serde_json::from_str(&contents).context("Failed to parse state file")?;
manager.cleanup_old_entries(Duration::from_secs(90 * 24 * 60 * 60));
Ok(manager)
}
pub fn save(&self) -> Result<()> {
let state_path = Self::state_file_path()?;
if let Some(parent) = state_path.parent() {
fs::create_dir_all(parent).context("Failed to create state directory")?;
}
let contents = serde_json::to_string_pretty(self).context("Failed to serialize state")?;
fs::write(&state_path, contents).context("Failed to write state file")?;
Ok(())
}
pub fn get_state(&self, file_path: &Path) -> Option<DocumentState> {
let key = file_path.to_string_lossy().to_string();
self.documents.get(&key).cloned()
}
pub fn set_state(&mut self, file_path: &Path, state: DocumentState) {
let key = file_path.to_string_lossy().to_string();
self.documents.insert(key, state);
}
fn cleanup_old_entries(&mut self, max_age: Duration) {
let now = SystemTime::now();
self.documents.retain(|_, state| {
now.duration_since(state.last_accessed)
.map(|age| age < max_age)
.unwrap_or(false)
});
}
fn state_file_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir().context("Failed to determine config directory")?;
Ok(config_dir.join("doxx").join("state.json"))
}
}
impl Default for StateManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_state_manager_new() {
let manager = StateManager::new();
assert_eq!(manager.documents.len(), 0);
}
#[test]
fn test_set_and_get_state() {
let mut manager = StateManager::new();
let path = PathBuf::from("/test/document.docx");
let state = DocumentState {
scroll_offset: 42,
last_search: "test".to_string(),
view_mode: ViewMode::Search,
last_accessed: SystemTime::now(),
};
manager.set_state(&path, state.clone());
let retrieved = manager.get_state(&path).unwrap();
assert_eq!(retrieved.scroll_offset, 42);
assert_eq!(retrieved.last_search, "test");
}
#[test]
fn test_cleanup_old_entries() {
let mut manager = StateManager::new();
let path = PathBuf::from("/test/old.docx");
let old_time = SystemTime::now() - Duration::from_secs(100 * 24 * 60 * 60); let state = DocumentState {
scroll_offset: 0,
last_search: String::new(),
view_mode: ViewMode::Document,
last_accessed: old_time,
};
manager.set_state(&path, state);
assert_eq!(manager.documents.len(), 1);
manager.cleanup_old_entries(Duration::from_secs(90 * 24 * 60 * 60));
assert_eq!(manager.documents.len(), 0);
}
#[test]
fn test_state_file_path_returns_path() {
let path = StateManager::state_file_path();
assert!(path.is_ok());
let path = path.unwrap();
assert!(path.ends_with("doxx/state.json") || path.ends_with("doxx\\state.json"));
}
}