Skip to main content

lingxia_shell/
store.rs

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}