1use crate::{PinCollection, ShellError, ShellResult};
2use serde::Serialize;
3use serde::de::DeserializeOwned;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7pub const PIN_STORE_FILE: &str = "shell-pins-v1.json";
8
9#[derive(Debug, Clone)]
10pub struct ShellStore {
11 root: PathBuf,
12}
13
14impl ShellStore {
15 pub fn new(root: impl Into<PathBuf>) -> Self {
16 Self { root: root.into() }
17 }
18
19 pub fn load_pins(&self) -> ShellResult<PinCollection> {
20 let Some(value) = self.load_optional::<PinCollection>(PIN_STORE_FILE)? else {
21 return Ok(PinCollection::default());
22 };
23 value.restore()
24 }
25
26 pub(crate) fn load_pins_recovering(&self) -> PinCollection {
27 match self.load_pins() {
28 Ok(pins) => pins,
29 Err(_) => {
30 let _ = self.quarantine(PIN_STORE_FILE);
31 PinCollection::default()
32 }
33 }
34 }
35
36 pub fn save_pins(&self, pins: &PinCollection) -> ShellResult<()> {
37 self.save(PIN_STORE_FILE, pins)
38 }
39
40 fn load_optional<T: DeserializeOwned>(&self, name: &str) -> ShellResult<Option<T>> {
41 let path = self.root.join(name);
42 #[cfg(windows)]
43 restore_backup_if_needed(&path)?;
44 if !path.is_file() {
45 return Ok(None);
46 }
47 let raw = fs::read_to_string(&path)
48 .map_err(|error| ShellError::Io(format!("read {}: {error}", path.display())))?;
49 serde_json::from_str(&raw)
50 .map(Some)
51 .map_err(|error| ShellError::InvalidState(format!("{}: {error}", path.display())))
52 }
53
54 fn quarantine(&self, name: &str) -> ShellResult<Option<PathBuf>> {
55 let path = self.root.join(name);
56 if !path.exists() {
57 return Ok(None);
58 }
59 for index in 0..=u16::MAX {
60 let suffix = if index == 0 {
61 "invalid".to_string()
62 } else {
63 format!("invalid.{index}")
64 };
65 let target = self.root.join(format!("{name}.{suffix}"));
66 if !target.exists() {
67 fs::rename(&path, &target).map_err(|error| {
68 ShellError::Io(format!(
69 "quarantine {} as {}: {error}",
70 path.display(),
71 target.display()
72 ))
73 })?;
74 return Ok(Some(target));
75 }
76 }
77 Err(ShellError::Io(format!(
78 "no quarantine filename available for {}",
79 path.display()
80 )))
81 }
82
83 fn save<T: Serialize>(&self, name: &str, value: &T) -> ShellResult<()> {
84 fs::create_dir_all(&self.root)
85 .map_err(|error| ShellError::Io(format!("create {}: {error}", self.root.display())))?;
86 let path = self.root.join(name);
87 let tmp = self.root.join(format!("{name}.tmp"));
88 let raw = serde_json::to_vec_pretty(value)?;
89 fs::write(&tmp, raw)
90 .map_err(|error| ShellError::Io(format!("write {}: {error}", tmp.display())))?;
91 replace_file(&tmp, &path)
92 }
93}
94
95#[cfg(not(windows))]
96fn replace_file(tmp: &Path, path: &Path) -> ShellResult<()> {
97 fs::rename(tmp, path)
98 .map_err(|error| ShellError::Io(format!("replace {}: {error}", path.display())))
99}
100
101#[cfg(windows)]
102fn restore_backup_if_needed(path: &Path) -> ShellResult<()> {
103 if path.exists() {
104 return Ok(());
105 }
106 let backup = path.with_extension("json.bak");
107 if backup.exists() {
108 fs::rename(&backup, path)
109 .map_err(|error| ShellError::Io(format!("restore {}: {error}", path.display())))?;
110 }
111 Ok(())
112}
113
114#[cfg(windows)]
115fn replace_file(tmp: &Path, path: &Path) -> ShellResult<()> {
116 let backup = path.with_extension("json.bak");
117 if backup.exists() {
118 fs::remove_file(&backup)
119 .map_err(|error| ShellError::Io(format!("remove {}: {error}", backup.display())))?;
120 }
121 let had_previous = path.exists();
122 if had_previous {
123 fs::rename(path, &backup)
124 .map_err(|error| ShellError::Io(format!("backup {}: {error}", path.display())))?;
125 }
126 if let Err(error) = fs::rename(tmp, path) {
127 if had_previous {
128 let _ = fs::rename(&backup, path);
129 }
130 return Err(ShellError::Io(format!(
131 "replace {}: {error}",
132 path.display()
133 )));
134 }
135 if had_previous {
136 let _ = fs::remove_file(backup);
137 }
138 Ok(())
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::ShellPinTarget;
145
146 #[test]
147 fn stores_mixed_pins() {
148 let dir = tempfile::tempdir().unwrap();
149 let store = ShellStore::new(dir.path());
150 let mut pins = PinCollection::default();
151 pins.pin(ShellPinTarget::Lxapp {
152 key: "app.chat".to_string(),
153 })
154 .unwrap();
155 pins.pin(ShellPinTarget::Bookmark {
156 key: "bookmark-a".to_string(),
157 })
158 .unwrap();
159 store.save_pins(&pins).unwrap();
160
161 let restored_pins = store.load_pins().unwrap();
162 assert_eq!(restored_pins, pins);
163 }
164
165 #[test]
166 fn invalid_pin_store_is_quarantined_without_blocking_manager_open() {
167 let overflow = serde_json::json!({
168 "version": 1,
169 "items": (0..=crate::MAX_SHELL_PINS)
170 .map(|index| serde_json::json!({ "kind": "lxapp", "key": format!("app.{index}") }))
171 .collect::<Vec<_>>()
172 })
173 .to_string();
174 for invalid in [
175 "{".to_string(),
176 r#"{"version":2,"items":[]}"#.to_string(),
177 r#"{"version":1,"items":[{"kind":"lxapp","key":"chat"},{"kind":"lxapp","key":"chat"}]}"#.to_string(),
178 overflow,
179 ] {
180 let dir = tempfile::tempdir().unwrap();
181 let path = dir.path().join(PIN_STORE_FILE);
182 fs::write(&path, invalid).unwrap();
183
184 let manager = crate::ShellManager::open(dir.path()).unwrap();
185
186 assert!(manager.snapshot().pins.items.is_empty());
187 assert!(!path.exists());
188 assert!(
189 dir.path()
190 .join(format!("{PIN_STORE_FILE}.invalid"))
191 .is_file()
192 );
193 }
194 }
195
196 #[cfg(windows)]
197 #[test]
198 fn interrupted_windows_replace_restores_the_backup() {
199 let dir = tempfile::tempdir().unwrap();
200 let path = dir.path().join(PIN_STORE_FILE);
201 let backup = path.with_extension("json.bak");
202 fs::write(
203 &backup,
204 r#"{"version":1,"items":[{"kind":"lxapp","key":"chat"}]}"#,
205 )
206 .unwrap();
207
208 let manager = crate::ShellManager::open(dir.path()).unwrap();
209
210 assert_eq!(manager.snapshot().pins.items.len(), 1);
211 assert!(path.is_file());
212 assert!(!backup.exists());
213 }
214}