use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum BookmarkError {
#[error("failed to read bookmarks from {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to write bookmarks to {path}: {source}")]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse bookmarks TOML: {0}")]
Parse(#[from] toml::de::Error),
#[error("failed to serialize bookmarks TOML: {0}")]
Serialize(#[from] toml::ser::Error),
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct BookmarksFile {
#[serde(default)]
bookmarks: BTreeMap<String, String>,
}
#[derive(Debug)]
pub struct BookmarkStore {
file_path: PathBuf,
map: BTreeMap<String, PathBuf>,
}
impl BookmarkStore {
pub fn open(file_path: PathBuf) -> Result<Self, BookmarkError> {
let map = if file_path.exists() {
let content = std::fs::read_to_string(&file_path).map_err(|e| BookmarkError::Read {
path: file_path.clone(),
source: e,
})?;
let parsed: BookmarksFile = toml::from_str(&content)?;
parsed
.bookmarks
.into_iter()
.map(|(k, v)| (k, PathBuf::from(v)))
.collect()
} else {
BTreeMap::new()
};
Ok(Self { file_path, map })
}
pub fn add(&mut self, name: String, path: PathBuf) -> Result<(), BookmarkError> {
self.map.insert(name, path);
self.save()
}
#[allow(dead_code)]
pub fn remove(&mut self, name: &str) -> Result<bool, BookmarkError> {
let existed = self.map.remove(name).is_some();
self.save()?;
Ok(existed)
}
pub fn get(&self, name: &str) -> Option<&Path> {
self.map.get(name).map(|p| p.as_path())
}
#[allow(dead_code)]
pub fn list(&self) -> impl Iterator<Item = (&str, &Path)> {
self.map.iter().map(|(k, v)| (k.as_str(), v.as_path()))
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.map.len()
}
fn save(&self) -> Result<(), BookmarkError> {
if let Some(parent) = self.file_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| BookmarkError::Write {
path: self.file_path.clone(),
source: e,
})?;
}
let on_disk = BookmarksFile {
bookmarks: self
.map
.iter()
.map(|(k, v)| (k.clone(), v.display().to_string()))
.collect(),
};
let toml_str = toml::to_string_pretty(&on_disk)?;
std::fs::write(&self.file_path, toml_str).map_err(|e| BookmarkError::Write {
path: self.file_path.clone(),
source: e,
})
}
}
#[allow(dead_code)]
pub fn default_bookmark_path() -> PathBuf {
crate::paths::bookmarks_file()
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_store() -> (tempfile::TempDir, BookmarkStore) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("bookmarks.toml");
let store = BookmarkStore::open(path).expect("open");
(dir, store)
}
#[test]
fn empty_store_on_new_file() {
let (_dir, store) = temp_store();
assert!(store.is_empty());
assert_eq!(store.len(), 0);
}
#[test]
fn add_and_get() {
let (_dir, mut store) = temp_store();
store
.add("home".to_owned(), PathBuf::from("/home/user"))
.expect("add");
assert_eq!(store.get("home"), Some(Path::new("/home/user")));
assert_eq!(store.len(), 1);
}
#[test]
fn add_overwrites_existing() {
let (_dir, mut store) = temp_store();
store
.add("home".to_owned(), PathBuf::from("/home/old"))
.expect("add");
store
.add("home".to_owned(), PathBuf::from("/home/new"))
.expect("add");
assert_eq!(store.get("home"), Some(Path::new("/home/new")));
assert_eq!(store.len(), 1);
}
#[test]
fn remove_returns_true_when_existed() {
let (_dir, mut store) = temp_store();
store
.add("work".to_owned(), PathBuf::from("/work"))
.expect("add");
let removed = store.remove("work").expect("remove");
assert!(removed);
assert!(store.is_empty());
}
#[test]
fn remove_returns_false_when_absent() {
let (_dir, mut store) = temp_store();
let removed = store.remove("nope").expect("remove");
assert!(!removed);
}
#[test]
fn list_is_alphabetical() {
let (_dir, mut store) = temp_store();
store
.add("zoo".to_owned(), PathBuf::from("/zoo"))
.expect("add");
store
.add("alpha".to_owned(), PathBuf::from("/alpha"))
.expect("add");
store
.add("middle".to_owned(), PathBuf::from("/middle"))
.expect("add");
let names: Vec<_> = store.list().map(|(n, _)| n).collect();
assert_eq!(names, vec!["alpha", "middle", "zoo"]);
}
#[test]
fn persists_across_reopen() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("bookmarks.toml");
{
let mut store = BookmarkStore::open(path.clone()).expect("open");
store
.add("work".to_owned(), PathBuf::from("/work/project"))
.expect("add");
}
let store2 = BookmarkStore::open(path).expect("reopen");
assert_eq!(store2.get("work"), Some(Path::new("/work/project")));
}
#[test]
fn get_nonexistent_returns_none() {
let (_dir, store) = temp_store();
assert_eq!(store.get("nope"), None);
}
}