use crate::{PinCollection, ShellError, ShellResult};
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::fs;
use std::path::{Path, PathBuf};
pub const PIN_STORE_FILE: &str = "shell-pins-v1.json";
#[derive(Debug, Clone)]
pub struct ShellStore {
root: PathBuf,
}
impl ShellStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn load_pins(&self) -> ShellResult<PinCollection> {
let Some(value) = self.load_optional::<PinCollection>(PIN_STORE_FILE)? else {
return Ok(PinCollection::default());
};
value.restore()
}
pub(crate) fn load_pins_recovering(&self) -> PinCollection {
match self.load_pins() {
Ok(pins) => pins,
Err(_) => {
let _ = self.quarantine(PIN_STORE_FILE);
PinCollection::default()
}
}
}
pub fn save_pins(&self, pins: &PinCollection) -> ShellResult<()> {
self.save(PIN_STORE_FILE, pins)
}
fn load_optional<T: DeserializeOwned>(&self, name: &str) -> ShellResult<Option<T>> {
let path = self.root.join(name);
#[cfg(windows)]
restore_backup_if_needed(&path)?;
if !path.is_file() {
return Ok(None);
}
let raw = fs::read_to_string(&path)
.map_err(|error| ShellError::Io(format!("read {}: {error}", path.display())))?;
serde_json::from_str(&raw)
.map(Some)
.map_err(|error| ShellError::InvalidState(format!("{}: {error}", path.display())))
}
fn quarantine(&self, name: &str) -> ShellResult<Option<PathBuf>> {
let path = self.root.join(name);
if !path.exists() {
return Ok(None);
}
for index in 0..=u16::MAX {
let suffix = if index == 0 {
"invalid".to_string()
} else {
format!("invalid.{index}")
};
let target = self.root.join(format!("{name}.{suffix}"));
if !target.exists() {
fs::rename(&path, &target).map_err(|error| {
ShellError::Io(format!(
"quarantine {} as {}: {error}",
path.display(),
target.display()
))
})?;
return Ok(Some(target));
}
}
Err(ShellError::Io(format!(
"no quarantine filename available for {}",
path.display()
)))
}
fn save<T: Serialize>(&self, name: &str, value: &T) -> ShellResult<()> {
fs::create_dir_all(&self.root)
.map_err(|error| ShellError::Io(format!("create {}: {error}", self.root.display())))?;
let path = self.root.join(name);
let tmp = self.root.join(format!("{name}.tmp"));
let raw = serde_json::to_vec_pretty(value)?;
fs::write(&tmp, raw)
.map_err(|error| ShellError::Io(format!("write {}: {error}", tmp.display())))?;
replace_file(&tmp, &path)
}
}
#[cfg(not(windows))]
fn replace_file(tmp: &Path, path: &Path) -> ShellResult<()> {
fs::rename(tmp, path)
.map_err(|error| ShellError::Io(format!("replace {}: {error}", path.display())))
}
#[cfg(windows)]
fn restore_backup_if_needed(path: &Path) -> ShellResult<()> {
if path.exists() {
return Ok(());
}
let backup = path.with_extension("json.bak");
if backup.exists() {
fs::rename(&backup, path)
.map_err(|error| ShellError::Io(format!("restore {}: {error}", path.display())))?;
}
Ok(())
}
#[cfg(windows)]
fn replace_file(tmp: &Path, path: &Path) -> ShellResult<()> {
let backup = path.with_extension("json.bak");
if backup.exists() {
fs::remove_file(&backup)
.map_err(|error| ShellError::Io(format!("remove {}: {error}", backup.display())))?;
}
let had_previous = path.exists();
if had_previous {
fs::rename(path, &backup)
.map_err(|error| ShellError::Io(format!("backup {}: {error}", path.display())))?;
}
if let Err(error) = fs::rename(tmp, path) {
if had_previous {
let _ = fs::rename(&backup, path);
}
return Err(ShellError::Io(format!(
"replace {}: {error}",
path.display()
)));
}
if had_previous {
let _ = fs::remove_file(backup);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ShellPinTarget;
#[test]
fn stores_mixed_pins() {
let dir = tempfile::tempdir().unwrap();
let store = ShellStore::new(dir.path());
let mut pins = PinCollection::default();
pins.pin(ShellPinTarget::Lxapp {
key: "app.chat".to_string(),
})
.unwrap();
pins.pin(ShellPinTarget::Bookmark {
key: "bookmark-a".to_string(),
})
.unwrap();
store.save_pins(&pins).unwrap();
let restored_pins = store.load_pins().unwrap();
assert_eq!(restored_pins, pins);
}
#[test]
fn invalid_pin_store_is_quarantined_without_blocking_manager_open() {
let overflow = serde_json::json!({
"version": 1,
"items": (0..=crate::MAX_SHELL_PINS)
.map(|index| serde_json::json!({ "kind": "lxapp", "key": format!("app.{index}") }))
.collect::<Vec<_>>()
})
.to_string();
for invalid in [
"{".to_string(),
r#"{"version":2,"items":[]}"#.to_string(),
r#"{"version":1,"items":[{"kind":"lxapp","key":"chat"},{"kind":"lxapp","key":"chat"}]}"#.to_string(),
overflow,
] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(PIN_STORE_FILE);
fs::write(&path, invalid).unwrap();
let manager = crate::ShellManager::open(dir.path()).unwrap();
assert!(manager.snapshot().pins.items.is_empty());
assert!(!path.exists());
assert!(
dir.path()
.join(format!("{PIN_STORE_FILE}.invalid"))
.is_file()
);
}
}
#[cfg(windows)]
#[test]
fn interrupted_windows_replace_restores_the_backup() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(PIN_STORE_FILE);
let backup = path.with_extension("json.bak");
fs::write(
&backup,
r#"{"version":1,"items":[{"kind":"lxapp","key":"chat"}]}"#,
)
.unwrap();
let manager = crate::ShellManager::open(dir.path()).unwrap();
assert_eq!(manager.snapshot().pins.items.len(), 1);
assert!(path.is_file());
assert!(!backup.exists());
}
}