use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Default, Serialize, Deserialize)]
struct MarksFile {
#[serde(default, skip_serializing_if = "String::is_empty")]
dir: String,
#[serde(default)]
marks: BTreeMap<String, String>,
}
pub type Bookmark = (char, PathBuf);
pub struct Bookmarks {
base: PathBuf, open_dir: PathBuf, global: BTreeMap<char, PathBuf>,
local: BTreeMap<char, PathBuf>,
}
impl Bookmarks {
pub fn load(open_dir: &Path) -> Self {
Self::with_base(default_base(), open_dir)
}
pub fn with_base(base: PathBuf, open_dir: &Path) -> Self {
let global = read_marks(&global_path(&base))
.into_iter()
.filter(|(k, _)| k.is_ascii_uppercase())
.collect();
let local = read_marks(&local_path(&base, open_dir))
.into_iter()
.filter(|(k, _)| k.is_ascii_lowercase())
.collect();
Self {
base,
open_dir: open_dir.to_path_buf(),
global,
local,
}
}
pub fn set(&mut self, key: char, dir: PathBuf) -> Result<bool> {
if key.is_ascii_uppercase() {
self.global.insert(key, dir);
self.save_global()?;
Ok(true)
} else if key.is_ascii_lowercase() {
self.local.insert(key, dir);
self.save_local()?;
Ok(true)
} else {
Ok(false)
}
}
pub fn get(&self, key: char) -> Option<PathBuf> {
if key.is_ascii_uppercase() {
self.global.get(&key).cloned()
} else {
self.local.get(&key).cloned()
}
}
pub fn remove(&mut self, key: char) -> Result<()> {
if key.is_ascii_uppercase() {
if self.global.remove(&key).is_some() {
self.save_global()?;
}
} else if self.local.remove(&key).is_some() {
self.save_local()?;
}
Ok(())
}
pub fn list(&self) -> (Vec<Bookmark>, Vec<Bookmark>) {
let l = self.local.iter().map(|(k, v)| (*k, v.clone())).collect();
let g = self.global.iter().map(|(k, v)| (*k, v.clone())).collect();
(l, g)
}
fn save_global(&self) -> Result<()> {
let mut mf = MarksFile::default();
for (k, v) in &self.global {
mf.marks
.insert(k.to_string(), v.to_string_lossy().to_string());
}
write_marks(&global_path(&self.base), &mf)
}
fn save_local(&self) -> Result<()> {
let mut mf = MarksFile {
dir: self.open_dir.to_string_lossy().to_string(),
..Default::default()
};
for (k, v) in &self.local {
mf.marks
.insert(k.to_string(), v.to_string_lossy().to_string());
}
write_marks(&local_path(&self.base, &self.open_dir), &mf)
}
}
pub(crate) fn xdg_base_dir(
xdg_value: Option<&std::ffi::OsStr>,
home_value: Option<&std::ffi::OsStr>,
home_suffix: &str,
) -> Option<PathBuf> {
if let Some(x) = xdg_value {
if !x.is_empty() {
return Some(PathBuf::from(x));
}
}
let home = home_value?;
if home.is_empty() {
return None;
}
Some(PathBuf::from(home).join(home_suffix))
}
pub(crate) fn xdg_config_home() -> Option<PathBuf> {
xdg_base_dir(
std::env::var_os("XDG_CONFIG_HOME").as_deref(),
std::env::var_os("HOME").as_deref(),
".config",
)
}
fn default_base_from(config_home: Option<PathBuf>) -> PathBuf {
config_home
.unwrap_or_else(std::env::temp_dir)
.join("konoma")
}
pub(crate) fn default_base() -> PathBuf {
default_base_from(xdg_config_home())
}
fn global_path(base: &Path) -> PathBuf {
base.join("bookmarks.toml")
}
fn local_path(base: &Path, open_dir: &Path) -> PathBuf {
base.join("bookmarks")
.join(format!("{}.toml", encode_path(open_dir)))
}
pub(crate) fn encode_path(p: &Path) -> String {
let s = p.to_string_lossy();
let mut out = String::with_capacity(s.len() + 8);
for b in s.bytes() {
let c = b as char;
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
out.push(c);
} else {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
}
const MAX: usize = 200;
if out.len() > MAX {
let h = fnv1a(s.as_bytes());
out.truncate(MAX.saturating_sub(9));
out.push('~');
out.push_str(&format!("{h:08x}"));
}
out
}
fn fnv1a(bytes: &[u8]) -> u32 {
let mut h: u32 = 0x811c_9dc5;
for &b in bytes {
h ^= b as u32;
h = h.wrapping_mul(0x0100_0193);
}
h
}
fn read_marks(path: &Path) -> BTreeMap<char, PathBuf> {
let mut out = BTreeMap::new();
let Ok(text) = std::fs::read_to_string(path) else {
return out;
};
let Ok(mf) = toml::from_str::<MarksFile>(&text) else {
return out;
};
for (k, v) in mf.marks {
let mut chars = k.chars();
if let (Some(c), None) = (chars.next(), chars.next()) {
if c.is_ascii_alphabetic() && !v.is_empty() {
out.insert(c, PathBuf::from(v));
}
}
}
out
}
fn write_marks(path: &Path, mf: &MarksFile) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create bookmarks directory: {}", parent.display()))?;
}
let text = toml::to_string(mf).context("format bookmarks as TOML")?;
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
std::fs::write(&tmp, text)
.with_context(|| format!("write bookmarks temp file: {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| format!("save bookmarks: {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::unique_tmp;
#[test]
fn default_base_from_must_not_fall_back_to_a_relative_path_when_home_is_missing() {
let base = default_base_from(None);
assert!(
base.is_absolute(),
"HOME/XDG_CONFIG_HOME どちらも無いとき、default_base は絶対パスに解決されるべき(相対パスへ落ちてはいけない): {base:?}"
);
assert_eq!(
base.parent().map(std::path::PathBuf::from),
Some(std::env::temp_dir()),
"無い場合のフォールバックはシステム一時ディレクトリ"
);
assert_eq!(base.file_name(), Some(std::ffi::OsStr::new("konoma")));
}
#[test]
fn default_base_from_uses_resolved_config_home_when_present() {
assert_eq!(
default_base_from(Some(PathBuf::from("/x/.config"))),
PathBuf::from("/x/.config/konoma")
);
}
#[test]
fn xdg_base_dir_prefers_xdg_var_then_home_then_none() {
use std::ffi::OsStr;
assert_eq!(
xdg_base_dir(
Some(OsStr::new("/xdg/cfg")),
Some(OsStr::new("/home/u")),
".config"
),
Some(PathBuf::from("/xdg/cfg")),
"XDG_* が最優先"
);
assert_eq!(
xdg_base_dir(None, Some(OsStr::new("/home/u")), ".config"),
Some(PathBuf::from("/home/u/.config")),
"XDG 無ければ HOME 由来"
);
assert_eq!(
xdg_base_dir(Some(OsStr::new("")), Some(OsStr::new("/home/u")), ".cache"),
Some(PathBuf::from("/home/u/.cache")),
"空の XDG_* は無視して HOME 由来"
);
assert_eq!(
xdg_base_dir(None, None, ".config"),
None,
"どちらも無ければ None"
);
assert_eq!(
xdg_base_dir(None, Some(OsStr::new("")), ".config"),
None,
"空の HOME も None 扱い"
);
}
#[test]
fn default_base_is_always_absolute_in_the_real_environment() {
assert!(
default_base().is_absolute(),
"実環境でも default_base は常に絶対パス: {:?}",
default_base()
);
}
#[test]
fn encode_path_is_reversible_and_safe() {
let enc = encode_path(Path::new("/Users/me/work/konoma"));
assert_eq!(enc, "%2FUsers%2Fme%2Fwork%2Fkonoma");
assert!(enc
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '%' | '.' | '_' | '-')));
}
#[test]
fn set_get_scope_by_case_and_persist() {
let base = unique_tmp("konoma_bm_test_base");
let _ = std::fs::remove_dir_all(&base);
let proj = unique_tmp("konoma_bm_test_proj");
std::fs::create_dir_all(&proj).unwrap();
let mut bm = Bookmarks::with_base(base.clone(), &proj);
assert!(bm.set('a', PathBuf::from("/tmp/local_a")).unwrap());
assert!(bm.set('A', PathBuf::from("/tmp/global_A")).unwrap());
assert!(
!bm.set('1', PathBuf::from("/tmp/x")).unwrap(),
"英字以外は拒否"
);
assert_eq!(bm.get('a'), Some(PathBuf::from("/tmp/local_a")));
assert_eq!(bm.get('A'), Some(PathBuf::from("/tmp/global_A")));
assert_eq!(bm.get('b'), None);
let bm2 = Bookmarks::with_base(base.clone(), &proj);
assert_eq!(bm2.get('a'), Some(PathBuf::from("/tmp/local_a")));
assert_eq!(bm2.get('A'), Some(PathBuf::from("/tmp/global_A")));
let proj2 = unique_tmp("konoma_bm_test_proj2");
std::fs::create_dir_all(&proj2).unwrap();
let bm3 = Bookmarks::with_base(base.clone(), &proj2);
assert_eq!(bm3.get('a'), None, "ローカルは起動dir 別");
assert_eq!(
bm3.get('A'),
Some(PathBuf::from("/tmp/global_A")),
"グローバルは共有"
);
let mut bm4 = Bookmarks::with_base(base.clone(), &proj);
bm4.remove('a').unwrap();
let bm5 = Bookmarks::with_base(base.clone(), &proj);
assert_eq!(bm5.get('a'), None);
assert_eq!(bm5.get('A'), Some(PathBuf::from("/tmp/global_A")));
std::fs::remove_dir_all(&base).ok();
std::fs::remove_dir_all(&proj).ok();
std::fs::remove_dir_all(&proj2).ok();
}
#[test]
fn fnv1a_is_stable_and_distinguishes_inputs() {
assert_eq!(fnv1a(b""), 0x811c_9dc5, "空入力はオフセット基底");
assert_eq!(fnv1a(b"a"), 0xe40c_292c, "\"a\" の既知ハッシュ");
assert_eq!(fnv1a(b"konoma"), fnv1a(b"konoma"));
assert_ne!(fnv1a(b"hello"), fnv1a(b"world"));
assert_ne!(fnv1a(b"ab"), fnv1a(b"ba"), "順序も効く");
}
#[test]
fn write_marks_and_read_marks_round_trip() {
let dir = unique_tmp("konoma_write_marks_test");
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("nested").join("marks.toml"); let mut mf = MarksFile {
dir: "/some/start/dir".into(),
..Default::default()
};
mf.marks.insert("a".into(), "/tmp/local_a".into());
mf.marks.insert("Z".into(), "/tmp/global_z".into());
mf.marks.insert("ab".into(), "/tmp/bad".into());
mf.marks.insert("c".into(), "".into());
write_marks(&path, &mf).unwrap();
assert!(path.is_file(), "親ごと作成して書き出す");
let got = read_marks(&path);
assert_eq!(got.get(&'a'), Some(&PathBuf::from("/tmp/local_a")));
assert_eq!(got.get(&'Z'), Some(&PathBuf::from("/tmp/global_z")));
assert!(!got.contains_key(&'c'), "空値は無視");
assert_eq!(got.len(), 2, "有効キーのみ復元(複数文字キー/空値は除外)");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
#[cfg(unix)]
fn write_marks_replaces_via_rename_not_through_symlinks() {
let dir = unique_tmp("konoma_write_marks_atomic_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let other = dir.join("other.toml");
std::fs::write(&other, "sentinel = \"do-not-touch\"\n").unwrap();
let target = dir.join("bookmarks.toml");
std::os::unix::fs::symlink(&other, &target).unwrap();
let mut mf = MarksFile::default();
mf.marks.insert("a".into(), "/tmp/new".into());
write_marks(&target, &mf).unwrap();
let other_after = std::fs::read_to_string(&other).unwrap();
assert_eq!(
other_after, "sentinel = \"do-not-touch\"\n",
"write_marks はシンボリックリンク先(=既存の内容)を破壊してはいけない(非アトミック書込みの再発防止)"
);
assert!(
!target.symlink_metadata().unwrap().file_type().is_symlink(),
"rename がシンボリックリンク自体を新しい内容の実ファイルへ置き換える"
);
let got = read_marks(&target);
assert_eq!(got.get(&'a'), Some(&PathBuf::from("/tmp/new")));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn write_marks_leaves_no_temp_file_behind() {
let dir = unique_tmp("konoma_write_marks_no_temp_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("bookmarks.toml");
let mut mf = MarksFile::default();
mf.marks.insert("a".into(), "/tmp/one".into());
write_marks(&path, &mf).unwrap();
mf.marks.insert("b".into(), "/tmp/two".into());
write_marks(&path, &mf).unwrap();
let got = read_marks(&path);
assert_eq!(got.len(), 2, "2回目の書込みが反映される");
let leftovers = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.count();
assert_eq!(leftovers, 0, "temp ファイルを残さない");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn list_returns_local_then_global_sorted() {
let base = unique_tmp("konoma_bm_list_test_base");
let _ = std::fs::remove_dir_all(&base);
let proj = unique_tmp("konoma_bm_list_test_proj");
std::fs::create_dir_all(&proj).unwrap();
let mut bm = Bookmarks::with_base(base.clone(), &proj);
bm.set('b', PathBuf::from("/tmp/b")).unwrap();
bm.set('a', PathBuf::from("/tmp/a")).unwrap();
bm.set('B', PathBuf::from("/tmp/B")).unwrap();
bm.set('A', PathBuf::from("/tmp/A")).unwrap();
let (local, global) = bm.list();
assert_eq!(
local,
vec![
('a', PathBuf::from("/tmp/a")),
('b', PathBuf::from("/tmp/b"))
]
);
assert_eq!(
global,
vec![
('A', PathBuf::from("/tmp/A")),
('B', PathBuf::from("/tmp/B"))
]
);
std::fs::remove_dir_all(&base).ok();
std::fs::remove_dir_all(&proj).ok();
}
}