Skip to main content

bookmarks/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::path::Path;
6
7const DEFAULT_EDITOR: &str = "vi";
8
9#[derive(Debug, Serialize, Deserialize, Default)]
10pub struct Config {
11    #[serde(default)]
12    pub aliases: HashMap<String, String>,
13    #[serde(default)]
14    pub links: HashMap<String, String>,
15    #[serde(default)]
16    pub groups: HashMap<String, Vec<String>>,
17}
18
19pub const DEFAULT_CONFIG: &str = r#"# bookmarks config file
20[aliases]
21gh = "github"
22li = "linkedin"
23[links]
24github = "https://github.com"
25linkedin = "https://linkedin.com"
26[groups]
27socials = ["gh", "linkedin"]
28"#;
29
30impl Config {
31    pub fn validate(&self) -> Vec<String> {
32        let mut warnings = Vec::new();
33
34        for (alias, target) in &self.aliases {
35            if !self.links.contains_key(target) {
36                warnings.push(format!(
37                    "alias '{alias}' points to '{target}' which is not in [links]"
38                ));
39            }
40        }
41
42        for (group, entries) in &self.groups {
43            for entry in entries {
44                if !self.aliases.contains_key(entry) && !self.links.contains_key(entry) {
45                    warnings.push(format!(
46                        "group '{group}' contains '{entry}' which is not in [aliases] or [links]"
47                    ));
48                }
49            }
50        }
51
52        warnings
53    }
54
55    /// Rename a link key and cascade to all aliases that target it.
56    pub fn rename_link(&mut self, old: &str, new: &str) -> Result<()> {
57        let url = self
58            .links
59            .remove(old)
60            .with_context(|| format!("link '{old}' not found"))?;
61        self.links.insert(new.to_string(), url);
62
63        // Update aliases that point to the old name
64        for target in self.aliases.values_mut() {
65            if target == old {
66                *target = new.to_string();
67            }
68        }
69
70        Ok(())
71    }
72
73    /// Rename an alias key and cascade to all groups that reference it.
74    pub fn rename_alias(&mut self, old: &str, new: &str) -> Result<()> {
75        let target = self
76            .aliases
77            .remove(old)
78            .with_context(|| format!("alias '{old}' not found"))?;
79        self.aliases.insert(new.to_string(), target);
80
81        // Update group entries that reference the old name
82        for entries in self.groups.values_mut() {
83            for entry in entries.iter_mut() {
84                if entry == old {
85                    *entry = new.to_string();
86                }
87            }
88        }
89
90        Ok(())
91    }
92}
93
94pub fn edit_config(config_path: &Path) -> Result<()> {
95    let editor = std::env::var("EDITOR").unwrap_or_else(|_| DEFAULT_EDITOR.to_string());
96
97    println!("Opening {} with {}...", config_path.display(), editor);
98
99    let status = std::process::Command::new(&editor)
100        .arg(config_path)
101        .status()
102        .with_context(|| format!("Editor {editor} not found in PATH"))?;
103
104    if !status.success() {
105        anyhow::bail!("Editor exited with non-zero status");
106    }
107
108    Ok(())
109}
110
111fn print_section<V>(
112    name: &str,
113    map: &HashMap<String, V>,
114    format_value: impl Fn(&V) -> Cow<'_, str>,
115) {
116    if map.is_empty() {
117        return;
118    }
119
120    println!("{name}:");
121    println!();
122
123    let mut entries: Vec<_> = map.iter().collect();
124    entries.sort_unstable_by_key(|(k, _)| k.as_str());
125
126    let max_key_len = entries.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
127
128    for (key, value) in entries {
129        println!("• {key:<max_key_len$} | {}", format_value(value));
130    }
131
132    println!();
133}
134
135pub fn print_config(config: &Config) {
136    print_section("aliases", &config.aliases, |v| Cow::Borrowed(v));
137    print_section("links", &config.links, |v| Cow::Borrowed(v));
138    print_section("groups", &config.groups, |v| {
139        Cow::Owned(format!("[{}]", v.join(", ")))
140    });
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_parse_valid_config() {
149        let toml = r#"
150[aliases]
151gh = "github"
152
153[links]
154github = "https://github.com"
155
156[groups]
157dev = ["gh"]
158"#;
159        let config: Config = toml::from_str(toml).unwrap();
160        assert_eq!(config.aliases.get("gh"), Some(&"github".to_string()));
161        assert_eq!(
162            config.links.get("github"),
163            Some(&"https://github.com".to_string())
164        );
165        assert_eq!(config.groups.get("dev"), Some(&vec!["gh".to_string()]));
166    }
167
168    #[test]
169    fn test_parse_empty_config() {
170        let toml = "";
171        let config: Config = toml::from_str(toml).unwrap();
172        assert!(config.aliases.is_empty());
173        assert!(config.links.is_empty());
174        assert!(config.groups.is_empty());
175    }
176
177    #[test]
178    fn test_parse_partial_config() {
179        let toml = r#"
180[links]
181rust = "https://rust-lang.org"
182"#;
183        let config: Config = toml::from_str(toml).unwrap();
184        assert!(config.aliases.is_empty());
185        assert_eq!(
186            config.links.get("rust"),
187            Some(&"https://rust-lang.org".to_string())
188        );
189        assert!(config.groups.is_empty());
190    }
191
192    #[test]
193    fn test_config_roundtrip() {
194        let mut config = Config::default();
195        config.aliases.insert("a".to_string(), "b".to_string());
196        config
197            .links
198            .insert("b".to_string(), "https://example.com".to_string());
199        config.groups.insert("g".to_string(), vec!["a".to_string()]);
200
201        let serialized = toml::to_string(&config).unwrap();
202        let deserialized: Config = toml::from_str(&serialized).unwrap();
203
204        assert_eq!(config.aliases, deserialized.aliases);
205        assert_eq!(config.links, deserialized.links);
206        assert_eq!(config.groups, deserialized.groups);
207    }
208
209    #[test]
210    fn test_default_config_parses() {
211        let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
212        assert!(!config.aliases.is_empty());
213        assert!(!config.links.is_empty());
214        assert!(!config.groups.is_empty());
215    }
216
217    #[test]
218    fn test_valid_config_has_no_warnings() {
219        let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
220        assert!(config.validate().is_empty());
221    }
222
223    #[test]
224    fn test_broken_alias_target_warns() {
225        let toml = r#"
226[aliases]
227broken = "nonexistent"
228
229[links]
230real = "https://example.com"
231"#;
232        let config: Config = toml::from_str(toml).unwrap();
233        let warnings = config.validate();
234        assert_eq!(warnings.len(), 1);
235        assert!(warnings[0].contains("nonexistent"));
236    }
237
238    #[test]
239    fn test_rename_link_cascades_aliases() {
240        let toml = r#"
241[aliases]
242gh = "github"
243g = "github"
244
245[links]
246github = "https://github.com"
247"#;
248        let mut config: Config = toml::from_str(toml).unwrap();
249        config.rename_link("github", "gh-link").unwrap();
250        assert!(config.links.contains_key("gh-link"));
251        assert!(!config.links.contains_key("github"));
252        assert_eq!(config.aliases.get("gh"), Some(&"gh-link".to_string()));
253        assert_eq!(config.aliases.get("g"), Some(&"gh-link".to_string()));
254    }
255
256    #[test]
257    fn test_rename_alias_cascades_groups() {
258        let toml = r#"
259[aliases]
260gh = "github"
261
262[links]
263github = "https://github.com"
264
265[groups]
266dev = ["gh"]
267all = ["gh", "other"]
268"#;
269        let mut config: Config = toml::from_str(toml).unwrap();
270        config.rename_alias("gh", "github-alias").unwrap();
271        assert!(config.aliases.contains_key("github-alias"));
272        assert!(!config.aliases.contains_key("gh"));
273        assert_eq!(
274            config.groups.get("dev"),
275            Some(&vec!["github-alias".to_string()])
276        );
277        let all = config.groups.get("all").unwrap();
278        assert!(all.contains(&"github-alias".to_string()));
279        assert!(all.contains(&"other".to_string()));
280    }
281
282    #[test]
283    fn test_rename_nonexistent_link_errors() {
284        let mut config = Config::default();
285        assert!(config.rename_link("nope", "new").is_err());
286    }
287
288    #[test]
289    fn test_rename_nonexistent_alias_errors() {
290        let mut config = Config::default();
291        assert!(config.rename_alias("nope", "new").is_err());
292    }
293
294    #[test]
295    fn test_broken_group_entry_warns() {
296        let toml = r#"
297[links]
298real = "https://example.com"
299
300[groups]
301dev = ["real", "ghost"]
302"#;
303        let config: Config = toml::from_str(toml).unwrap();
304        let warnings = config.validate();
305        assert_eq!(warnings.len(), 1);
306        assert!(warnings[0].contains("ghost"));
307    }
308}