1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
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 pub fn set_aliases(&mut self, new_aliases: Vec<String>) {
80 match self {
81 UrlEntry::Simple(url) => {
82 if !new_aliases.is_empty() {
83 *self = UrlEntry::Full {
84 url: url.clone(),
85 aliases: new_aliases,
86 };
87 }
88 }
89 UrlEntry::Full { url, aliases } => {
90 if new_aliases.is_empty() {
91 *self = UrlEntry::Simple(url.clone());
92 } else {
93 *aliases = new_aliases;
94 }
95 }
96 }
97 }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, Default)]
101pub struct Config {
102 #[serde(default)]
103 pub urls: HashMap<String, UrlEntry>,
104 #[serde(default)]
105 pub groups: HashMap<String, Vec<String>>,
106}
107
108pub const DEFAULT_CONFIG: &str = r#"# https://github.com/dkdc-io/bookmarks
109# bookmarks config file
110
111[urls]
112dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
113github = { url = "https://github.com", aliases = ["gh"] }
114
115[urls.linkedin]
116url = "https://linkedin.com"
117aliases = ["li"]
118
119[groups]
120socials = ["gh", "linkedin"]
121"#;
122
123impl Config {
124 fn alias_map(&self) -> HashMap<&str, &str> {
126 let mut map = HashMap::new();
127 for (name, entry) in &self.urls {
128 for alias in entry.aliases() {
129 map.insert(alias.as_str(), name.as_str());
130 }
131 }
132 map
133 }
134
135 pub fn resolve(&self, name: &str) -> Option<&str> {
137 if let Some(entry) = self.urls.get(name) {
139 return Some(entry.url());
140 }
141 for entry in self.urls.values() {
143 if entry.has_alias(name) {
144 return Some(entry.url());
145 }
146 }
147 None
148 }
149
150 pub fn contains(&self, name: &str) -> bool {
152 self.resolve(name).is_some()
153 }
154
155 pub fn parse_list(raw: &str) -> Vec<String> {
156 raw.split(',')
157 .map(|s| s.trim().to_string())
158 .filter(|s| !s.is_empty())
159 .collect()
160 }
161
162 pub fn upsert_url(&mut self, name: String, url: String) {
163 self.urls.insert(name, UrlEntry::Simple(url));
164 }
165
166 pub fn set_url_value(&mut self, name: &str, url: String) -> Result<()> {
167 let entry = self
168 .urls
169 .get_mut(name)
170 .with_context(|| format!("url '{name}' not found"))?;
171 entry.set_url(url);
172 Ok(())
173 }
174
175 pub fn set_url_aliases(&mut self, name: &str, aliases: Vec<String>) -> Result<()> {
176 let entry = self
177 .urls
178 .get_mut(name)
179 .with_context(|| format!("url '{name}' not found"))?;
180 entry.set_aliases(aliases);
181 Ok(())
182 }
183
184 pub fn validate_group_entries(&self, entries: &[String]) -> Result<()> {
185 let missing: Vec<&str> = entries
186 .iter()
187 .filter(|e| !self.contains(e))
188 .map(String::as_str)
189 .collect();
190 if !missing.is_empty() {
191 anyhow::bail!(crate::strings::err_group_entries_missing(&missing));
192 }
193 Ok(())
194 }
195
196 pub fn upsert_group(&mut self, name: String, entries: Vec<String>) -> Result<()> {
197 self.validate_group_entries(&entries)?;
198 self.groups.insert(name, entries);
199 Ok(())
200 }
201
202 pub fn set_group_entries(&mut self, name: &str, entries: Vec<String>) -> Result<()> {
203 self.validate_group_entries(&entries)?;
204 let existing = self
205 .groups
206 .get_mut(name)
207 .with_context(|| format!("group '{name}' not found"))?;
208 *existing = entries;
209 Ok(())
210 }
211
212 pub fn validate(&self) -> Vec<String> {
213 let mut warnings = Vec::new();
214
215 let mut seen_aliases: HashMap<&str, &str> = HashMap::new();
217 for (url_name, entry) in &self.urls {
218 for alias in entry.aliases() {
219 if let Some(other) = seen_aliases.get(alias.as_str()) {
220 warnings.push(format!(
221 "alias '{alias}' is defined on both '{url_name}' and '{other}'"
222 ));
223 } else {
224 seen_aliases.insert(alias.as_str(), url_name.as_str());
225 }
226 if self.urls.contains_key(alias.as_str()) {
228 warnings.push(format!(
229 "alias '{alias}' on '{url_name}' shadows url name '{alias}'"
230 ));
231 }
232 }
233 }
234
235 for (url_name, entry) in &self.urls {
237 if entry.url().is_empty() {
238 warnings.push(format!("url '{url_name}' has an empty URL string"));
239 }
240 }
241
242 for (group, entries) in &self.groups {
244 for entry in entries {
245 if !self.contains(entry) {
246 warnings.push(format!(
247 "group '{group}' contains '{entry}' which is not a url name or alias"
248 ));
249 }
250 }
251 }
252
253 for (group, entries) in &self.groups {
259 if entries.iter().any(|e| e == group) {
260 warnings.push(format!(
261 "group '{group}' references itself, which is likely a mistake"
262 ));
263 }
264 }
265
266 let group_names: HashSet<&str> = self.groups.keys().map(|k| k.as_str()).collect();
270 let adj: HashMap<&str, Vec<&str>> = self
271 .groups
272 .iter()
273 .map(|(g, entries)| {
274 let refs: Vec<&str> = entries
275 .iter()
276 .filter(|e| group_names.contains(e.as_str()) && e.as_str() != g.as_str())
277 .map(|e| e.as_str())
278 .collect();
279 (g.as_str(), refs)
280 })
281 .collect();
282
283 let mut visited: HashSet<&str> = HashSet::new();
285 let mut on_stack: HashSet<&str> = HashSet::new();
286
287 fn dfs<'a>(
288 node: &'a str,
289 adj: &HashMap<&'a str, Vec<&'a str>>,
290 visited: &mut HashSet<&'a str>,
291 on_stack: &mut HashSet<&'a str>,
292 warnings: &mut Vec<String>,
293 ) {
294 visited.insert(node);
295 on_stack.insert(node);
296 if let Some(neighbors) = adj.get(node) {
297 for &next in neighbors {
298 if on_stack.contains(next) {
299 warnings.push(format!(
300 "group '{node}' and group '{next}' form a circular reference"
301 ));
302 } else if !visited.contains(next) {
303 dfs(next, adj, visited, on_stack, warnings);
304 }
305 }
306 }
307 on_stack.remove(node);
308 }
309
310 for &group in &group_names {
311 if !visited.contains(group) {
312 dfs(group, &adj, &mut visited, &mut on_stack, &mut warnings);
313 }
314 }
315
316 warnings
317 }
318
319 pub fn rename_url(&mut self, old: &str, new: &str) -> Result<()> {
321 if old == new {
322 anyhow::ensure!(self.urls.contains_key(old), "url '{old}' not found");
323 return Ok(());
324 }
325 if self.urls.contains_key(new) {
326 anyhow::bail!("url '{new}' already exists");
327 }
328 let alias_map = self.alias_map();
330 if alias_map.contains_key(new) {
331 anyhow::bail!("'{new}' already exists as an alias");
332 }
333 let entry = self
334 .urls
335 .remove(old)
336 .with_context(|| format!("url '{old}' not found"))?;
337 self.urls.insert(new.to_string(), entry);
338
339 for entries in self.groups.values_mut() {
341 for e in entries.iter_mut() {
342 if e == old {
343 *e = new.to_string();
344 }
345 }
346 }
347
348 Ok(())
349 }
350
351 pub fn rename_alias(&mut self, old: &str, new: &str) -> Result<()> {
353 if old == new {
354 return Ok(());
355 }
356 if self.urls.contains_key(new) {
357 anyhow::bail!("'{new}' already exists as a url name");
358 }
359 let alias_map = self.alias_map();
360 if alias_map.contains_key(new) {
361 anyhow::bail!("alias '{new}' already exists");
362 }
363
364 let url_name = alias_map
366 .get(old)
367 .with_context(|| format!("alias '{old}' not found"))?
368 .to_string();
369
370 let entry = self
371 .urls
372 .get_mut(&url_name)
373 .context("internal error: alias owner not found in urls")?;
374 entry.remove_alias(old);
375 entry.add_alias(new.to_string());
376
377 for entries in self.groups.values_mut() {
379 for e in entries.iter_mut() {
380 if e == old {
381 *e = new.to_string();
382 }
383 }
384 }
385
386 Ok(())
387 }
388
389 pub fn delete_url(&mut self, name: &str) -> Result<()> {
391 let entry = self
392 .urls
393 .remove(name)
394 .with_context(|| format!("url '{name}' not found"))?;
395 let mut to_remove: Vec<String> = vec![name.to_string()];
397 to_remove.extend(entry.aliases().iter().cloned());
398 for entries in self.groups.values_mut() {
399 entries.retain(|e| !to_remove.contains(e));
400 }
401 self.groups.retain(|_, entries| !entries.is_empty());
402 Ok(())
403 }
404
405 pub fn delete_alias(&mut self, alias: &str) -> Result<()> {
407 let alias_map = self.alias_map();
408 let url_name = alias_map
409 .get(alias)
410 .with_context(|| format!("alias '{alias}' not found"))?
411 .to_string();
412
413 self.urls
414 .get_mut(&url_name)
415 .context("internal error: alias owner not found in urls")?
416 .remove_alias(alias);
417
418 for entries in self.groups.values_mut() {
419 entries.retain(|e| e != alias);
420 }
421 self.groups.retain(|_, entries| !entries.is_empty());
422 Ok(())
423 }
424
425 pub fn rename_group(&mut self, old: &str, new: &str) -> Result<()> {
427 if old != new && self.groups.contains_key(new) {
428 anyhow::bail!("group '{new}' already exists");
429 }
430 let entries = self
431 .groups
432 .remove(old)
433 .with_context(|| format!("group '{old}' not found"))?;
434 self.groups.insert(new.to_string(), entries);
435 Ok(())
436 }
437
438 pub fn delete_group(&mut self, name: &str) -> Result<()> {
440 self.groups
441 .remove(name)
442 .with_context(|| format!("group '{name}' not found"))?;
443 Ok(())
444 }
445}
446
447pub fn edit_config(config_path: &Path) -> Result<()> {
448 let editor = std::env::var("EDITOR").unwrap_or_else(|_| DEFAULT_EDITOR.to_string());
449
450 println!("Opening {} with {}...", config_path.display(), editor);
451
452 let status = std::process::Command::new(&editor)
453 .arg(config_path)
454 .status()
455 .with_context(|| format!("Editor {editor} not found in PATH"))?;
456
457 if !status.success() {
458 anyhow::bail!("Editor exited with non-zero status");
459 }
460
461 Ok(())
462}
463
464pub fn print_config(config: &Config) {
465 if !config.urls.is_empty() {
466 println!("urls:");
467 println!();
468
469 let mut entries: Vec<_> = config.urls.iter().collect();
470 entries.sort_unstable_by_key(|(k, _)| k.as_str());
471
472 let max_key_len = entries.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
473
474 for (name, entry) in &entries {
475 let aliases = entry.aliases();
476 if aliases.is_empty() {
477 println!("• {name:<max_key_len$} | {}", entry.url());
478 } else {
479 println!(
480 "• {name:<max_key_len$} | {} (aliases: {})",
481 entry.url(),
482 aliases.join(", ")
483 );
484 }
485 }
486
487 println!();
488 }
489
490 if !config.groups.is_empty() {
491 println!("groups:");
492 println!();
493
494 let mut entries: Vec<_> = config.groups.iter().collect();
495 entries.sort_unstable_by_key(|(k, _)| k.as_str());
496
497 let max_key_len = entries.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
498
499 for (name, group_entries) in &entries {
500 println!("• {name:<max_key_len$} | [{}]", group_entries.join(", "));
501 }
502
503 println!();
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn test_parse_valid_config() {
513 let toml = r#"
514[urls]
515github = { url = "https://github.com", aliases = ["gh"] }
516
517[groups]
518dev = ["gh"]
519"#;
520 let config: Config = toml::from_str(toml).unwrap();
521 let entry = config.urls.get("github").unwrap();
522 assert_eq!(entry.url(), "https://github.com");
523 assert_eq!(entry.aliases(), &["gh"]);
524 assert_eq!(config.groups.get("dev"), Some(&vec!["gh".to_string()]));
525 }
526
527 #[test]
528 fn test_parse_simple_url() {
529 let toml = r#"
530[urls]
531dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
532"#;
533 let config: Config = toml::from_str(toml).unwrap();
534 let entry = config.urls.get("dkdc-bookmarks").unwrap();
535 assert_eq!(entry.url(), "https://github.com/dkdc-io/bookmarks");
536 assert!(entry.aliases().is_empty());
537 }
538
539 #[test]
540 fn test_parse_expanded_table() {
541 let toml = r#"
542[urls.linkedin]
543url = "https://linkedin.com"
544aliases = ["li", "ln"]
545"#;
546 let config: Config = toml::from_str(toml).unwrap();
547 let entry = config.urls.get("linkedin").unwrap();
548 assert_eq!(entry.url(), "https://linkedin.com");
549 assert_eq!(entry.aliases(), &["li", "ln"]);
550 }
551
552 #[test]
553 fn test_parse_hybrid_config() {
554 let toml = r#"
555[urls]
556dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
557github = { url = "https://github.com", aliases = ["gh"] }
558
559[urls.linkedin]
560url = "https://linkedin.com"
561aliases = ["li"]
562
563[groups]
564socials = ["gh", "linkedin"]
565"#;
566 let config: Config = toml::from_str(toml).unwrap();
567 assert_eq!(config.urls.len(), 3);
568 assert_eq!(
569 config.urls.get("dkdc-bookmarks").unwrap().url(),
570 "https://github.com/dkdc-io/bookmarks"
571 );
572 assert_eq!(config.urls.get("github").unwrap().aliases(), &["gh"]);
573 assert_eq!(config.urls.get("linkedin").unwrap().aliases(), &["li"]);
574 assert!(config.validate().is_empty());
575 }
576
577 #[test]
578 fn test_parse_empty_config() {
579 let config: Config = toml::from_str("").unwrap();
580 assert!(config.urls.is_empty());
581 assert!(config.groups.is_empty());
582 }
583
584 #[test]
585 fn test_config_roundtrip() {
586 let mut config = Config::default();
587 config.urls.insert(
588 "example".to_string(),
589 UrlEntry::Full {
590 url: "https://example.com".to_string(),
591 aliases: vec!["ex".to_string()],
592 },
593 );
594 config
595 .groups
596 .insert("g".to_string(), vec!["ex".to_string()]);
597
598 let serialized = toml::to_string(&config).unwrap();
599 let deserialized: Config = toml::from_str(&serialized).unwrap();
600
601 assert_eq!(config.urls.len(), deserialized.urls.len());
602 assert_eq!(config.groups, deserialized.groups);
603 }
604
605 #[test]
606 fn test_default_config_parses() {
607 let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
608 assert!(!config.urls.is_empty());
609 assert!(!config.groups.is_empty());
610 }
611
612 #[test]
613 fn test_valid_config_has_no_warnings() {
614 let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
615 assert!(config.validate().is_empty());
616 }
617
618 #[test]
619 fn test_resolve_by_url_name() {
620 let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
621 assert_eq!(
622 config.resolve("dkdc-bookmarks"),
623 Some("https://github.com/dkdc-io/bookmarks")
624 );
625 }
626
627 #[test]
628 fn test_resolve_by_alias() {
629 let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
630 assert_eq!(config.resolve("gh"), Some("https://github.com"));
631 }
632
633 #[test]
634 fn test_resolve_unknown() {
635 let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
636 assert_eq!(config.resolve("nope"), None);
637 }
638
639 #[test]
640 fn test_duplicate_alias_warns() {
641 let toml = r#"
642[urls]
643a = { url = "https://a.com", aliases = ["x"] }
644b = { url = "https://b.com", aliases = ["x"] }
645"#;
646 let config: Config = toml::from_str(toml).unwrap();
647 let warnings = config.validate();
648 assert_eq!(warnings.len(), 1);
649 assert!(warnings[0].contains("x"));
650 }
651
652 #[test]
653 fn test_alias_shadows_url_name_warns() {
654 let toml = r#"
655[urls]
656github = { url = "https://github.com", aliases = ["dkdc-bookmarks"] }
657dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
658"#;
659 let config: Config = toml::from_str(toml).unwrap();
660 let warnings = config.validate();
661 assert!(!warnings.is_empty());
662 assert!(warnings.iter().any(|w| w.contains("shadows")));
663 }
664
665 #[test]
666 fn test_broken_group_entry_warns() {
667 let toml = r#"
668[urls]
669real = "https://example.com"
670
671[groups]
672dev = ["real", "ghost"]
673"#;
674 let config: Config = toml::from_str(toml).unwrap();
675 let warnings = config.validate();
676 assert_eq!(warnings.len(), 1);
677 assert!(warnings[0].contains("ghost"));
678 }
679
680 #[test]
681 fn test_rename_url_cascades_groups() {
682 let toml = r#"
683[urls]
684github = "https://github.com"
685
686[groups]
687dev = ["github"]
688"#;
689 let mut config: Config = toml::from_str(toml).unwrap();
690 config.rename_url("github", "gh-link").unwrap();
691 assert!(config.urls.contains_key("gh-link"));
692 assert!(!config.urls.contains_key("github"));
693 assert_eq!(config.groups.get("dev"), Some(&vec!["gh-link".to_string()]));
694 }
695
696 #[test]
697 fn test_rename_alias_cascades_groups() {
698 let toml = r#"
699[urls]
700github = { url = "https://github.com", aliases = ["gh"] }
701
702[groups]
703dev = ["gh"]
704all = ["gh", "other"]
705"#;
706 let mut config: Config = toml::from_str(toml).unwrap();
707 config.rename_alias("gh", "github-alias").unwrap();
708 let entry = config.urls.get("github").unwrap();
709 assert!(entry.has_alias("github-alias"));
710 assert!(!entry.has_alias("gh"));
711 assert_eq!(
712 config.groups.get("dev"),
713 Some(&vec!["github-alias".to_string()])
714 );
715 let all = config.groups.get("all").unwrap();
716 assert!(all.contains(&"github-alias".to_string()));
717 assert!(all.contains(&"other".to_string()));
718 }
719
720 #[test]
721 fn test_rename_nonexistent_url_errors() {
722 let mut config = Config::default();
723 assert!(config.rename_url("nope", "new").is_err());
724 }
725
726 #[test]
727 fn test_rename_nonexistent_alias_errors() {
728 let mut config = Config::default();
729 assert!(config.rename_alias("nope", "new").is_err());
730 }
731
732 #[test]
733 fn test_rename_url_collision_errors() {
734 let toml = r#"
735[urls]
736a = "https://a.com"
737b = "https://b.com"
738"#;
739 let mut config: Config = toml::from_str(toml).unwrap();
740 let result = config.rename_url("a", "b");
741 assert!(result.is_err());
742 assert!(result.unwrap_err().to_string().contains("already exists"));
743 assert!(config.urls.contains_key("a"));
744 assert!(config.urls.contains_key("b"));
745 }
746
747 #[test]
748 fn test_rename_alias_collision_errors() {
749 let toml = r#"
750[urls]
751a = { url = "https://a.com", aliases = ["x"] }
752b = { url = "https://b.com", aliases = ["y"] }
753"#;
754 let mut config: Config = toml::from_str(toml).unwrap();
755 let result = config.rename_alias("x", "y");
756 assert!(result.is_err());
757 assert!(result.unwrap_err().to_string().contains("already exists"));
758 }
759
760 #[test]
761 fn test_rename_url_to_existing_alias_errors() {
762 let toml = r#"
763[urls]
764github = { url = "https://github.com", aliases = ["gh"] }
765other = "https://other.com"
766"#;
767 let mut config: Config = toml::from_str(toml).unwrap();
768 let result = config.rename_url("other", "gh");
769 assert!(result.is_err());
770 assert!(
771 result
772 .unwrap_err()
773 .to_string()
774 .contains("already exists as an alias")
775 );
776 assert!(config.urls.contains_key("other"));
777 }
778
779 #[test]
780 fn test_rename_alias_to_existing_url_errors() {
781 let toml = r#"
782[urls]
783github = { url = "https://github.com", aliases = ["gh"] }
784other = "https://other.com"
785"#;
786 let mut config: Config = toml::from_str(toml).unwrap();
787 let result = config.rename_alias("gh", "other");
788 assert!(result.is_err());
789 assert!(
790 result
791 .unwrap_err()
792 .to_string()
793 .contains("already exists as a url name")
794 );
795 assert!(config.urls.get("github").unwrap().has_alias("gh"));
796 }
797
798 #[test]
799 fn test_rename_url_same_name_is_noop() {
800 let toml = r#"
801[urls]
802a = "https://a.com"
803"#;
804 let mut config: Config = toml::from_str(toml).unwrap();
805 config.rename_url("a", "a").unwrap();
806 assert_eq!(config.urls.get("a").unwrap().url(), "https://a.com");
807 }
808
809 #[test]
810 fn test_delete_url_cascades() {
811 let toml = r#"
812[urls]
813github = { url = "https://github.com", aliases = ["gh", "g"] }
814dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
815
816[groups]
817dev = ["gh", "github"]
818"#;
819 let mut config: Config = toml::from_str(toml).unwrap();
820 config.delete_url("github").unwrap();
821 assert!(!config.urls.contains_key("github"));
822 assert!(config.urls.contains_key("dkdc-bookmarks"));
823 assert!(!config.groups.contains_key("dev"));
825 }
826
827 #[test]
828 fn test_delete_url_partial_group_cleanup() {
829 let toml = r#"
830[urls]
831github = { url = "https://github.com", aliases = ["gh"] }
832dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
833
834[groups]
835dev = ["gh", "dkdc-bookmarks"]
836"#;
837 let mut config: Config = toml::from_str(toml).unwrap();
838 config.delete_url("github").unwrap();
839 let dev = config.groups.get("dev").unwrap();
840 assert_eq!(dev, &vec!["dkdc-bookmarks".to_string()]);
841 }
842
843 #[test]
844 fn test_delete_alias_cascades_to_groups() {
845 let toml = r#"
846[urls]
847github = { url = "https://github.com", aliases = ["gh"] }
848
849[groups]
850dev = ["gh"]
851"#;
852 let mut config: Config = toml::from_str(toml).unwrap();
853 config.delete_alias("gh").unwrap();
854 assert!(config.urls.get("github").unwrap().aliases().is_empty());
856 assert!(!config.groups.contains_key("dev"));
858 }
859
860 #[test]
861 fn test_delete_group() {
862 let toml = r#"
863[groups]
864dev = ["gh"]
865"#;
866 let mut config: Config = toml::from_str(toml).unwrap();
867 config.delete_group("dev").unwrap();
868 assert!(!config.groups.contains_key("dev"));
869 }
870
871 #[test]
872 fn test_rename_group_collision_errors() {
873 let toml = r#"
874[groups]
875a = ["x"]
876b = ["y"]
877"#;
878 let mut config: Config = toml::from_str(toml).unwrap();
879 let result = config.rename_group("a", "b");
880 assert!(result.is_err());
881 assert!(result.unwrap_err().to_string().contains("already exists"));
882 assert!(config.groups.contains_key("a"));
883 assert!(config.groups.contains_key("b"));
884 }
885
886 #[test]
887 fn test_rename_group_cascades() {
888 let toml = r#"
889[groups]
890dev = ["gh", "dkdc-bookmarks"]
891"#;
892 let mut config: Config = toml::from_str(toml).unwrap();
893 config.rename_group("dev", "development").unwrap();
894 assert!(!config.groups.contains_key("dev"));
895 assert_eq!(
896 config.groups.get("development"),
897 Some(&vec!["gh".to_string(), "dkdc-bookmarks".to_string()])
898 );
899 }
900
901 #[test]
902 fn test_delete_nonexistent_errors() {
903 let mut config = Config::default();
904 assert!(config.delete_url("nope").is_err());
905 assert!(config.delete_alias("nope").is_err());
906 assert!(config.delete_group("nope").is_err());
907 }
908
909 #[test]
910 fn test_parse_malformed_toml() {
911 assert!(toml::from_str::<Config>("this is not valid { toml").is_err());
912 }
913
914 #[test]
915 fn test_parse_url_wrong_type() {
916 let toml = "[urls]\ngithub = 42";
917 assert!(toml::from_str::<Config>(toml).is_err());
918 }
919
920 #[test]
921 fn test_parse_missing_url_in_full_entry() {
922 let toml = "[urls.gh]\naliases = [\"x\"]";
923 assert!(toml::from_str::<Config>(toml).is_err());
924 }
925
926 #[test]
927 fn test_parse_groups_only_no_urls() {
928 let toml = "[groups]\ndev = [\"gh\"]";
929 let config: Config = toml::from_str(toml).unwrap();
930 assert!(config.urls.is_empty());
931 let warnings = config.validate();
932 assert!(warnings.iter().any(|w| w.contains("gh")));
933 }
934
935 #[test]
936 fn test_parse_extra_sections_ignored() {
937 let toml = "[urls]\ngithub = \"https://github.com\"\n\n[metadata]\nauthor = \"test\"";
938 let result = toml::from_str::<Config>(toml);
940 assert!(result.is_ok());
941 }
942
943 #[test]
944 fn test_empty_url_simple_warns() {
945 let toml = r#"
946[urls]
947empty = ""
948"#;
949 let config: Config = toml::from_str(toml).unwrap();
950 let warnings = config.validate();
951 assert!(warnings.iter().any(|w| w.contains("empty URL string")));
952 }
953
954 #[test]
955 fn test_empty_url_full_warns() {
956 let toml = r#"
957[urls]
958empty = { url = "", aliases = ["e"] }
959"#;
960 let config: Config = toml::from_str(toml).unwrap();
961 let warnings = config.validate();
962 assert!(warnings.iter().any(|w| w.contains("empty URL string")));
963 }
964
965 #[test]
966 fn test_nonempty_url_no_empty_warning() {
967 let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
968 let warnings = config.validate();
969 assert!(!warnings.iter().any(|w| w.contains("empty URL string")));
970 }
971
972 #[test]
973 fn test_self_referencing_group_warns() {
974 let toml = r#"
975[urls]
976dev = "https://dev.example.com"
977
978[groups]
979dev = ["dev"]
980"#;
981 let config: Config = toml::from_str(toml).unwrap();
982 let warnings = config.validate();
983 assert!(warnings.iter().any(|w| w.contains("references itself")));
984 }
985
986 #[test]
987 fn test_circular_group_reference_warns() {
988 let toml = r#"
989[urls]
990a = "https://a.com"
991b = "https://b.com"
992
993[groups]
994a = ["b"]
995b = ["a"]
996"#;
997 let config: Config = toml::from_str(toml).unwrap();
998 let warnings = config.validate();
999 assert!(warnings.iter().any(|w| w.contains("circular reference")));
1000 }
1001
1002 #[test]
1003 fn test_no_circular_warning_for_valid_groups() {
1004 let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
1005 let warnings = config.validate();
1006 assert!(!warnings.iter().any(|w| w.contains("circular")));
1007 assert!(!warnings.iter().any(|w| w.contains("references itself")));
1008 }
1009}