diff --git a/pixstrip-core/Cargo.toml b/pixstrip-core/Cargo.toml index 2d09295..6afddc3 100644 --- a/pixstrip-core/Cargo.toml +++ b/pixstrip-core/Cargo.toml @@ -19,6 +19,7 @@ webp = "0.3" little_exif = "0.4" imageproc = "0.25" ab_glyph = "0.2" +dirs = "6" [dev-dependencies] tempfile = "3" diff --git a/pixstrip-core/src/lib.rs b/pixstrip-core/src/lib.rs index 01259d6..e1e9952 100644 --- a/pixstrip-core/src/lib.rs +++ b/pixstrip-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod loader; pub mod operations; pub mod pipeline; pub mod preset; +pub mod storage; pub mod types; pub fn version() -> &'static str { diff --git a/pixstrip-core/src/storage.rs b/pixstrip-core/src/storage.rs new file mode 100644 index 0000000..31546cb --- /dev/null +++ b/pixstrip-core/src/storage.rs @@ -0,0 +1,287 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::config::AppConfig; +use crate::error::{PixstripError, Result}; +use crate::preset::Preset; + +fn default_config_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("~/.config")) + .join("pixstrip") +} + +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|c| match c { + '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*' => '_', + _ => c, + }) + .collect() +} + +// --- Preset Store --- + +pub struct PresetStore { + presets_dir: PathBuf, +} + +impl PresetStore { + pub fn new() -> Self { + Self { + presets_dir: default_config_dir().join("presets"), + } + } + + pub fn with_base_dir(base: &Path) -> Self { + Self { + presets_dir: base.join("presets"), + } + } + + fn ensure_dir(&self) -> Result<()> { + std::fs::create_dir_all(&self.presets_dir).map_err(PixstripError::Io) + } + + fn preset_path(&self, name: &str) -> PathBuf { + let filename = format!("{}.json", sanitize_filename(name)); + self.presets_dir.join(filename) + } + + pub fn save(&self, preset: &Preset) -> Result<()> { + self.ensure_dir()?; + let path = self.preset_path(&preset.name); + let json = serde_json::to_string_pretty(preset) + .map_err(|e| PixstripError::Preset(e.to_string()))?; + std::fs::write(&path, json).map_err(PixstripError::Io) + } + + pub fn load(&self, name: &str) -> Result { + let path = self.preset_path(name); + if !path.exists() { + return Err(PixstripError::Preset(format!( + "Preset '{}' not found", + name + ))); + } + let data = std::fs::read_to_string(&path).map_err(PixstripError::Io)?; + serde_json::from_str(&data).map_err(|e| PixstripError::Preset(e.to_string())) + } + + pub fn list(&self) -> Result> { + if !self.presets_dir.exists() { + return Ok(Vec::new()); + } + + let mut presets = Vec::new(); + for entry in std::fs::read_dir(&self.presets_dir).map_err(PixstripError::Io)? { + let entry = entry.map_err(PixstripError::Io)?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + let data = std::fs::read_to_string(&path).map_err(PixstripError::Io)?; + if let Ok(preset) = serde_json::from_str::(&data) { + presets.push(preset); + } + } + } + presets.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(presets) + } + + pub fn delete(&self, name: &str) -> Result<()> { + let path = self.preset_path(name); + if !path.exists() { + return Err(PixstripError::Preset(format!( + "Preset '{}' not found", + name + ))); + } + std::fs::remove_file(&path).map_err(PixstripError::Io) + } + + pub fn export_to_file(&self, preset: &Preset, path: &Path) -> Result<()> { + let json = serde_json::to_string_pretty(preset) + .map_err(|e| PixstripError::Preset(e.to_string()))?; + std::fs::write(path, json).map_err(PixstripError::Io) + } + + pub fn import_from_file(&self, path: &Path) -> Result { + let data = std::fs::read_to_string(path).map_err(PixstripError::Io)?; + let mut preset: Preset = + serde_json::from_str(&data).map_err(|e| PixstripError::Preset(e.to_string()))?; + preset.is_custom = true; + Ok(preset) + } +} + +impl Default for PresetStore { + fn default() -> Self { + Self::new() + } +} + +// --- Config Store --- + +pub struct ConfigStore { + config_path: PathBuf, +} + +impl ConfigStore { + pub fn new() -> Self { + Self { + config_path: default_config_dir().join("config.json"), + } + } + + pub fn with_base_dir(base: &Path) -> Self { + Self { + config_path: base.join("config.json"), + } + } + + pub fn save(&self, config: &AppConfig) -> Result<()> { + if let Some(parent) = self.config_path.parent() { + std::fs::create_dir_all(parent).map_err(PixstripError::Io)?; + } + let json = serde_json::to_string_pretty(config) + .map_err(|e| PixstripError::Config(e.to_string()))?; + std::fs::write(&self.config_path, json).map_err(PixstripError::Io) + } + + pub fn load(&self) -> Result { + if !self.config_path.exists() { + return Ok(AppConfig::default()); + } + let data = std::fs::read_to_string(&self.config_path).map_err(PixstripError::Io)?; + serde_json::from_str(&data).map_err(|e| PixstripError::Config(e.to_string())) + } +} + +impl Default for ConfigStore { + fn default() -> Self { + Self::new() + } +} + +// --- Session Store --- + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SessionState { + pub last_input_dir: Option, + pub last_output_dir: Option, + pub last_preset_name: Option, + pub current_step: u32, +} + +pub struct SessionStore { + session_path: PathBuf, +} + +impl SessionStore { + pub fn new() -> Self { + Self { + session_path: default_config_dir().join("session.json"), + } + } + + pub fn with_base_dir(base: &Path) -> Self { + Self { + session_path: base.join("session.json"), + } + } + + pub fn save(&self, state: &SessionState) -> Result<()> { + if let Some(parent) = self.session_path.parent() { + std::fs::create_dir_all(parent).map_err(PixstripError::Io)?; + } + let json = serde_json::to_string_pretty(state) + .map_err(|e| PixstripError::Config(e.to_string()))?; + std::fs::write(&self.session_path, json).map_err(PixstripError::Io) + } + + pub fn load(&self) -> Result { + if !self.session_path.exists() { + return Ok(SessionState::default()); + } + let data = std::fs::read_to_string(&self.session_path).map_err(PixstripError::Io)?; + serde_json::from_str(&data).map_err(|e| PixstripError::Config(e.to_string())) + } +} + +impl Default for SessionStore { + fn default() -> Self { + Self::new() + } +} + +// --- History Store --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoryEntry { + pub timestamp: String, + pub input_dir: String, + pub output_dir: String, + pub preset_name: Option, + pub total: usize, + pub succeeded: usize, + pub failed: usize, + pub total_input_bytes: u64, + pub total_output_bytes: u64, + pub elapsed_ms: u64, + pub output_files: Vec, +} + +pub struct HistoryStore { + history_path: PathBuf, +} + +impl HistoryStore { + pub fn new() -> Self { + Self { + history_path: default_config_dir().join("history.json"), + } + } + + pub fn with_base_dir(base: &Path) -> Self { + Self { + history_path: base.join("history.json"), + } + } + + pub fn add(&self, entry: HistoryEntry) -> Result<()> { + let mut entries = self.list()?; + entries.push(entry); + self.write_all(&entries) + } + + pub fn list(&self) -> Result> { + if !self.history_path.exists() { + return Ok(Vec::new()); + } + let data = std::fs::read_to_string(&self.history_path).map_err(PixstripError::Io)?; + if data.trim().is_empty() { + return Ok(Vec::new()); + } + serde_json::from_str(&data).map_err(|e| PixstripError::Config(e.to_string())) + } + + pub fn clear(&self) -> Result<()> { + self.write_all(&Vec::::new()) + } + + fn write_all(&self, entries: &[HistoryEntry]) -> Result<()> { + if let Some(parent) = self.history_path.parent() { + std::fs::create_dir_all(parent).map_err(PixstripError::Io)?; + } + let json = serde_json::to_string_pretty(entries) + .map_err(|e| PixstripError::Config(e.to_string()))?; + std::fs::write(&self.history_path, json).map_err(PixstripError::Io) + } +} + +impl Default for HistoryStore { + fn default() -> Self { + Self::new() + } +} diff --git a/pixstrip-core/tests/config_tests.rs b/pixstrip-core/tests/config_tests.rs index ea3c207..2364d56 100644 --- a/pixstrip-core/tests/config_tests.rs +++ b/pixstrip-core/tests/config_tests.rs @@ -22,6 +22,6 @@ fn config_serialization_roundtrip() { #[test] fn skill_level_toggle() { - assert_eq!(SkillLevel::Simple.is_advanced(), false); - assert_eq!(SkillLevel::Detailed.is_advanced(), true); + assert!(!SkillLevel::Simple.is_advanced()); + assert!(SkillLevel::Detailed.is_advanced()); } diff --git a/pixstrip-core/tests/storage_tests.rs b/pixstrip-core/tests/storage_tests.rs new file mode 100644 index 0000000..7bdaf05 --- /dev/null +++ b/pixstrip-core/tests/storage_tests.rs @@ -0,0 +1,278 @@ +use pixstrip_core::preset::Preset; +use pixstrip_core::storage::*; + +fn with_temp_config_dir(f: impl FnOnce(&std::path::Path)) { + let dir = tempfile::tempdir().unwrap(); + f(dir.path()); +} + +// --- Preset file I/O --- + +#[test] +fn save_and_load_preset() { + with_temp_config_dir(|base| { + let store = PresetStore::with_base_dir(base); + let preset = Preset::builtin_blog_photos(); + + store.save(&preset).unwrap(); + let loaded = store.load("Blog Photos").unwrap(); + + assert_eq!(loaded.name, preset.name); + assert_eq!(loaded.is_custom, preset.is_custom); + }); +} + +#[test] +fn save_preset_creates_directory() { + with_temp_config_dir(|base| { + let presets_dir = base.join("presets"); + assert!(!presets_dir.exists()); + + let store = PresetStore::with_base_dir(base); + let preset = Preset::builtin_blog_photos(); + store.save(&preset).unwrap(); + + assert!(presets_dir.exists()); + }); +} + +#[test] +fn list_saved_presets() { + with_temp_config_dir(|base| { + let store = PresetStore::with_base_dir(base); + store.save(&Preset::builtin_blog_photos()).unwrap(); + store.save(&Preset::builtin_privacy_clean()).unwrap(); + + let list = store.list().unwrap(); + assert_eq!(list.len(), 2); + + let names: Vec<&str> = list.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"Blog Photos")); + assert!(names.contains(&"Privacy Clean")); + }); +} + +#[test] +fn delete_preset() { + with_temp_config_dir(|base| { + let store = PresetStore::with_base_dir(base); + store.save(&Preset::builtin_blog_photos()).unwrap(); + + assert!(store.delete("Blog Photos").is_ok()); + assert!(store.load("Blog Photos").is_err()); + }); +} + +#[test] +fn load_nonexistent_preset_returns_error() { + with_temp_config_dir(|base| { + let store = PresetStore::with_base_dir(base); + assert!(store.load("Nonexistent").is_err()); + }); +} + +#[test] +fn preset_filename_sanitization() { + with_temp_config_dir(|base| { + let store = PresetStore::with_base_dir(base); + let mut preset = Preset::builtin_blog_photos(); + preset.name = "My Preset / With Chars!".into(); + preset.is_custom = true; + + store.save(&preset).unwrap(); + let loaded = store.load("My Preset / With Chars!").unwrap(); + assert_eq!(loaded.name, preset.name); + }); +} + +// --- Preset import/export --- + +#[test] +fn export_and_import_preset() { + with_temp_config_dir(|base| { + let store = PresetStore::with_base_dir(base); + let preset = Preset::builtin_blog_photos(); + + let export_path = base.join("exported.pixstrip-preset"); + store.export_to_file(&preset, &export_path).unwrap(); + + assert!(export_path.exists()); + + let imported = store.import_from_file(&export_path).unwrap(); + assert_eq!(imported.name, preset.name); + assert!(imported.is_custom); + }); +} + +#[test] +fn import_preset_marks_as_custom() { + with_temp_config_dir(|base| { + let store = PresetStore::with_base_dir(base); + let preset = Preset::builtin_blog_photos(); + + let export_path = base.join("test.pixstrip-preset"); + store.export_to_file(&preset, &export_path).unwrap(); + + let imported = store.import_from_file(&export_path).unwrap(); + assert!(imported.is_custom); + }); +} + +// --- Config persistence --- + +#[test] +fn save_and_load_config() { + with_temp_config_dir(|base| { + let config_store = ConfigStore::with_base_dir(base); + let config = pixstrip_core::config::AppConfig { + output_subfolder: "output_images".into(), + auto_open_output: true, + ..Default::default() + }; + + config_store.save(&config).unwrap(); + let loaded = config_store.load().unwrap(); + + assert_eq!(loaded.output_subfolder, "output_images"); + assert!(loaded.auto_open_output); + }); +} + +#[test] +fn load_missing_config_returns_default() { + with_temp_config_dir(|base| { + let config_store = ConfigStore::with_base_dir(base); + let config = config_store.load().unwrap(); + let default = pixstrip_core::config::AppConfig::default(); + + assert_eq!(config.output_subfolder, default.output_subfolder); + }); +} + +// --- Session memory --- + +#[test] +fn save_and_load_session() { + with_temp_config_dir(|base| { + let session_store = SessionStore::with_base_dir(base); + let session = SessionState { + last_input_dir: Some("/home/user/photos".into()), + last_output_dir: Some("/home/user/processed".into()), + last_preset_name: Some("Blog Photos".into()), + current_step: 3, + }; + + session_store.save(&session).unwrap(); + let loaded = session_store.load().unwrap(); + + assert_eq!(loaded.last_input_dir.as_deref(), Some("/home/user/photos")); + assert_eq!(loaded.current_step, 3); + }); +} + +#[test] +fn load_missing_session_returns_default() { + with_temp_config_dir(|base| { + let session_store = SessionStore::with_base_dir(base); + let session = session_store.load().unwrap(); + assert_eq!(session.current_step, 0); + assert!(session.last_input_dir.is_none()); + }); +} + +// --- Processing history --- + +#[test] +fn add_and_list_history_entries() { + with_temp_config_dir(|base| { + let history = HistoryStore::with_base_dir(base); + + let entry = HistoryEntry { + timestamp: "2026-03-06T12:00:00Z".into(), + input_dir: "/home/user/photos".into(), + output_dir: "/home/user/processed".into(), + preset_name: Some("Blog Photos".into()), + total: 10, + succeeded: 9, + failed: 1, + total_input_bytes: 50_000_000, + total_output_bytes: 10_000_000, + elapsed_ms: 5000, + output_files: vec![ + "/home/user/processed/photo1.jpg".into(), + "/home/user/processed/photo2.jpg".into(), + ], + }; + + history.add(entry.clone()).unwrap(); + let entries = history.list().unwrap(); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].total, 10); + assert_eq!(entries[0].succeeded, 9); + }); +} + +#[test] +fn history_appends_entries() { + with_temp_config_dir(|base| { + let history = HistoryStore::with_base_dir(base); + + for i in 0..3 { + history + .add(HistoryEntry { + timestamp: format!("2026-03-06T12:0{}:00Z", i), + input_dir: "/input".into(), + output_dir: "/output".into(), + preset_name: None, + total: i + 1, + succeeded: i + 1, + failed: 0, + total_input_bytes: 1000, + total_output_bytes: 500, + elapsed_ms: 100, + output_files: vec![], + }) + .unwrap(); + } + + let entries = history.list().unwrap(); + assert_eq!(entries.len(), 3); + }); +} + +#[test] +fn clear_history() { + with_temp_config_dir(|base| { + let history = HistoryStore::with_base_dir(base); + + history + .add(HistoryEntry { + timestamp: "2026-03-06T12:00:00Z".into(), + input_dir: "/input".into(), + output_dir: "/output".into(), + preset_name: None, + total: 1, + succeeded: 1, + failed: 0, + total_input_bytes: 1000, + total_output_bytes: 500, + elapsed_ms: 100, + output_files: vec![], + }) + .unwrap(); + + history.clear().unwrap(); + let entries = history.list().unwrap(); + assert!(entries.is_empty()); + }); +} + +#[test] +fn empty_history_returns_empty_list() { + with_temp_config_dir(|base| { + let history = HistoryStore::with_base_dir(base); + let entries = history.list().unwrap(); + assert!(entries.is_empty()); + }); +}