Skip to main content

bookmarks_core/
toml_storage.rs

1use 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    /// Default config path: ~/.config/bookmarks/bookmarks.toml
22    pub fn default_path() -> Result<PathBuf> {
23        // Intentionally use ~/.config/ rather than dirs::config_dir(), which
24        // returns ~/Library/Application Support/ on macOS. We want a single
25        // consistent dotfile location across platforms.
26        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    /// Local config path: ./bookmarks.toml in the current working directory.
31    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        // Write a config manually
102        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        // Save and reload
123        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}