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    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    /// Build a reverse lookup: alias → url_name.
125    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    /// Resolve a name (url name or alias) to a URL string.
136    pub fn resolve(&self, name: &str) -> Option<&str> {
137        // Direct url name
138        if let Some(entry) = self.urls.get(name) {
139            return Some(entry.url());
140        }
141        // Alias lookup
142        for entry in self.urls.values() {
143            if entry.has_alias(name) {
144                return Some(entry.url());
145            }
146        }
147        None
148    }
149
150    /// Check if a name is a known url name or alias.
151    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        // Check for duplicate aliases across urls
216        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                // Alias shadows a url name
227                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        // Check for empty URLs
236        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        // Check group entries
243        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        // Check for circular group references
254        // Groups don't reference other groups in the current model, but a group
255        // entry that matches its own group name (e.g., dev = ["dev"]) is likely
256        // a mistake — the entry resolves as a url/alias, not a group expansion,
257        // so flag self-references as warnings.
258        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        // Detect indirect cycles: group A contains entry X where X is also a
267        // group name, and group X contains entry Y where Y == A.
268        // Build adjacency: group -> set of group names that appear in its entries.
269        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        // DFS cycle detection
284        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    /// Rename a url key and cascade to group entries that reference it by name.
320    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        // Check if new name collides with an existing alias
329        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        // Update group entries that reference the old url name
340        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    /// Rename an alias and cascade to group entries.
352    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        // Find which url owns this alias
365        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        // Update group entries
378        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    /// Delete a url and clean up group references to it and its aliases.
390    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        // Collect the url name + all its aliases for group cleanup
396        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    /// Delete an alias from its parent url and clean up group references.
406    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    /// Rename a group key.
426    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    /// Delete a group.
439    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        // Group entries for both the url name and its aliases are removed
824        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        // Alias removed from url entry
855        assert!(config.urls.get("github").unwrap().aliases().is_empty());
856        // Group with only "gh" is now empty and removed
857        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        // Config doesn't use deny_unknown_fields, so extra sections are ignored
939        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}