BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / recent.rs

recent.rs

recent.rs 5.1 KiB Download Raw
1//! Recently opened projects for the start screen (Tim t998u.1).
2//!
3//! Stored per user as `recent.json` in the user config directory; see
4//! [`config_dir`]. Never fatal: an unreadable list is treated as empty.
5use std::fs;
6use std::path::{Path, PathBuf};
7
8/// Maximum number of remembered projects.
9pub const MAX_RECENT: usize = 5;
10pub const FILE_NAME: &str = "recent.json";
11const MAX_FILE_BYTES: u64 = 64 * 1024;
12
13/// `PAPERDOLL_CONFIG_DIR`, else `%APPDATA%\paperdoll-renderer` (Windows) or
14/// `$XDG_CONFIG_HOME/paperdoll-renderer` (default `~/.config/paperdoll-renderer`).
15pub fn config_dir() -> Option<PathBuf> {
16 if let Some(dir) = std::env::var_os("PAPERDOLL_CONFIG_DIR").filter(|v| !v.is_empty()) {
17 return Some(PathBuf::from(dir));
18 }
19 let base = if cfg!(windows) {
20 std::env::var_os("APPDATA").map(PathBuf::from)
21 } else {
22 std::env::var_os("XDG_CONFIG_HOME")
23 .filter(|v| !v.is_empty())
24 .map(PathBuf::from)
25 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
26 }?;
27 Some(base.join("paperdoll-renderer"))
28}
29
30/// Remembered `character.ppd` paths, newest first (at most [`MAX_RECENT`]).
31pub fn load(dir: &Path) -> Vec<PathBuf> {
32 let path = dir.join(FILE_NAME);
33 let Ok(meta) = fs::metadata(&path) else {
34 return Vec::new();
35 };
36 if !meta.is_file() || meta.len() > MAX_FILE_BYTES {
37 return Vec::new();
38 }
39 let Ok(bytes) = fs::read(&path) else {
40 return Vec::new();
41 };
42 let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
43 return Vec::new();
44 };
45 let mut out: Vec<PathBuf> = Vec::new();
46 for item in doc
47 .get("projects")
48 .and_then(|v| v.as_array())
49 .into_iter()
50 .flatten()
51 {
52 if let Some(text) = item.as_str() {
53 let p = PathBuf::from(text);
54 if p.is_absolute() && !out.contains(&p) {
55 out.push(p);
56 }
57 }
58 if out.len() == MAX_RECENT {
59 break;
60 }
61 }
62 out
63}
64
65/// Put `manifest` first, dedupe, cap at [`MAX_RECENT`] and write atomically.
66pub fn record(dir: &Path, manifest: &Path) -> Result<Vec<PathBuf>, String> {
67 let manifest = manifest
68 .canonicalize()
69 .unwrap_or_else(|_| manifest.to_path_buf());
70 let mut list = load(dir);
71 list.retain(|p| *p != manifest);
72 list.insert(0, manifest);
73 list.truncate(MAX_RECENT);
74 fs::create_dir_all(dir).map_err(|e| e.to_string())?;
75 let doc = serde_json::json!({
76 "projects": list.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>(),
77 });
78 let bytes = serde_json::to_vec_pretty(&doc).map_err(|e| e.to_string())?;
79 let temp = dir.join(format!("{FILE_NAME}.tmp-{}", std::process::id()));
80 fs::write(&temp, bytes).map_err(|e| e.to_string())?;
81 fs::rename(&temp, dir.join(FILE_NAME)).map_err(|e| {
82 let _ = fs::remove_file(&temp);
83 e.to_string()
84 })?;
85 Ok(list)
86}
87
88/// Display label: the project folder name.
89pub fn label(manifest: &Path) -> String {
90 manifest
91 .parent()
92 .and_then(|p| p.file_name())
93 .map(|n| n.to_string_lossy().to_string())
94 .unwrap_or_else(|| manifest.display().to_string())
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 fn temp(tag: &str) -> PathBuf {
102 let dir = std::env::temp_dir().join(format!(
103 "paperdoll-recent-{tag}-{}-{}",
104 std::process::id(),
105 std::time::SystemTime::now()
106 .duration_since(std::time::UNIX_EPOCH)
107 .unwrap()
108 .as_nanos()
109 ));
110 fs::create_dir_all(&dir).unwrap();
111 dir
112 }
113
114 #[test]
115 fn records_newest_first_dedupes_caps_and_survives_damage() {
116 let dir = temp("list");
117 assert!(load(&dir).is_empty());
118 let projects: Vec<PathBuf> = (0..7)
119 .map(|i| {
120 let p = dir.join(format!("p{i}"));
121 fs::create_dir_all(&p).unwrap();
122 let m = p.join("character.ppd");
123 fs::write(&m, b"{}").unwrap();
124 m.canonicalize().unwrap()
125 })
126 .collect();
127 for p in &projects {
128 record(&dir, p).unwrap();
129 }
130 record(&dir, &projects[3]).unwrap();
131 let list = load(&dir);
132 assert_eq!(
133 list,
134 vec![
135 projects[3].clone(),
136 projects[6].clone(),
137 projects[5].clone(),
138 projects[4].clone(),
139 projects[2].clone()
140 ]
141 );
142 assert_eq!(label(&list[0]), "p3");
143 // A missing project stays listed (shown greyed by the UI).
144 fs::remove_dir_all(projects[6].parent().unwrap()).unwrap();
145 assert!(load(&dir).contains(&projects[6]));
146 // Damaged file: empty, and the next record repairs it.
147 fs::write(dir.join(FILE_NAME), b"not json").unwrap();
148 assert!(load(&dir).is_empty());
149 record(&dir, &projects[0]).unwrap();
150 assert_eq!(load(&dir), vec![projects[0].clone()]);
151 let _ = fs::remove_dir_all(dir);
152 }
153}