//! Recently opened projects for the start screen (Tim t998u.1). //! //! Stored per user as `recent.json` in the user config directory; see //! [`config_dir`]. Never fatal: an unreadable list is treated as empty. use std::fs; use std::path::{Path, PathBuf}; /// Maximum number of remembered projects. pub const MAX_RECENT: usize = 5; pub const FILE_NAME: &str = "recent.json"; const MAX_FILE_BYTES: u64 = 64 * 1024; /// `PAPERDOLL_CONFIG_DIR`, else `%APPDATA%\paperdoll-renderer` (Windows) or /// `$XDG_CONFIG_HOME/paperdoll-renderer` (default `~/.config/paperdoll-renderer`). pub fn config_dir() -> Option { if let Some(dir) = std::env::var_os("PAPERDOLL_CONFIG_DIR").filter(|v| !v.is_empty()) { return Some(PathBuf::from(dir)); } let base = if cfg!(windows) { std::env::var_os("APPDATA").map(PathBuf::from) } else { std::env::var_os("XDG_CONFIG_HOME") .filter(|v| !v.is_empty()) .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) }?; Some(base.join("paperdoll-renderer")) } /// Remembered `character.ppd` paths, newest first (at most [`MAX_RECENT`]). pub fn load(dir: &Path) -> Vec { let path = dir.join(FILE_NAME); let Ok(meta) = fs::metadata(&path) else { return Vec::new(); }; if !meta.is_file() || meta.len() > MAX_FILE_BYTES { return Vec::new(); } let Ok(bytes) = fs::read(&path) else { return Vec::new(); }; let Ok(doc) = serde_json::from_slice::(&bytes) else { return Vec::new(); }; let mut out: Vec = Vec::new(); for item in doc .get("projects") .and_then(|v| v.as_array()) .into_iter() .flatten() { if let Some(text) = item.as_str() { let p = PathBuf::from(text); if p.is_absolute() && !out.contains(&p) { out.push(p); } } if out.len() == MAX_RECENT { break; } } out } /// Put `manifest` first, dedupe, cap at [`MAX_RECENT`] and write atomically. pub fn record(dir: &Path, manifest: &Path) -> Result, String> { let manifest = manifest .canonicalize() .unwrap_or_else(|_| manifest.to_path_buf()); let mut list = load(dir); list.retain(|p| *p != manifest); list.insert(0, manifest); list.truncate(MAX_RECENT); fs::create_dir_all(dir).map_err(|e| e.to_string())?; let doc = serde_json::json!({ "projects": list.iter().map(|p| p.to_string_lossy().to_string()).collect::>(), }); let bytes = serde_json::to_vec_pretty(&doc).map_err(|e| e.to_string())?; let temp = dir.join(format!("{FILE_NAME}.tmp-{}", std::process::id())); fs::write(&temp, bytes).map_err(|e| e.to_string())?; fs::rename(&temp, dir.join(FILE_NAME)).map_err(|e| { let _ = fs::remove_file(&temp); e.to_string() })?; Ok(list) } /// Display label: the project folder name. pub fn label(manifest: &Path) -> String { manifest .parent() .and_then(|p| p.file_name()) .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| manifest.display().to_string()) } #[cfg(test)] mod tests { use super::*; fn temp(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "paperdoll-recent-{tag}-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); fs::create_dir_all(&dir).unwrap(); dir } #[test] fn records_newest_first_dedupes_caps_and_survives_damage() { let dir = temp("list"); assert!(load(&dir).is_empty()); let projects: Vec = (0..7) .map(|i| { let p = dir.join(format!("p{i}")); fs::create_dir_all(&p).unwrap(); let m = p.join("character.ppd"); fs::write(&m, b"{}").unwrap(); m.canonicalize().unwrap() }) .collect(); for p in &projects { record(&dir, p).unwrap(); } record(&dir, &projects[3]).unwrap(); let list = load(&dir); assert_eq!( list, vec![ projects[3].clone(), projects[6].clone(), projects[5].clone(), projects[4].clone(), projects[2].clone() ] ); assert_eq!(label(&list[0]), "p3"); // A missing project stays listed (shown greyed by the UI). fs::remove_dir_all(projects[6].parent().unwrap()).unwrap(); assert!(load(&dir).contains(&projects[6])); // Damaged file: empty, and the next record repairs it. fs::write(dir.join(FILE_NAME), b"not json").unwrap(); assert!(load(&dir).is_empty()); record(&dir, &projects[0]).unwrap(); assert_eq!(load(&dir), vec![projects[0].clone()]); let _ = fs::remove_dir_all(dir); } }