#![allow(dead_code)]
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Bookmark {
pub name: String,
pub endpoint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_identity: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub pinned: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct BookmarkFile {
#[serde(default = "default_version")]
version: u32,
#[serde(default, rename = "cluster")]
clusters: Vec<Bookmark>,
}
fn default_version() -> u32 {
1
}
const CURRENT_VERSION: u32 = 1;
#[derive(Clone, Debug, Default)]
pub struct BookmarkStore {
bookmarks: Vec<Bookmark>,
path: Option<PathBuf>,
}
impl BookmarkStore {
pub fn load() -> Result<Self, BookmarkError> {
let path = default_path()?;
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Result<Self, BookmarkError> {
if !path.exists() {
return Ok(Self {
bookmarks: Vec::new(),
path: Some(path.to_path_buf()),
});
}
let text = std::fs::read_to_string(path)
.map_err(|e| BookmarkError::Io(format!("read {}: {e}", path.display())))?;
let file: BookmarkFile = match toml::from_str(&text) {
Ok(f) => f,
Err(e) => {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let mut aside = path.with_extension(format!("toml.corrupt-{stamp}"));
for suffix in 1..1000u32 {
if !aside.exists() {
break;
}
aside = path.with_extension(format!("toml.corrupt-{stamp}-{suffix}"));
}
if std::fs::rename(path, &aside).is_ok() {
return Ok(Self {
bookmarks: Vec::new(),
path: Some(path.to_path_buf()),
});
}
return Err(BookmarkError::Parse(format!("{}: {e}", path.display())));
}
};
if file.version != CURRENT_VERSION {
return Err(BookmarkError::Version(file.version, CURRENT_VERSION));
}
Ok(Self {
bookmarks: file.clusters,
path: Some(path.to_path_buf()),
})
}
pub fn empty() -> Self {
Self::default()
}
pub fn bookmarks(&self) -> &[Bookmark] {
&self.bookmarks
}
pub fn sorted(&self) -> Vec<&Bookmark> {
let mut out: Vec<&Bookmark> = self.bookmarks.iter().collect();
out.sort_by(|a, b| b.pinned.cmp(&a.pinned).then_with(|| a.name.cmp(&b.name)));
out
}
pub fn upsert(&mut self, mut bm: Bookmark) -> Result<(), BookmarkError> {
bm.name = bm.name.trim().to_string();
bm.endpoint = bm.endpoint.trim().to_string();
if bm.name.is_empty() {
return Err(BookmarkError::InvalidField("name must be non-empty".into()));
}
if bm.endpoint.is_empty() {
return Err(BookmarkError::InvalidField(
"endpoint must be non-empty".into(),
));
}
if let Some(slot) = self.bookmarks.iter_mut().find(|b| b.name == bm.name) {
*slot = bm;
} else {
self.bookmarks.push(bm);
}
Ok(())
}
pub fn remove(&mut self, name: &str) -> bool {
let before = self.bookmarks.len();
self.bookmarks.retain(|b| b.name != name);
self.bookmarks.len() != before
}
pub fn toggle_pin(&mut self, name: &str) -> Option<bool> {
let bm = self.bookmarks.iter_mut().find(|b| b.name == name)?;
bm.pinned = !bm.pinned;
Some(bm.pinned)
}
pub fn save(&self) -> Result<(), BookmarkError> {
let Some(path) = self.path.as_ref() else {
return Ok(());
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
BookmarkError::Io(format!("create_dir_all {}: {e}", parent.display()))
})?;
}
let file = BookmarkFile {
version: CURRENT_VERSION,
clusters: self.bookmarks.clone(),
};
let text =
toml::to_string_pretty(&file).map_err(|e| BookmarkError::Serialize(e.to_string()))?;
let suffix = {
let pid = std::process::id();
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
format!("toml.tmp.{pid}.{ms}")
};
let tmp = path.with_extension(suffix);
std::fs::write(&tmp, text)
.map_err(|e| BookmarkError::Io(format!("write {}: {e}", tmp.display())))?;
std::fs::rename(&tmp, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
BookmarkError::Io(format!(
"rename {} -> {}: {e}",
tmp.display(),
path.display()
))
})?;
Ok(())
}
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
}
pub fn default_path() -> Result<PathBuf, BookmarkError> {
let mut dir = dirs::config_dir().ok_or(BookmarkError::NoConfigDir)?;
dir.push("net-deck");
dir.push("bookmarks.toml");
Ok(dir)
}
#[derive(Debug)]
pub enum BookmarkError {
NoConfigDir,
Io(String),
Parse(String),
Serialize(String),
Version(u32, u32),
InvalidField(String),
}
impl std::fmt::Display for BookmarkError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoConfigDir => write!(f, "no config directory available"),
Self::Io(msg) => write!(f, "bookmark I/O: {msg}"),
Self::Parse(msg) => write!(f, "bookmark parse: {msg}"),
Self::Serialize(msg) => write!(f, "bookmark serialize: {msg}"),
Self::Version(found, expected) => write!(
f,
"bookmark file version {found} unsupported (expected {expected})"
),
Self::InvalidField(msg) => write!(f, "bookmark invalid: {msg}"),
}
}
}
impl std::error::Error for BookmarkError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_store_round_trips() {
let dir = tempdir_unique();
let path = dir.join("bookmarks.toml");
let store = BookmarkStore::load_from(&path).expect("missing file is ok");
store.save().expect("save no-op when nothing to write");
assert!(
!path.exists() || {
let s = std::fs::read_to_string(&path).unwrap();
!s.is_empty()
}
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn upsert_remove_toggle_roundtrip_to_disk() {
let dir = tempdir_unique();
let path = dir.join("bookmarks.toml");
let mut store = BookmarkStore::load_from(&path).expect("missing ok");
store
.upsert(Bookmark {
name: "prod-east".to_string(),
endpoint: "mesh://0xa96f@10.0.0.7:9001".to_string(),
default_identity: None,
pinned: false,
})
.expect("valid bookmark");
store
.upsert(Bookmark {
name: "dev-laptop".to_string(),
endpoint: "unix:///tmp/deck-dev.sock".to_string(),
default_identity: Some("~/.config/deck/identities/dev.toml".to_string()),
pinned: false,
})
.expect("valid bookmark");
assert_eq!(store.toggle_pin("prod-east"), Some(true));
store.save().expect("save");
let reloaded = BookmarkStore::load_from(&path).expect("reload");
assert_eq!(reloaded.bookmarks().len(), 2);
let sorted = reloaded.sorted();
assert_eq!(sorted[0].name, "prod-east");
assert!(sorted[0].pinned);
assert_eq!(sorted[1].name, "dev-laptop");
assert!(!sorted[1].pinned);
let mut store = reloaded;
assert!(store.remove("dev-laptop"));
assert!(!store.remove("dev-laptop"));
store.save().expect("save after remove");
let reloaded = BookmarkStore::load_from(&path).expect("reload after remove");
assert_eq!(reloaded.bookmarks().len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn upsert_replaces_existing_by_name() {
let mut store = BookmarkStore::empty();
store
.upsert(Bookmark {
name: "k1".to_string(),
endpoint: "a".to_string(),
..Default::default()
})
.expect("valid bookmark");
store
.upsert(Bookmark {
name: "k1".to_string(),
endpoint: "b".to_string(),
pinned: true,
..Default::default()
})
.expect("valid bookmark");
assert_eq!(store.bookmarks().len(), 1);
assert_eq!(store.bookmarks()[0].endpoint, "b");
assert!(store.bookmarks()[0].pinned);
}
#[test]
fn upsert_rejects_empty_name() {
let mut store = BookmarkStore::empty();
let err = store
.upsert(Bookmark {
name: " ".to_string(),
endpoint: "mesh://example".to_string(),
..Default::default()
})
.expect_err("whitespace-only name must be rejected");
assert!(matches!(err, BookmarkError::InvalidField(_)));
assert!(store.bookmarks().is_empty());
}
#[test]
fn upsert_rejects_empty_endpoint() {
let mut store = BookmarkStore::empty();
let err = store
.upsert(Bookmark {
name: "named".to_string(),
endpoint: "".to_string(),
..Default::default()
})
.expect_err("empty endpoint must be rejected");
assert!(matches!(err, BookmarkError::InvalidField(_)));
}
#[test]
fn version_mismatch_surfaces_as_error() {
let dir = tempdir_unique();
let path = dir.join("bookmarks.toml");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
&path,
"version = 999\n[[cluster]]\nname = \"x\"\nendpoint = \"y\"\n",
)
.unwrap();
match BookmarkStore::load_from(&path) {
Err(BookmarkError::Version(999, 1)) => {}
other => panic!("expected version mismatch, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&dir);
}
fn tempdir_unique() -> std::path::PathBuf {
let n: u64 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!("deck-bookmark-test-{n}"));
std::fs::create_dir_all(&dir).unwrap();
dir
}
}