use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bookmark {
pub key: String,
pub path: PathBuf,
pub name: Option<String>,
}
impl Bookmark {
pub fn validate_name(name: &str) -> Result<()> {
if name.is_empty() {
anyhow::bail!("Bookmark name cannot be empty");
}
if name.len() > 255 {
anyhow::bail!("Bookmark name too long (max 255 characters)");
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
anyhow::bail!("Bookmark name cannot contain path separators (/, \\) or null bytes");
}
if name.chars().any(|c| c.is_control()) {
anyhow::bail!("Bookmark name cannot contain control characters");
}
let reserved = [
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
"COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
if reserved.contains(&name.to_uppercase().as_str()) {
anyhow::bail!("Bookmark name '{}' is reserved", name);
}
Ok(())
}
}
#[derive(Debug, Default)]
pub struct Bookmarks {
bookmarks: BTreeMap<String, Bookmark>,
file_path: PathBuf,
pub is_selecting: bool,
pub is_creating: bool,
pub input_buffer: String,
pub selected_index: usize, pub filter_mode: bool, filtered_keys: Vec<String>, pub scroll_offset: usize, pub pending_deletion_index: Option<usize>, pub center_selection: bool,
}
impl Bookmarks {
pub fn new() -> Result<Self> {
let config_dir = dirs::config_dir()
.context("Could not find config directory")?
.join("bmrk");
fs::create_dir_all(&config_dir)?;
let file_path = config_dir.join("bookmarks.json");
let mut bookmarks = Self {
bookmarks: BTreeMap::new(),
file_path,
is_selecting: false,
is_creating: false,
input_buffer: String::new(),
selected_index: 0,
filter_mode: false,
filtered_keys: Vec::new(),
scroll_offset: 0,
pending_deletion_index: None,
center_selection: false,
};
if let Err(e) = bookmarks.load() {
eprintln!("\n┌─────────────────────────────────────────────────────────────┐");
eprintln!("│ WARNING: Bookmarks file is corrupted │");
eprintln!("└─────────────────────────────────────────────────────────────┘\n");
eprintln!("{}\n", e);
eprintln!("Press Enter to continue...");
let mut input = String::new();
let _ = std::io::stdin().read_line(&mut input);
}
Ok(bookmarks)
}
fn load(&mut self) -> Result<()> {
if !self.file_path.exists() {
self.save()?;
return Ok(());
}
let content = match fs::read_to_string(&self.file_path) {
Ok(c) => c,
Err(_) => {
self.save()?;
return Ok(());
}
};
if content.trim().is_empty() {
return Ok(());
}
match serde_json::from_str::<Vec<Bookmark>>(&content) {
Ok(bookmarks_vec) => {
self.bookmarks.clear();
for bookmark in bookmarks_vec {
self.bookmarks.insert(bookmark.key.clone(), bookmark);
}
Ok(())
}
Err(e) => {
let backup_path = self.file_path.with_extension("json.backup");
let _ = fs::copy(&self.file_path, &backup_path);
self.bookmarks.clear();
self.save()?;
Err(anyhow::anyhow!(
"Failed to parse bookmarks JSON: {}.\n\
The corrupted file has been backed up to: {}\n\
A new empty bookmarks file has been created.",
e,
backup_path.display()
))
}
}
}
fn save(&self) -> Result<()> {
let bookmarks_vec: Vec<&Bookmark> = self.bookmarks.values().collect();
let json = serde_json::to_string_pretty(&bookmarks_vec)
.context("Failed to serialize bookmarks")?;
let tmp_path = self.file_path.with_extension("json.tmp");
fs::write(&tmp_path, &json).context("Failed to write temp bookmarks file")?;
fs::rename(&tmp_path, &self.file_path).context("Failed to rename temp bookmarks file")?;
Ok(())
}
pub fn add(&mut self, key: String, path: PathBuf, name: Option<String>) -> Result<()> {
Bookmark::validate_name(&key)?;
let bookmark = Bookmark {
key: key.clone(),
path,
name,
};
self.bookmarks.insert(key, bookmark);
self.save()?;
Ok(())
}
pub fn get(&self, key: &str) -> Option<&Bookmark> {
self.bookmarks.get(key)
}
pub fn remove(&mut self, key: &str) -> Result<()> {
if self.bookmarks.remove(key).is_none() {
anyhow::bail!("Bookmark '{}' not found", key);
}
self.save()?;
Ok(())
}
pub fn list(&self) -> Vec<&Bookmark> {
self.bookmarks.values().collect()
}
pub fn enter_selection_mode(&mut self) {
self.is_selecting = true;
self.is_creating = false;
self.input_buffer.clear();
self.selected_index = 0;
self.filter_mode = false;
self.pending_deletion_index = None;
self.center_selection = false;
self.update_filtered_list();
}
pub fn exit_selection_mode(&mut self) {
self.is_selecting = false;
self.input_buffer.clear();
self.selected_index = 0;
self.filter_mode = false;
self.filtered_keys.clear();
self.pending_deletion_index = None;
self.center_selection = false;
}
pub fn enter_creation_mode(&mut self) {
self.is_creating = true;
self.is_selecting = false;
self.input_buffer.clear();
self.selected_index = 0;
self.filter_mode = false;
self.scroll_offset = 0;
}
pub fn exit_creation_mode(&mut self) {
self.is_creating = false;
self.input_buffer.clear();
self.scroll_offset = 0;
}
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
pub fn scroll_down(&mut self, max_visible: usize) {
let total_bookmarks = self.list().len();
let max_offset = total_bookmarks.saturating_sub(max_visible);
if self.scroll_offset < max_offset {
self.scroll_offset += 1;
}
}
pub fn add_char(&mut self, c: char) {
self.input_buffer.push(c);
if self.filter_mode {
self.update_filtered_list();
let list_len = self.get_filtered_bookmarks().len();
if list_len == 0 {
self.selected_index = 0;
} else if self.selected_index >= list_len {
self.selected_index = list_len - 1;
}
}
}
pub fn backspace(&mut self) {
self.input_buffer.pop();
if self.filter_mode {
self.update_filtered_list();
let list_len = self.get_filtered_bookmarks().len();
if list_len == 0 {
self.selected_index = 0;
} else if self.selected_index >= list_len {
self.selected_index = list_len - 1;
}
}
}
pub fn get_input(&self) -> &str {
&self.input_buffer
}
pub fn toggle_filter_mode(&mut self) {
self.filter_mode = !self.filter_mode;
let list_len = self.get_filtered_bookmarks().len();
if list_len > 0 && self.selected_index >= list_len {
self.selected_index = list_len - 1;
}
}
fn update_filtered_list(&mut self) {
let query = self.input_buffer.to_lowercase();
if query.is_empty() {
self.filtered_keys = self.list().iter().map(|b| b.key.clone()).collect();
} else {
self.filtered_keys = self
.list()
.iter()
.filter(|b| {
let key_match = b.key.to_lowercase().contains(&query);
let name_match = b
.name
.as_ref()
.map(|n| n.to_lowercase().contains(&query))
.unwrap_or(false);
key_match || name_match
})
.map(|b| b.key.clone())
.collect();
}
}
pub fn get_filtered_bookmarks(&self) -> Vec<&Bookmark> {
if self.filtered_keys.is_empty() {
Vec::new()
} else {
self.filtered_keys
.iter()
.filter_map(|key| self.bookmarks.get(key))
.collect()
}
}
pub fn move_up(&mut self) {
if self.selected_index > 0 {
self.selected_index -= 1;
}
self.pending_deletion_index = None;
self.center_selection = true;
}
pub fn move_down(&mut self) {
let list_len = self.get_filtered_bookmarks().len();
if list_len > 0 && self.selected_index < list_len - 1 {
self.selected_index += 1;
}
self.pending_deletion_index = None;
self.center_selection = true;
}
pub fn get_selected_bookmark(&self) -> Option<&Bookmark> {
let filtered = self.get_filtered_bookmarks();
filtered.get(self.selected_index).copied()
}
pub fn handle_deletion_key(&mut self) -> Result<bool> {
if let Some(pending_idx) = self.pending_deletion_index {
if pending_idx == self.selected_index {
if let Some(bookmark) = self.get_selected_bookmark() {
let key = bookmark.key.clone();
self.remove(&key)?;
self.update_filtered_list();
let list_len = self.get_filtered_bookmarks().len();
if list_len == 0 {
self.selected_index = 0;
} else if self.selected_index >= list_len {
self.selected_index = list_len - 1;
}
self.pending_deletion_index = None;
return Ok(true);
}
}
}
self.pending_deletion_index = Some(self.selected_index);
Ok(false)
}
#[allow(dead_code)]
pub fn is_marked_for_deletion(&self) -> bool {
self.pending_deletion_index == Some(self.selected_index)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn create_test_bookmarks(temp_dir: &TempDir) -> Bookmarks {
let file_path = temp_dir.path().join("bookmarks.json");
Bookmarks {
bookmarks: BTreeMap::new(),
file_path,
is_selecting: false,
is_creating: false,
input_buffer: String::new(),
selected_index: 0,
filter_mode: false,
filtered_keys: Vec::new(),
scroll_offset: 0,
pending_deletion_index: None,
center_selection: false,
}
}
#[test]
fn test_save_and_load_bookmarks() {
let temp_dir = TempDir::new().unwrap();
let mut bookmarks = create_test_bookmarks(&temp_dir);
bookmarks
.add(
"a".to_string(),
PathBuf::from("/tmp/test1"),
Some("Test 1".to_string()),
)
.unwrap();
bookmarks
.add(
"b".to_string(),
PathBuf::from("/tmp/test2"),
Some("Test 2".to_string()),
)
.unwrap();
bookmarks.save().unwrap();
let mut bookmarks2 = create_test_bookmarks(&temp_dir);
bookmarks2.load().unwrap();
assert_eq!(bookmarks2.list().len(), 2);
assert_eq!(
bookmarks2.get("a").unwrap().path,
PathBuf::from("/tmp/test1")
);
assert_eq!(
bookmarks2.get("b").unwrap().path,
PathBuf::from("/tmp/test2")
);
}
#[test]
fn test_corrupted_json_creates_backup() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("bookmarks.json");
let backup_path = temp_dir.path().join("bookmarks.json.backup");
let mut file = std::fs::File::create(&file_path).unwrap();
file.write_all(b"[\n {\"key\": \"a\", \"path\": \"/tmp/test\", \"name\": \"test\"},\n]")
.unwrap();
drop(file);
let mut bookmarks = Bookmarks {
bookmarks: BTreeMap::new(),
file_path: file_path.clone(),
is_selecting: false,
is_creating: false,
input_buffer: String::new(),
selected_index: 0,
filter_mode: false,
filtered_keys: Vec::new(),
scroll_offset: 0,
pending_deletion_index: None,
center_selection: false,
};
let result = bookmarks.load();
assert!(result.is_err());
assert!(backup_path.exists());
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "[]");
assert_eq!(bookmarks.list().len(), 0);
}
#[test]
fn test_save_leaves_no_tmp_file() {
let temp_dir = TempDir::new().unwrap();
let mut bookmarks = create_test_bookmarks(&temp_dir);
bookmarks
.add("a".to_string(), PathBuf::from("/tmp/a"), None)
.unwrap();
let tmp_path = bookmarks.file_path.with_extension("json.tmp");
assert!(
!tmp_path.exists(),
".tmp file must not exist after successful save"
);
let content = fs::read_to_string(&bookmarks.file_path).unwrap();
let parsed: Vec<serde_json::Value> = serde_json::from_str(&content).unwrap();
assert_eq!(parsed.len(), 1);
}
#[test]
fn test_empty_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("bookmarks.json");
std::fs::File::create(&file_path).unwrap();
let mut bookmarks = Bookmarks {
bookmarks: BTreeMap::new(),
file_path,
is_selecting: false,
is_creating: false,
input_buffer: String::new(),
selected_index: 0,
filter_mode: false,
filtered_keys: Vec::new(),
scroll_offset: 0,
pending_deletion_index: None,
center_selection: false,
};
bookmarks.load().unwrap();
assert_eq!(bookmarks.list().len(), 0);
}
#[test]
fn test_add_and_remove_bookmarks() {
let temp_dir = TempDir::new().unwrap();
let mut bookmarks = create_test_bookmarks(&temp_dir);
bookmarks
.add(
"x".to_string(),
PathBuf::from("/tmp/testx"),
Some("TestX".to_string()),
)
.unwrap();
assert_eq!(bookmarks.list().len(), 1);
assert!(bookmarks.get("x").is_some());
bookmarks.remove("x").unwrap();
assert_eq!(bookmarks.list().len(), 0);
assert!(bookmarks.get("x").is_none());
}
#[test]
fn test_list_sorted_by_key() {
let temp_dir = TempDir::new().unwrap();
let mut bookmarks = create_test_bookmarks(&temp_dir);
bookmarks
.add("z".to_string(), PathBuf::from("/tmp/z"), None)
.unwrap();
bookmarks
.add("a".to_string(), PathBuf::from("/tmp/a"), None)
.unwrap();
bookmarks
.add("m".to_string(), PathBuf::from("/tmp/m"), None)
.unwrap();
let list = bookmarks.list();
assert_eq!(list.len(), 3);
assert_eq!(list[0].key, "a");
assert_eq!(list[1].key, "m");
assert_eq!(list[2].key, "z");
}
#[test]
fn test_selection_mode() {
let temp_dir = TempDir::new().unwrap();
let mut bookmarks = create_test_bookmarks(&temp_dir);
assert!(!bookmarks.is_selecting);
bookmarks.enter_selection_mode();
assert!(bookmarks.is_selecting);
bookmarks.exit_selection_mode();
assert!(!bookmarks.is_selecting);
}
#[test]
fn test_creation_mode() {
let temp_dir = TempDir::new().unwrap();
let mut bookmarks = create_test_bookmarks(&temp_dir);
assert!(!bookmarks.is_creating);
bookmarks.enter_creation_mode();
assert!(bookmarks.is_creating);
bookmarks.exit_creation_mode();
assert!(!bookmarks.is_creating);
}
#[test]
fn test_overwrite_existing_bookmark() {
let temp_dir = TempDir::new().unwrap();
let mut bookmarks = create_test_bookmarks(&temp_dir);
bookmarks
.add(
"t".to_string(),
PathBuf::from("/tmp/first"),
Some("First".to_string()),
)
.unwrap();
assert_eq!(
bookmarks.get("t").unwrap().path,
PathBuf::from("/tmp/first")
);
bookmarks
.add(
"t".to_string(),
PathBuf::from("/tmp/second"),
Some("Second".to_string()),
)
.unwrap();
assert_eq!(
bookmarks.get("t").unwrap().path,
PathBuf::from("/tmp/second")
);
assert_eq!(bookmarks.get("t").unwrap().name, Some("Second".to_string()));
assert_eq!(bookmarks.list().len(), 1);
}
#[test]
fn test_multi_character_bookmark_names() {
let temp_dir = TempDir::new().unwrap();
let mut bookmarks = create_test_bookmarks(&temp_dir);
bookmarks
.add(
"work".to_string(),
PathBuf::from("/tmp/work"),
Some("Work".to_string()),
)
.unwrap();
bookmarks
.add(
"project-123".to_string(),
PathBuf::from("/tmp/proj"),
Some("Project".to_string()),
)
.unwrap();
bookmarks
.add(
"my_home".to_string(),
PathBuf::from("/home/user"),
Some("Home".to_string()),
)
.unwrap();
assert_eq!(bookmarks.list().len(), 3);
assert_eq!(
bookmarks.get("work").unwrap().path,
PathBuf::from("/tmp/work")
);
assert_eq!(
bookmarks.get("project-123").unwrap().path,
PathBuf::from("/tmp/proj")
);
assert_eq!(
bookmarks.get("my_home").unwrap().path,
PathBuf::from("/home/user")
);
}
#[test]
fn test_bookmark_name_validation() {
let temp_dir = TempDir::new().unwrap();
let mut bookmarks = create_test_bookmarks(&temp_dir);
let result = bookmarks.add("".to_string(), PathBuf::from("/tmp/test"), None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
let result = bookmarks.add("work/project".to_string(), PathBuf::from("/tmp/test"), None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("path separators"));
let result = bookmarks.add("work\0test".to_string(), PathBuf::from("/tmp/test"), None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("path separators"));
let result = bookmarks.add("CON".to_string(), PathBuf::from("/tmp/test"), None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("reserved"));
assert!(bookmarks
.add("work".to_string(), PathBuf::from("/tmp/work"), None)
.is_ok());
assert!(bookmarks
.add("project-123".to_string(), PathBuf::from("/tmp/proj"), None)
.is_ok());
assert!(bookmarks
.add(
"my_home.backup".to_string(),
PathBuf::from("/tmp/home"),
None
)
.is_ok());
}
#[test]
fn test_bookmark_remove_error() {
let temp_dir = TempDir::new().unwrap();
let mut bookmarks = create_test_bookmarks(&temp_dir);
let result = bookmarks.remove("nonexistent");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn test_scroll_down_reaches_y_and_z_bookmarks() {
let temp_dir = TempDir::new().unwrap();
let mut bookmarks = create_test_bookmarks(&temp_dir);
for ch in ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'y', 'z'] {
bookmarks
.add(ch.to_string(), PathBuf::from(format!("/tmp/{ch}")), None)
.unwrap();
}
bookmarks.enter_creation_mode();
assert_eq!(bookmarks.scroll_offset, 0);
let max_visible = 4;
for _ in 0..6 {
bookmarks.scroll_down(max_visible);
}
assert_eq!(
bookmarks.scroll_offset, 6,
"should reach offset 6 to expose y and z"
);
bookmarks.scroll_down(max_visible);
assert_eq!(bookmarks.scroll_offset, 6, "should not scroll past the end");
let visible: Vec<String> = bookmarks
.list()
.iter()
.skip(bookmarks.scroll_offset)
.take(max_visible)
.map(|b| b.key.clone())
.collect();
assert!(
visible.contains(&"y".to_string()),
"y must be visible after scrolling"
);
assert!(
visible.contains(&"z".to_string()),
"z must be visible after scrolling"
);
}
}