Skip to main content

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