Skip to main content

bookmarks_core/
config.rs

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/// A URL entry: either a plain URL string or a table with url + optional aliases.
9///
10/// In TOML this means all three forms work:
11/// ```toml
12/// [urls]
13/// dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
14/// github = { url = "https://github.com", aliases = ["gh"] }
15///
16/// [urls.linkedin]
17/// url = "https://linkedin.com"
18/// aliases = ["li", "ln"]
19/// ```
20#[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    /// Build a reverse lookup: alias → url_name.
105    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    /// Resolve a name (url name or alias) to a URL string.
116    pub fn resolve(&self, name: &str) -> Option<&str> {
117        // Direct url name
118        if let Some(entry) = self.urls.get(name) {
119            return Some(entry.url());
120        }
121        // Alias lookup
122        for entry in self.urls.values() {
123            if entry.has_alias(name) {
124                return Some(entry.url());
125            }
126        }
127        None
128    }
129
130    /// Check if a name is a known url name or alias.
131    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        // Check for duplicate aliases across urls
139        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                // Alias shadows a url name
150                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        // Check for empty URLs
159        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        // Check group entries
166        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        // Check for circular group references
177        // Groups don't reference other groups in the current model, but a group
178        // entry that matches its own group name (e.g., dev = ["dev"]) is likely
179        // a mistake — the entry resolves as a url/alias, not a group expansion,
180        // so flag self-references as warnings.
181        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        // Detect indirect cycles: group A contains entry X where X is also a
190        // group name, and group X contains entry Y where Y == A.
191        // Build adjacency: group -> set of group names that appear in its entries.
192        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        // DFS cycle detection
207        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    /// Rename a url key and cascade to group entries that reference it by name.
243    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        // Check if new name collides with an existing alias
252        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        // Update group entries that reference the old url name
263        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    /// Rename an alias and cascade to group entries.
275    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        // Find which url owns this alias
288        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        // Update group entries
301        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    /// Delete a url and clean up group references to it and its aliases.
313    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        // Collect the url name + all its aliases for group cleanup
319        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    /// Delete an alias from its parent url and clean up group references.
329    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    /// Rename a group key.
349    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    /// Delete a group.
362    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        // Group entries for both the url name and its aliases are removed
747        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        // Alias removed from url entry
778        assert!(config.urls.get("github").unwrap().aliases().is_empty());
779        // Group with only "gh" is now empty and removed
780        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        // Config doesn't use deny_unknown_fields, so extra sections are ignored
862        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}