Skip to main content

bookmarks_core/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
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 group entries
159        for (group, entries) in &self.groups {
160            for entry in entries {
161                if !self.contains(entry) {
162                    warnings.push(format!(
163                        "group '{group}' contains '{entry}' which is not a url name or alias"
164                    ));
165                }
166            }
167        }
168
169        warnings
170    }
171
172    /// Rename a url key and cascade to group entries that reference it by name.
173    pub fn rename_url(&mut self, old: &str, new: &str) -> Result<()> {
174        if old == new {
175            anyhow::ensure!(self.urls.contains_key(old), "url '{old}' not found");
176            return Ok(());
177        }
178        if self.urls.contains_key(new) {
179            anyhow::bail!("url '{new}' already exists");
180        }
181        // Check if new name collides with an existing alias
182        let alias_map = self.alias_map();
183        if alias_map.contains_key(new) {
184            anyhow::bail!("'{new}' already exists as an alias");
185        }
186        let entry = self
187            .urls
188            .remove(old)
189            .with_context(|| format!("url '{old}' not found"))?;
190        self.urls.insert(new.to_string(), entry);
191
192        // Update group entries that reference the old url name
193        for entries in self.groups.values_mut() {
194            for e in entries.iter_mut() {
195                if e == old {
196                    *e = new.to_string();
197                }
198            }
199        }
200
201        Ok(())
202    }
203
204    /// Rename an alias and cascade to group entries.
205    pub fn rename_alias(&mut self, old: &str, new: &str) -> Result<()> {
206        if old == new {
207            return Ok(());
208        }
209        if self.urls.contains_key(new) {
210            anyhow::bail!("'{new}' already exists as a url name");
211        }
212        let alias_map = self.alias_map();
213        if alias_map.contains_key(new) {
214            anyhow::bail!("alias '{new}' already exists");
215        }
216
217        // Find which url owns this alias
218        let url_name = alias_map
219            .get(old)
220            .with_context(|| format!("alias '{old}' not found"))?
221            .to_string();
222
223        let entry = self.urls.get_mut(&url_name).unwrap();
224        entry.remove_alias(old);
225        entry.add_alias(new.to_string());
226
227        // Update group entries
228        for entries in self.groups.values_mut() {
229            for e in entries.iter_mut() {
230                if e == old {
231                    *e = new.to_string();
232                }
233            }
234        }
235
236        Ok(())
237    }
238
239    /// Delete a url and clean up group references to it and its aliases.
240    pub fn delete_url(&mut self, name: &str) -> Result<()> {
241        let entry = self
242            .urls
243            .remove(name)
244            .with_context(|| format!("url '{name}' not found"))?;
245        // Collect the url name + all its aliases for group cleanup
246        let mut to_remove: Vec<String> = vec![name.to_string()];
247        to_remove.extend(entry.aliases().iter().cloned());
248        for entries in self.groups.values_mut() {
249            entries.retain(|e| !to_remove.contains(e));
250        }
251        self.groups.retain(|_, entries| !entries.is_empty());
252        Ok(())
253    }
254
255    /// Delete an alias from its parent url and clean up group references.
256    pub fn delete_alias(&mut self, alias: &str) -> Result<()> {
257        let alias_map = self.alias_map();
258        let url_name = alias_map
259            .get(alias)
260            .with_context(|| format!("alias '{alias}' not found"))?
261            .to_string();
262
263        self.urls.get_mut(&url_name).unwrap().remove_alias(alias);
264
265        for entries in self.groups.values_mut() {
266            entries.retain(|e| e != alias);
267        }
268        self.groups.retain(|_, entries| !entries.is_empty());
269        Ok(())
270    }
271
272    /// Rename a group key.
273    pub fn rename_group(&mut self, old: &str, new: &str) -> Result<()> {
274        if old != new && self.groups.contains_key(new) {
275            anyhow::bail!("group '{new}' already exists");
276        }
277        let entries = self
278            .groups
279            .remove(old)
280            .with_context(|| format!("group '{old}' not found"))?;
281        self.groups.insert(new.to_string(), entries);
282        Ok(())
283    }
284
285    /// Delete a group.
286    pub fn delete_group(&mut self, name: &str) -> Result<()> {
287        self.groups
288            .remove(name)
289            .with_context(|| format!("group '{name}' not found"))?;
290        Ok(())
291    }
292}
293
294pub fn edit_config(config_path: &Path) -> Result<()> {
295    let editor = std::env::var("EDITOR").unwrap_or_else(|_| DEFAULT_EDITOR.to_string());
296
297    println!("Opening {} with {}...", config_path.display(), editor);
298
299    let status = std::process::Command::new(&editor)
300        .arg(config_path)
301        .status()
302        .with_context(|| format!("Editor {editor} not found in PATH"))?;
303
304    if !status.success() {
305        anyhow::bail!("Editor exited with non-zero status");
306    }
307
308    Ok(())
309}
310
311pub fn print_config(config: &Config) {
312    if !config.urls.is_empty() {
313        println!("urls:");
314        println!();
315
316        let mut entries: Vec<_> = config.urls.iter().collect();
317        entries.sort_unstable_by_key(|(k, _)| k.as_str());
318
319        let max_key_len = entries.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
320
321        for (name, entry) in &entries {
322            let aliases = entry.aliases();
323            if aliases.is_empty() {
324                println!("• {name:<max_key_len$} | {}", entry.url());
325            } else {
326                println!(
327                    "• {name:<max_key_len$} | {} (aliases: {})",
328                    entry.url(),
329                    aliases.join(", ")
330                );
331            }
332        }
333
334        println!();
335    }
336
337    if !config.groups.is_empty() {
338        println!("groups:");
339        println!();
340
341        let mut entries: Vec<_> = config.groups.iter().collect();
342        entries.sort_unstable_by_key(|(k, _)| k.as_str());
343
344        let max_key_len = entries.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
345
346        for (name, group_entries) in &entries {
347            println!("• {name:<max_key_len$} | [{}]", group_entries.join(", "));
348        }
349
350        println!();
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn test_parse_valid_config() {
360        let toml = r#"
361[urls]
362github = { url = "https://github.com", aliases = ["gh"] }
363
364[groups]
365dev = ["gh"]
366"#;
367        let config: Config = toml::from_str(toml).unwrap();
368        let entry = config.urls.get("github").unwrap();
369        assert_eq!(entry.url(), "https://github.com");
370        assert_eq!(entry.aliases(), &["gh"]);
371        assert_eq!(config.groups.get("dev"), Some(&vec!["gh".to_string()]));
372    }
373
374    #[test]
375    fn test_parse_simple_url() {
376        let toml = r#"
377[urls]
378dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
379"#;
380        let config: Config = toml::from_str(toml).unwrap();
381        let entry = config.urls.get("dkdc-bookmarks").unwrap();
382        assert_eq!(entry.url(), "https://github.com/dkdc-io/bookmarks");
383        assert!(entry.aliases().is_empty());
384    }
385
386    #[test]
387    fn test_parse_expanded_table() {
388        let toml = r#"
389[urls.linkedin]
390url = "https://linkedin.com"
391aliases = ["li", "ln"]
392"#;
393        let config: Config = toml::from_str(toml).unwrap();
394        let entry = config.urls.get("linkedin").unwrap();
395        assert_eq!(entry.url(), "https://linkedin.com");
396        assert_eq!(entry.aliases(), &["li", "ln"]);
397    }
398
399    #[test]
400    fn test_parse_hybrid_config() {
401        let toml = r#"
402[urls]
403dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
404github = { url = "https://github.com", aliases = ["gh"] }
405
406[urls.linkedin]
407url = "https://linkedin.com"
408aliases = ["li"]
409
410[groups]
411socials = ["gh", "linkedin"]
412"#;
413        let config: Config = toml::from_str(toml).unwrap();
414        assert_eq!(config.urls.len(), 3);
415        assert_eq!(
416            config.urls.get("dkdc-bookmarks").unwrap().url(),
417            "https://github.com/dkdc-io/bookmarks"
418        );
419        assert_eq!(config.urls.get("github").unwrap().aliases(), &["gh"]);
420        assert_eq!(config.urls.get("linkedin").unwrap().aliases(), &["li"]);
421        assert!(config.validate().is_empty());
422    }
423
424    #[test]
425    fn test_parse_empty_config() {
426        let config: Config = toml::from_str("").unwrap();
427        assert!(config.urls.is_empty());
428        assert!(config.groups.is_empty());
429    }
430
431    #[test]
432    fn test_config_roundtrip() {
433        let mut config = Config::default();
434        config.urls.insert(
435            "example".to_string(),
436            UrlEntry::Full {
437                url: "https://example.com".to_string(),
438                aliases: vec!["ex".to_string()],
439            },
440        );
441        config
442            .groups
443            .insert("g".to_string(), vec!["ex".to_string()]);
444
445        let serialized = toml::to_string(&config).unwrap();
446        let deserialized: Config = toml::from_str(&serialized).unwrap();
447
448        assert_eq!(config.urls.len(), deserialized.urls.len());
449        assert_eq!(config.groups, deserialized.groups);
450    }
451
452    #[test]
453    fn test_default_config_parses() {
454        let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
455        assert!(!config.urls.is_empty());
456        assert!(!config.groups.is_empty());
457    }
458
459    #[test]
460    fn test_valid_config_has_no_warnings() {
461        let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
462        assert!(config.validate().is_empty());
463    }
464
465    #[test]
466    fn test_resolve_by_url_name() {
467        let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
468        assert_eq!(
469            config.resolve("dkdc-bookmarks"),
470            Some("https://github.com/dkdc-io/bookmarks")
471        );
472    }
473
474    #[test]
475    fn test_resolve_by_alias() {
476        let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
477        assert_eq!(config.resolve("gh"), Some("https://github.com"));
478    }
479
480    #[test]
481    fn test_resolve_unknown() {
482        let config: Config = toml::from_str(DEFAULT_CONFIG).unwrap();
483        assert_eq!(config.resolve("nope"), None);
484    }
485
486    #[test]
487    fn test_duplicate_alias_warns() {
488        let toml = r#"
489[urls]
490a = { url = "https://a.com", aliases = ["x"] }
491b = { url = "https://b.com", aliases = ["x"] }
492"#;
493        let config: Config = toml::from_str(toml).unwrap();
494        let warnings = config.validate();
495        assert_eq!(warnings.len(), 1);
496        assert!(warnings[0].contains("x"));
497    }
498
499    #[test]
500    fn test_alias_shadows_url_name_warns() {
501        let toml = r#"
502[urls]
503github = { url = "https://github.com", aliases = ["dkdc-bookmarks"] }
504dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
505"#;
506        let config: Config = toml::from_str(toml).unwrap();
507        let warnings = config.validate();
508        assert!(!warnings.is_empty());
509        assert!(warnings.iter().any(|w| w.contains("shadows")));
510    }
511
512    #[test]
513    fn test_broken_group_entry_warns() {
514        let toml = r#"
515[urls]
516real = "https://example.com"
517
518[groups]
519dev = ["real", "ghost"]
520"#;
521        let config: Config = toml::from_str(toml).unwrap();
522        let warnings = config.validate();
523        assert_eq!(warnings.len(), 1);
524        assert!(warnings[0].contains("ghost"));
525    }
526
527    #[test]
528    fn test_rename_url_cascades_groups() {
529        let toml = r#"
530[urls]
531github = "https://github.com"
532
533[groups]
534dev = ["github"]
535"#;
536        let mut config: Config = toml::from_str(toml).unwrap();
537        config.rename_url("github", "gh-link").unwrap();
538        assert!(config.urls.contains_key("gh-link"));
539        assert!(!config.urls.contains_key("github"));
540        assert_eq!(config.groups.get("dev"), Some(&vec!["gh-link".to_string()]));
541    }
542
543    #[test]
544    fn test_rename_alias_cascades_groups() {
545        let toml = r#"
546[urls]
547github = { url = "https://github.com", aliases = ["gh"] }
548
549[groups]
550dev = ["gh"]
551all = ["gh", "other"]
552"#;
553        let mut config: Config = toml::from_str(toml).unwrap();
554        config.rename_alias("gh", "github-alias").unwrap();
555        let entry = config.urls.get("github").unwrap();
556        assert!(entry.has_alias("github-alias"));
557        assert!(!entry.has_alias("gh"));
558        assert_eq!(
559            config.groups.get("dev"),
560            Some(&vec!["github-alias".to_string()])
561        );
562        let all = config.groups.get("all").unwrap();
563        assert!(all.contains(&"github-alias".to_string()));
564        assert!(all.contains(&"other".to_string()));
565    }
566
567    #[test]
568    fn test_rename_nonexistent_url_errors() {
569        let mut config = Config::default();
570        assert!(config.rename_url("nope", "new").is_err());
571    }
572
573    #[test]
574    fn test_rename_nonexistent_alias_errors() {
575        let mut config = Config::default();
576        assert!(config.rename_alias("nope", "new").is_err());
577    }
578
579    #[test]
580    fn test_rename_url_collision_errors() {
581        let toml = r#"
582[urls]
583a = "https://a.com"
584b = "https://b.com"
585"#;
586        let mut config: Config = toml::from_str(toml).unwrap();
587        let result = config.rename_url("a", "b");
588        assert!(result.is_err());
589        assert!(result.unwrap_err().to_string().contains("already exists"));
590        assert!(config.urls.contains_key("a"));
591        assert!(config.urls.contains_key("b"));
592    }
593
594    #[test]
595    fn test_rename_alias_collision_errors() {
596        let toml = r#"
597[urls]
598a = { url = "https://a.com", aliases = ["x"] }
599b = { url = "https://b.com", aliases = ["y"] }
600"#;
601        let mut config: Config = toml::from_str(toml).unwrap();
602        let result = config.rename_alias("x", "y");
603        assert!(result.is_err());
604        assert!(result.unwrap_err().to_string().contains("already exists"));
605    }
606
607    #[test]
608    fn test_rename_url_to_existing_alias_errors() {
609        let toml = r#"
610[urls]
611github = { url = "https://github.com", aliases = ["gh"] }
612other = "https://other.com"
613"#;
614        let mut config: Config = toml::from_str(toml).unwrap();
615        let result = config.rename_url("other", "gh");
616        assert!(result.is_err());
617        assert!(
618            result
619                .unwrap_err()
620                .to_string()
621                .contains("already exists as an alias")
622        );
623        assert!(config.urls.contains_key("other"));
624    }
625
626    #[test]
627    fn test_rename_alias_to_existing_url_errors() {
628        let toml = r#"
629[urls]
630github = { url = "https://github.com", aliases = ["gh"] }
631other = "https://other.com"
632"#;
633        let mut config: Config = toml::from_str(toml).unwrap();
634        let result = config.rename_alias("gh", "other");
635        assert!(result.is_err());
636        assert!(
637            result
638                .unwrap_err()
639                .to_string()
640                .contains("already exists as a url name")
641        );
642        assert!(config.urls.get("github").unwrap().has_alias("gh"));
643    }
644
645    #[test]
646    fn test_rename_url_same_name_is_noop() {
647        let toml = r#"
648[urls]
649a = "https://a.com"
650"#;
651        let mut config: Config = toml::from_str(toml).unwrap();
652        config.rename_url("a", "a").unwrap();
653        assert_eq!(config.urls.get("a").unwrap().url(), "https://a.com");
654    }
655
656    #[test]
657    fn test_delete_url_cascades() {
658        let toml = r#"
659[urls]
660github = { url = "https://github.com", aliases = ["gh", "g"] }
661dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
662
663[groups]
664dev = ["gh", "github"]
665"#;
666        let mut config: Config = toml::from_str(toml).unwrap();
667        config.delete_url("github").unwrap();
668        assert!(!config.urls.contains_key("github"));
669        assert!(config.urls.contains_key("dkdc-bookmarks"));
670        // Group entries for both the url name and its aliases are removed
671        assert!(!config.groups.contains_key("dev"));
672    }
673
674    #[test]
675    fn test_delete_url_partial_group_cleanup() {
676        let toml = r#"
677[urls]
678github = { url = "https://github.com", aliases = ["gh"] }
679dkdc-bookmarks = "https://github.com/dkdc-io/bookmarks"
680
681[groups]
682dev = ["gh", "dkdc-bookmarks"]
683"#;
684        let mut config: Config = toml::from_str(toml).unwrap();
685        config.delete_url("github").unwrap();
686        let dev = config.groups.get("dev").unwrap();
687        assert_eq!(dev, &vec!["dkdc-bookmarks".to_string()]);
688    }
689
690    #[test]
691    fn test_delete_alias_cascades_to_groups() {
692        let toml = r#"
693[urls]
694github = { url = "https://github.com", aliases = ["gh"] }
695
696[groups]
697dev = ["gh"]
698"#;
699        let mut config: Config = toml::from_str(toml).unwrap();
700        config.delete_alias("gh").unwrap();
701        // Alias removed from url entry
702        assert!(config.urls.get("github").unwrap().aliases().is_empty());
703        // Group with only "gh" is now empty and removed
704        assert!(!config.groups.contains_key("dev"));
705    }
706
707    #[test]
708    fn test_delete_group() {
709        let toml = r#"
710[groups]
711dev = ["gh"]
712"#;
713        let mut config: Config = toml::from_str(toml).unwrap();
714        config.delete_group("dev").unwrap();
715        assert!(!config.groups.contains_key("dev"));
716    }
717
718    #[test]
719    fn test_rename_group_collision_errors() {
720        let toml = r#"
721[groups]
722a = ["x"]
723b = ["y"]
724"#;
725        let mut config: Config = toml::from_str(toml).unwrap();
726        let result = config.rename_group("a", "b");
727        assert!(result.is_err());
728        assert!(result.unwrap_err().to_string().contains("already exists"));
729        assert!(config.groups.contains_key("a"));
730        assert!(config.groups.contains_key("b"));
731    }
732
733    #[test]
734    fn test_rename_group_cascades() {
735        let toml = r#"
736[groups]
737dev = ["gh", "dkdc-bookmarks"]
738"#;
739        let mut config: Config = toml::from_str(toml).unwrap();
740        config.rename_group("dev", "development").unwrap();
741        assert!(!config.groups.contains_key("dev"));
742        assert_eq!(
743            config.groups.get("development"),
744            Some(&vec!["gh".to_string(), "dkdc-bookmarks".to_string()])
745        );
746    }
747
748    #[test]
749    fn test_delete_nonexistent_errors() {
750        let mut config = Config::default();
751        assert!(config.delete_url("nope").is_err());
752        assert!(config.delete_alias("nope").is_err());
753        assert!(config.delete_group("nope").is_err());
754    }
755
756    #[test]
757    fn test_parse_malformed_toml() {
758        assert!(toml::from_str::<Config>("this is not valid { toml").is_err());
759    }
760
761    #[test]
762    fn test_parse_url_wrong_type() {
763        let toml = "[urls]\ngithub = 42";
764        assert!(toml::from_str::<Config>(toml).is_err());
765    }
766
767    #[test]
768    fn test_parse_missing_url_in_full_entry() {
769        let toml = "[urls.gh]\naliases = [\"x\"]";
770        assert!(toml::from_str::<Config>(toml).is_err());
771    }
772
773    #[test]
774    fn test_parse_groups_only_no_urls() {
775        let toml = "[groups]\ndev = [\"gh\"]";
776        let config: Config = toml::from_str(toml).unwrap();
777        assert!(config.urls.is_empty());
778        let warnings = config.validate();
779        assert!(warnings.iter().any(|w| w.contains("gh")));
780    }
781
782    #[test]
783    fn test_parse_extra_sections_ignored() {
784        let toml = "[urls]\ngithub = \"https://github.com\"\n\n[metadata]\nauthor = \"test\"";
785        // Config doesn't use deny_unknown_fields, so extra sections are ignored
786        let result = toml::from_str::<Config>(toml);
787        assert!(result.is_ok());
788    }
789}