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