bookmarks_core/
toml_storage.rs1use anyhow::{Context, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::config::{Config, DEFAULT_CONFIG};
6use crate::storage::Storage;
7
8const CONFIG_DIR: &str = ".config";
9const APP_NAME: &str = "bookmarks";
10const CONFIG_FILENAME: &str = "bookmarks.toml";
11
12pub struct TomlStorage {
13 path: PathBuf,
14}
15
16impl TomlStorage {
17 pub fn new(path: PathBuf) -> Self {
18 Self { path }
19 }
20
21 pub fn default_path() -> Result<PathBuf> {
23 let home = dirs::home_dir().context("Failed to get home directory")?;
27 Ok(home.join(CONFIG_DIR).join(APP_NAME).join(CONFIG_FILENAME))
28 }
29
30 pub fn cwd_path() -> Option<PathBuf> {
32 std::env::current_dir()
33 .ok()
34 .map(|d| d.join(CONFIG_FILENAME))
35 }
36
37 pub fn with_default_path() -> Result<Self> {
38 Ok(Self::new(Self::default_path()?))
39 }
40}
41
42impl Storage for TomlStorage {
43 fn load(&self) -> Result<Config> {
44 let contents = fs::read_to_string(&self.path).context("Failed to read config file")?;
45 let config: Config = toml::from_str(&contents).context("Failed to parse config file")?;
46
47 for warning in config.validate() {
48 eprintln!("[bookmarks] warning: {warning}");
49 }
50
51 Ok(config)
52 }
53
54 fn save(&self, config: &Config) -> Result<()> {
55 let contents = toml::to_string(config).context("Failed to serialize config")?;
56 fs::write(&self.path, contents).context("Failed to write config file")?;
57 Ok(())
58 }
59
60 fn init(&self) -> Result<()> {
61 if !self.path.exists() {
62 let config_dir = self
63 .path
64 .parent()
65 .context("Invalid config path: no parent directory")?;
66 fs::create_dir_all(config_dir).context("Failed to create config directory")?;
67 fs::write(&self.path, DEFAULT_CONFIG).context("Failed to write default config")?;
68 }
69 Ok(())
70 }
71
72 fn backend_name(&self) -> &str {
73 "toml"
74 }
75
76 fn path(&self) -> Option<&Path> {
77 Some(&self.path)
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84 use std::io::Write;
85
86 #[test]
87 fn test_default_path() {
88 let path = TomlStorage::default_path().unwrap();
89 assert!(path.ends_with(".config/bookmarks/bookmarks.toml"));
90 }
91
92 #[test]
93 fn test_load_save_roundtrip() {
94 let dir = tempfile::tempdir().unwrap();
95 let path = dir.path().join("bookmarks.toml");
96
97 let storage = TomlStorage::new(path.clone());
98
99 let mut f = fs::File::create(&path).unwrap();
101 writeln!(
102 f,
103 r#"[urls]
104github = {{ url = "https://github.com", aliases = ["gh"] }}
105rust = "https://rust-lang.org"
106
107[groups]
108dev = ["gh"]
109"#
110 )
111 .unwrap();
112
113 let config = storage.load().unwrap();
114 assert_eq!(config.urls.get("github").unwrap().aliases(), &["gh"]);
115 assert_eq!(
116 config.urls.get("rust").unwrap().url(),
117 "https://rust-lang.org"
118 );
119
120 storage.save(&config).unwrap();
122 let reloaded = storage.load().unwrap();
123 assert_eq!(config.urls.len(), reloaded.urls.len());
124 assert_eq!(config.groups, reloaded.groups);
125 }
126
127 #[test]
128 fn test_init_creates_default_config() {
129 let dir = tempfile::tempdir().unwrap();
130 let path = dir.path().join("sub").join("bookmarks.toml");
131
132 let storage = TomlStorage::new(path.clone());
133 storage.init().unwrap();
134
135 assert!(path.exists());
136 let config = storage.load().unwrap();
137 assert!(!config.urls.is_empty());
138 }
139
140 #[test]
141 fn test_init_does_not_overwrite() {
142 let dir = tempfile::tempdir().unwrap();
143 let path = dir.path().join("bookmarks.toml");
144
145 fs::write(&path, "[urls]\nrust = \"https://rust-lang.org\"\n").unwrap();
146
147 let storage = TomlStorage::new(path);
148 storage.init().unwrap();
149
150 let config = storage.load().unwrap();
151 assert_eq!(
152 config.urls.get("rust").unwrap().url(),
153 "https://rust-lang.org"
154 );
155 }
156
157 #[test]
158 fn test_backend_name() {
159 let storage = TomlStorage::new(PathBuf::from("/tmp/test.toml"));
160 assert_eq!(storage.backend_name(), "toml");
161 }
162}