Skip to main content

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