1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::Path;
5
6const DEFAULT_EDITOR: &str = "vi";
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21#[serde(untagged)]
22pub enum UrlEntry {
23 Simple(String),
24 Full {
25 url: String,
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 aliases: Vec<String>,
28 },
29}
30
31impl UrlEntry {
32 pub fn url(&self) -> &str {
33 match self {
34 UrlEntry::Simple(url) => url,
35 UrlEntry::Full { url, .. } => url,
36 }
37 }
38
39 pub fn aliases(&self) -> &[String] {
40 match self {
41 UrlEntry::Simple(_) => &[],
42 UrlEntry::Full { aliases, .. } => aliases,
43 }
44 }
45
46 pub fn set_url(&mut self, new_url: String) {
47 match self {
48 UrlEntry::Simple(url) => *url = new_url,
49 UrlEntry::Full { url, .. } => *url = new_url,
50 }
51 }
52
53 pub fn add_alias(&mut self, alias: String) {
54 match self {
55 UrlEntry::Simple(url) => {
56 *self = UrlEntry::Full {
57 url: url.clone(),
58 aliases: vec![alias],
59 };
60 }
61 UrlEntry::Full { aliases, .. } => {
62 if !aliases.contains(&alias) {
63 aliases.push(alias);
64 }
65 }
66 }
67 }
68
69 pub fn remove_alias(&mut self, alias: &str) {
70 if let UrlEntry::Full { aliases, .. } = self {
71 aliases.retain(|a| a != alias);
72 }
73 }
74
75 pub fn has_alias(&self, alias: &str) -> bool {
76 self.aliases().iter().any(|a| a == alias)
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, Default)]
81pub struct Config {
82 #[serde(default)]
83 pub urls: HashMap<String, UrlEntry>,
84 #[serde(default)]
85 pub groups: HashMap<String, Vec<String>>,
86}
87
88pub const DEFAULT_CONFIG: &str = r#"# https://github.com/dkdc-io/bookmarks
89# bookmarks config file
90
91[urls]
92dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
93github = { url = "https://github.com", aliases = ["gh"] }
94
95[urls.linkedin]
96url = "https://linkedin.com"
97aliases = ["li"]
98
99[groups]
100socials = ["gh", "linkedin"]
101"#;
102
103impl Config {
104 fn alias_map(&self) -> HashMap<&str, &str> {
106 let mut map = HashMap::new();
107 for (name, entry) in &self.urls {
108 for alias in entry.aliases() {
109 map.insert(alias.as_str(), name.as_str());
110 }
111 }
112 map
113 }
114
115 pub fn resolve(&self, name: &str) -> Option<&str> {
117 if let Some(entry) = self.urls.get(name) {
119 return Some(entry.url());
120 }
121 for entry in self.urls.values() {
123 if entry.has_alias(name) {
124 return Some(entry.url());
125 }
126 }
127 None
128 }
129
130 pub fn contains(&self, name: &str) -> bool {
132 self.resolve(name).is_some()
133 }
134
135 pub fn validate(&self) -> Vec<String> {
136 let mut warnings = Vec::new();
137
138 let mut seen_aliases: HashMap<&str, &str> = HashMap::new();
140 for (url_name, entry) in &self.urls {
141 for alias in entry.aliases() {
142 if let Some(other) = seen_aliases.get(alias.as_str()) {
143 warnings.push(format!(
144 "alias '{alias}' is defined on both '{url_name}' and '{other}'"
145 ));
146 } else {
147 seen_aliases.insert(alias.as_str(), url_name.as_str());
148 }
149 if self.urls.contains_key(alias.as_str()) {
151 warnings.push(format!(
152 "alias '{alias}' on '{url_name}' shadows url name '{alias}'"
153 ));
154 }
155 }
156 }
157
158 for (group, entries) in &self.groups {
160 for entry in entries {
161 if !self.contains(entry) {
162 warnings.push(format!(
163 "group '{group}' contains '{entry}' which is not a url name or alias"
164 ));
165 }
166 }
167 }
168
169 warnings
170 }
171
172 pub fn rename_url(&mut self, old: &str, new: &str) -> Result<()> {
174 if old == new {
175 anyhow::ensure!(self.urls.contains_key(old), "url '{old}' not found");
176 return Ok(());
177 }
178 if self.urls.contains_key(new) {
179 anyhow::bail!("url '{new}' already exists");
180 }
181 let alias_map = self.alias_map();
183 if alias_map.contains_key(new) {
184 anyhow::bail!("'{new}' already exists as an alias");
185 }
186 let entry = self
187 .urls
188 .remove(old)
189 .with_context(|| format!("url '{old}' not found"))?;
190 self.urls.insert(new.to_string(), entry);
191
192 for entries in self.groups.values_mut() {
194 for e in entries.iter_mut() {
195 if e == old {
196 *e = new.to_string();
197 }
198 }
199 }
200
201 Ok(())
202 }
203
204 pub fn rename_alias(&mut self, old: &str, new: &str) -> Result<()> {
206 if old == new {
207 return Ok(());
208 }
209 if self.urls.contains_key(new) {
210 anyhow::bail!("'{new}' already exists as a url name");
211 }
212 let alias_map = self.alias_map();
213 if alias_map.contains_key(new) {
214 anyhow::bail!("alias '{new}' already exists");
215 }
216
217 let url_name = alias_map
219 .get(old)
220 .with_context(|| format!("alias '{old}' not found"))?
221 .to_string();
222
223 let entry = self.urls.get_mut(&url_name).unwrap();
224 entry.remove_alias(old);
225 entry.add_alias(new.to_string());
226
227 for entries in self.groups.values_mut() {
229 for e in entries.iter_mut() {
230 if e == old {
231 *e = new.to_string();
232 }
233 }
234 }
235
236 Ok(())
237 }
238
239 pub fn delete_url(&mut self, name: &str) -> Result<()> {
241 let entry = self
242 .urls
243 .remove(name)
244 .with_context(|| format!("url '{name}' not found"))?;
245 let mut to_remove: Vec<String> = vec![name.to_string()];
247 to_remove.extend(entry.aliases().iter().cloned());
248 for entries in self.groups.values_mut() {
249 entries.retain(|e| !to_remove.contains(e));
250 }
251 self.groups.retain(|_, entries| !entries.is_empty());
252 Ok(())
253 }
254
255 pub fn delete_alias(&mut self, alias: &str) -> Result<()> {
257 let alias_map = self.alias_map();
258 let url_name = alias_map
259 .get(alias)
260 .with_context(|| format!("alias '{alias}' not found"))?
261 .to_string();
262
263 self.urls.get_mut(&url_name).unwrap().remove_alias(alias);
264
265 for entries in self.groups.values_mut() {
266 entries.retain(|e| e != alias);
267 }
268 self.groups.retain(|_, entries| !entries.is_empty());
269 Ok(())
270 }
271
272 pub fn rename_group(&mut self, old: &str, new: &str) -> Result<()> {
274 if old != new && self.groups.contains_key(new) {
275 anyhow::bail!("group '{new}' already exists");
276 }
277 let entries = self
278 .groups
279 .remove(old)
280 .with_context(|| format!("group '{old}' not found"))?;
281 self.groups.insert(new.to_string(), entries);
282 Ok(())
283 }
284
285 pub fn delete_group(&mut self, name: &str) -> Result<()> {
287 self.groups
288 .remove(name)
289 .with_context(|| format!("group '{name}' not found"))?;
290 Ok(())
291 }
292}
293
294pub fn edit_config(config_path: &Path) -> Result<()> {
295 let editor = std::env::var("EDITOR").unwrap_or_else(|_| DEFAULT_EDITOR.to_string());
296
297 println!("Opening {} with {}...", config_path.display(), editor);
298
299 let status = std::process::Command::new(&editor)
300 .arg(config_path)
301 .status()
302 .with_context(|| format!("Editor {editor} not found in PATH"))?;
303
304 if !status.success() {
305 anyhow::bail!("Editor exited with non-zero status");
306 }
307
308 Ok(())
309}
310
311pub fn print_config(config: &Config) {
312 if !config.urls.is_empty() {
313 println!("urls:");
314 println!();
315
316 let mut entries: Vec<_> = config.urls.iter().collect();
317 entries.sort_unstable_by_key(|(k, _)| k.as_str());
318
319 let max_key_len = entries.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
320
321 for (name, entry) in &entries {
322 let aliases = entry.aliases();
323 if aliases.is_empty() {
324 println!("• {name:<max_key_len$} | {}", entry.url());
325 } else {
326 println!(
327 "• {name:<max_key_len$} | {} (aliases: {})",
328 entry.url(),
329 aliases.join(", ")
330 );
331 }
332 }
333
334 println!();
335 }
336
337 if !config.groups.is_empty() {
338 println!("groups:");
339 println!();
340
341 let mut entries: Vec<_> = config.groups.iter().collect();
342 entries.sort_unstable_by_key(|(k, _)| k.as_str());
343
344 let max_key_len = entries.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
345
346 for (name, group_entries) in &entries {
347 println!("• {name:<max_key_len$} | [{}]", group_entries.join(", "));
348 }
349
350 println!();
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn test_parse_valid_config() {
360 let toml = r#"
361[urls]
362github = { url = "https://github.com", aliases = ["gh"] }
363
364[groups]
365dev = ["gh"]
366"#;
367 let config: Config = toml::from_str(toml).unwrap();
368 let entry = config.urls.get("github").unwrap();
369 assert_eq!(entry.url(), "https://github.com");
370 assert_eq!(entry.aliases(), &["gh"]);
371 assert_eq!(config.groups.get("dev"), Some(&vec!["gh".to_string()]));
372 }
373
374 #[test]
375 fn test_parse_simple_url() {
376 let toml = r#"
377[urls]
378dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
379"#;
380 let config: Config = toml::from_str(toml).unwrap();
381 let entry = config.urls.get("dkdc-bookmarks").unwrap();
382 assert_eq!(entry.url(), "https://github.com/dkdc-io/bookmarks");
383 assert!(entry.aliases().is_empty());
384 }
385
386 #[test]
387 fn test_parse_expanded_table() {
388 let toml = r#"
389[urls.linkedin]
390url = "https://linkedin.com"
391aliases = ["li", "ln"]
392"#;
393 let config: Config = toml::from_str(toml).unwrap();
394 let entry = config.urls.get("linkedin").unwrap();
395 assert_eq!(entry.url(), "https://linkedin.com");
396 assert_eq!(entry.aliases(), &["li", "ln"]);
397 }
398
399 #[test]
400 fn test_parse_hybrid_config() {
401 let toml = r#"
402[urls]
403dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
404github = { url = "https://github.com", aliases = ["gh"] }
405
406[urls.linkedin]
407url = "https://linkedin.com"
408aliases = ["li"]
409
410[groups]
411socials = ["gh", "linkedin"]
412"#;
413 let config: Config = toml::from_str(toml).unwrap();
414 assert_eq!(config.urls.len(), 3);
415 assert_eq!(
416 config.urls.get("dkdc-bookmarks").unwrap().url(),
417 "https://github.com/dkdc-io/bookmarks"
418 );
419 assert_eq!(config.urls.get("github").unwrap().aliases(), &["gh"]);
420 assert_eq!(config.urls.get("linkedin").unwrap().aliases(), &["li"]);
421 assert!(config.validate().is_empty());
422 }
423
424 #[test]
425 fn test_parse_empty_config() {
426 let config: Config = toml::from_str("").unwrap();
427 assert!(config.urls.is_empty());
428 assert!(config.groups.is_empty());
429 }
430
431 #[test]
432 fn test_config_roundtrip() {
433 let mut config = Config::default();
434 config.urls.insert(
435 "example".to_string(),
436 UrlEntry::Full {
437 url: "https://example.com".to_string(),
438 aliases: vec!["ex".to_string()],
439 },
440 );
441 config
442 .groups
443 .insert("g".to_string(), vec!["ex".to_string()]);
444
445 let serialized = toml::to_string(&config).unwrap();
446 let deserialized: Config = toml::from_str(&serialized).unwrap();
447
448 assert_eq!(config.urls.len(), deserialized.urls.len());
449 assert_eq!(config.groups, deserialized.groups);
450 }
451
452 #[test]
453 fn test_default_config_parses() {
454 let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
455 assert!(!config.urls.is_empty());
456 assert!(!config.groups.is_empty());
457 }
458
459 #[test]
460 fn test_valid_config_has_no_warnings() {
461 let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
462 assert!(config.validate().is_empty());
463 }
464
465 #[test]
466 fn test_resolve_by_url_name() {
467 let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
468 assert_eq!(
469 config.resolve("dkdc-bookmarks"),
470 Some("https://github.com/dkdc-io/bookmarks")
471 );
472 }
473
474 #[test]
475 fn test_resolve_by_alias() {
476 let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
477 assert_eq!(config.resolve("gh"), Some("https://github.com"));
478 }
479
480 #[test]
481 fn test_resolve_unknown() {
482 let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
483 assert_eq!(config.resolve("nope"), None);
484 }
485
486 #[test]
487 fn test_duplicate_alias_warns() {
488 let toml = r#"
489[urls]
490a = { url = "https://a.com", aliases = ["x"] }
491b = { url = "https://b.com", aliases = ["x"] }
492"#;
493 let config: Config = toml::from_str(toml).unwrap();
494 let warnings = config.validate();
495 assert_eq!(warnings.len(), 1);
496 assert!(warnings[0].contains("x"));
497 }
498
499 #[test]
500 fn test_alias_shadows_url_name_warns() {
501 let toml = r#"
502[urls]
503github = { url = "https://github.com", aliases = ["dkdc-bookmarks"] }
504dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
505"#;
506 let config: Config = toml::from_str(toml).unwrap();
507 let warnings = config.validate();
508 assert!(!warnings.is_empty());
509 assert!(warnings.iter().any(|w| w.contains("shadows")));
510 }
511
512 #[test]
513 fn test_broken_group_entry_warns() {
514 let toml = r#"
515[urls]
516real = "https://example.com"
517
518[groups]
519dev = ["real", "ghost"]
520"#;
521 let config: Config = toml::from_str(toml).unwrap();
522 let warnings = config.validate();
523 assert_eq!(warnings.len(), 1);
524 assert!(warnings[0].contains("ghost"));
525 }
526
527 #[test]
528 fn test_rename_url_cascades_groups() {
529 let toml = r#"
530[urls]
531github = "https://github.com"
532
533[groups]
534dev = ["github"]
535"#;
536 let mut config: Config = toml::from_str(toml).unwrap();
537 config.rename_url("github", "gh-link").unwrap();
538 assert!(config.urls.contains_key("gh-link"));
539 assert!(!config.urls.contains_key("github"));
540 assert_eq!(config.groups.get("dev"), Some(&vec!["gh-link".to_string()]));
541 }
542
543 #[test]
544 fn test_rename_alias_cascades_groups() {
545 let toml = r#"
546[urls]
547github = { url = "https://github.com", aliases = ["gh"] }
548
549[groups]
550dev = ["gh"]
551all = ["gh", "other"]
552"#;
553 let mut config: Config = toml::from_str(toml).unwrap();
554 config.rename_alias("gh", "github-alias").unwrap();
555 let entry = config.urls.get("github").unwrap();
556 assert!(entry.has_alias("github-alias"));
557 assert!(!entry.has_alias("gh"));
558 assert_eq!(
559 config.groups.get("dev"),
560 Some(&vec!["github-alias".to_string()])
561 );
562 let all = config.groups.get("all").unwrap();
563 assert!(all.contains(&"github-alias".to_string()));
564 assert!(all.contains(&"other".to_string()));
565 }
566
567 #[test]
568 fn test_rename_nonexistent_url_errors() {
569 let mut config = Config::default();
570 assert!(config.rename_url("nope", "new").is_err());
571 }
572
573 #[test]
574 fn test_rename_nonexistent_alias_errors() {
575 let mut config = Config::default();
576 assert!(config.rename_alias("nope", "new").is_err());
577 }
578
579 #[test]
580 fn test_rename_url_collision_errors() {
581 let toml = r#"
582[urls]
583a = "https://a.com"
584b = "https://b.com"
585"#;
586 let mut config: Config = toml::from_str(toml).unwrap();
587 let result = config.rename_url("a", "b");
588 assert!(result.is_err());
589 assert!(result.unwrap_err().to_string().contains("already exists"));
590 assert!(config.urls.contains_key("a"));
591 assert!(config.urls.contains_key("b"));
592 }
593
594 #[test]
595 fn test_rename_alias_collision_errors() {
596 let toml = r#"
597[urls]
598a = { url = "https://a.com", aliases = ["x"] }
599b = { url = "https://b.com", aliases = ["y"] }
600"#;
601 let mut config: Config = toml::from_str(toml).unwrap();
602 let result = config.rename_alias("x", "y");
603 assert!(result.is_err());
604 assert!(result.unwrap_err().to_string().contains("already exists"));
605 }
606
607 #[test]
608 fn test_rename_url_to_existing_alias_errors() {
609 let toml = r#"
610[urls]
611github = { url = "https://github.com", aliases = ["gh"] }
612other = "https://other.com"
613"#;
614 let mut config: Config = toml::from_str(toml).unwrap();
615 let result = config.rename_url("other", "gh");
616 assert!(result.is_err());
617 assert!(
618 result
619 .unwrap_err()
620 .to_string()
621 .contains("already exists as an alias")
622 );
623 assert!(config.urls.contains_key("other"));
624 }
625
626 #[test]
627 fn test_rename_alias_to_existing_url_errors() {
628 let toml = r#"
629[urls]
630github = { url = "https://github.com", aliases = ["gh"] }
631other = "https://other.com"
632"#;
633 let mut config: Config = toml::from_str(toml).unwrap();
634 let result = config.rename_alias("gh", "other");
635 assert!(result.is_err());
636 assert!(
637 result
638 .unwrap_err()
639 .to_string()
640 .contains("already exists as a url name")
641 );
642 assert!(config.urls.get("github").unwrap().has_alias("gh"));
643 }
644
645 #[test]
646 fn test_rename_url_same_name_is_noop() {
647 let toml = r#"
648[urls]
649a = "https://a.com"
650"#;
651 let mut config: Config = toml::from_str(toml).unwrap();
652 config.rename_url("a", "a").unwrap();
653 assert_eq!(config.urls.get("a").unwrap().url(), "https://a.com");
654 }
655
656 #[test]
657 fn test_delete_url_cascades() {
658 let toml = r#"
659[urls]
660github = { url = "https://github.com", aliases = ["gh", "g"] }
661dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
662
663[groups]
664dev = ["gh", "github"]
665"#;
666 let mut config: Config = toml::from_str(toml).unwrap();
667 config.delete_url("github").unwrap();
668 assert!(!config.urls.contains_key("github"));
669 assert!(config.urls.contains_key("dkdc-bookmarks"));
670 assert!(!config.groups.contains_key("dev"));
672 }
673
674 #[test]
675 fn test_delete_url_partial_group_cleanup() {
676 let toml = r#"
677[urls]
678github = { url = "https://github.com", aliases = ["gh"] }
679dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
680
681[groups]
682dev = ["gh", "dkdc-bookmarks"]
683"#;
684 let mut config: Config = toml::from_str(toml).unwrap();
685 config.delete_url("github").unwrap();
686 let dev = config.groups.get("dev").unwrap();
687 assert_eq!(dev, &vec!["dkdc-bookmarks".to_string()]);
688 }
689
690 #[test]
691 fn test_delete_alias_cascades_to_groups() {
692 let toml = r#"
693[urls]
694github = { url = "https://github.com", aliases = ["gh"] }
695
696[groups]
697dev = ["gh"]
698"#;
699 let mut config: Config = toml::from_str(toml).unwrap();
700 config.delete_alias("gh").unwrap();
701 assert!(config.urls.get("github").unwrap().aliases().is_empty());
703 assert!(!config.groups.contains_key("dev"));
705 }
706
707 #[test]
708 fn test_delete_group() {
709 let toml = r#"
710[groups]
711dev = ["gh"]
712"#;
713 let mut config: Config = toml::from_str(toml).unwrap();
714 config.delete_group("dev").unwrap();
715 assert!(!config.groups.contains_key("dev"));
716 }
717
718 #[test]
719 fn test_rename_group_collision_errors() {
720 let toml = r#"
721[groups]
722a = ["x"]
723b = ["y"]
724"#;
725 let mut config: Config = toml::from_str(toml).unwrap();
726 let result = config.rename_group("a", "b");
727 assert!(result.is_err());
728 assert!(result.unwrap_err().to_string().contains("already exists"));
729 assert!(config.groups.contains_key("a"));
730 assert!(config.groups.contains_key("b"));
731 }
732
733 #[test]
734 fn test_rename_group_cascades() {
735 let toml = r#"
736[groups]
737dev = ["gh", "dkdc-bookmarks"]
738"#;
739 let mut config: Config = toml::from_str(toml).unwrap();
740 config.rename_group("dev", "development").unwrap();
741 assert!(!config.groups.contains_key("dev"));
742 assert_eq!(
743 config.groups.get("development"),
744 Some(&vec!["gh".to_string(), "dkdc-bookmarks".to_string()])
745 );
746 }
747
748 #[test]
749 fn test_delete_nonexistent_errors() {
750 let mut config = Config::default();
751 assert!(config.delete_url("nope").is_err());
752 assert!(config.delete_alias("nope").is_err());
753 assert!(config.delete_group("nope").is_err());
754 }
755
756 #[test]
757 fn test_parse_malformed_toml() {
758 assert!(toml::from_str::<Config>("this is not valid { toml").is_err());
759 }
760
761 #[test]
762 fn test_parse_url_wrong_type() {
763 let toml = "[urls]\ngithub = 42";
764 assert!(toml::from_str::<Config>(toml).is_err());
765 }
766
767 #[test]
768 fn test_parse_missing_url_in_full_entry() {
769 let toml = "[urls.gh]\naliases = [\"x\"]";
770 assert!(toml::from_str::<Config>(toml).is_err());
771 }
772
773 #[test]
774 fn test_parse_groups_only_no_urls() {
775 let toml = "[groups]\ndev = [\"gh\"]";
776 let config: Config = toml::from_str(toml).unwrap();
777 assert!(config.urls.is_empty());
778 let warnings = config.validate();
779 assert!(warnings.iter().any(|w| w.contains("gh")));
780 }
781
782 #[test]
783 fn test_parse_extra_sections_ignored() {
784 let toml = "[urls]\ngithub = \"https://github.com\"\n\n[metadata]\nauthor = \"test\"";
785 let result = toml::from_str::<Config>(toml);
787 assert!(result.is_ok());
788 }
789}