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 let temp = self.path.with_extension("toml.tmp");
57 fs::write(&temp, contents).context("Failed to write temp config file")?;
58 fs::rename(&temp, &self.path).context("Failed to rename temp config file")?;
59 Ok(())
60 }
61
62 fn init(&self) -> Result<()> {
63 if !self.path.exists() {
64 let config_dir = self
65 .path
66 .parent()
67 .context("Invalid config path: no parent directory")?;
68 fs::create_dir_all(config_dir).context("Failed to create config directory")?;
69 fs::write(&self.path, DEFAULT_CONFIG).context("Failed to write default config")?;
70 }
71 Ok(())
72 }
73
74 fn backend_name(&self) -> &str {
75 "toml"
76 }
77
78 fn path(&self) -> Option<&Path> {
79 Some(&self.path)
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use std::io::Write;
87
88 #[test]
89 fn test_default_path() {
90 let path = TomlStorage::default_path().unwrap();
91 assert!(path.ends_with(".config/bookmarks/bookmarks.toml"));
92 }
93
94 #[test]
95 fn test_load_save_roundtrip() {
96 let dir = tempfile::tempdir().unwrap();
97 let path = dir.path().join("bookmarks.toml");
98
99 let storage = TomlStorage::new(path.clone());
100
101 let mut f = fs::File::create(&path).unwrap();
103 writeln!(
104 f,
105 r#"[urls]
106github = {{ url = "https://github.com", aliases = ["gh"] }}
107dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
108
109[groups]
110dev = ["gh"]
111"#
112 )
113 .unwrap();
114
115 let config = storage.load().unwrap();
116 assert_eq!(config.urls.get("github").unwrap().aliases(), &["gh"]);
117 assert_eq!(
118 config.urls.get("dkdc-bookmarks").unwrap().url(),
119 "https://github.com/dkdc-io/bookmarks"
120 );
121
122 storage.save(&config).unwrap();
124 let reloaded = storage.load().unwrap();
125 assert_eq!(config.urls.len(), reloaded.urls.len());
126 assert_eq!(config.groups, reloaded.groups);
127 }
128
129 #[test]
130 fn test_init_creates_default_config() {
131 let dir = tempfile::tempdir().unwrap();
132 let path = dir.path().join("sub").join("bookmarks.toml");
133
134 let storage = TomlStorage::new(path.clone());
135 storage.init().unwrap();
136
137 assert!(path.exists());
138 let config = storage.load().unwrap();
139 assert!(!config.urls.is_empty());
140 }
141
142 #[test]
143 fn test_init_does_not_overwrite() {
144 let dir = tempfile::tempdir().unwrap();
145 let path = dir.path().join("bookmarks.toml");
146
147 fs::write(
148 &path,
149 "[urls]\ndkdc-bookmarks = \"https://github.com/dkdc-io/bookmarks\"\n",
150 )
151 .unwrap();
152
153 let storage = TomlStorage::new(path);
154 storage.init().unwrap();
155
156 let config = storage.load().unwrap();
157 assert_eq!(
158 config.urls.get("dkdc-bookmarks").unwrap().url(),
159 "https://github.com/dkdc-io/bookmarks"
160 );
161 }
162
163 #[test]
164 fn test_backend_name() {
165 let storage = TomlStorage::new(PathBuf::from("/tmp/test.toml"));
166 assert_eq!(storage.backend_name(), "toml");
167 }
168
169 #[test]
170 fn test_load_nonexistent_file() {
171 let storage = TomlStorage::new(PathBuf::from("/nonexistent/path/bookmarks.toml"));
172 assert!(storage.load().is_err());
173 }
174
175 #[test]
176 fn test_load_malformed_file() {
177 let dir = tempfile::tempdir().unwrap();
178 let path = dir.path().join("bookmarks.toml");
179 fs::write(&path, "this is not valid { toml").unwrap();
180 let storage = TomlStorage::new(path);
181 assert!(storage.load().is_err());
182 }
183
184 #[test]
185 fn test_load_empty_file() {
186 let dir = tempfile::tempdir().unwrap();
187 let path = dir.path().join("bookmarks.toml");
188 fs::write(&path, "").unwrap();
189 let storage = TomlStorage::new(path);
190 let config = storage.load().unwrap();
191 assert!(config.urls.is_empty());
192 assert!(config.groups.is_empty());
193 }
194}