Skip to main content

cadmus_core/dictionary/monolingual/
metadata.rs

1//! API response types for the monolingual dictionary metadata endpoint.
2//!
3//! The `GET https://www.reader-dict.com/api/v1/dictionaries` endpoint returns
4//! a unified bilingual + monolingual registry. This module only models and
5//! exposes the **monolingual** subset (entries where source language equals
6//! target language, e.g. `en → en`). Bilingual pairs are ignored.
7
8use std::collections::HashMap;
9
10use chrono::NaiveDate;
11use serde::{Deserialize, Serialize};
12
13/// Top-level response from `GET https://www.reader-dict.com/api/v1/dictionaries`.
14///
15/// The API returns a nested map of source language → target language → entry.
16/// Both monolingual (src == tgt) and bilingual (src != tgt) entries are present,
17/// but only the monolingual subset is used by this module.
18pub type DictionariesResponse = HashMap<String, HashMap<String, DictionaryEntry>>;
19
20/// A single dictionary entry returned by the API.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub struct DictionaryEntry {
23    /// Comma-separated list of available download formats
24    /// (e.g. `"df,dic,dictorg,kobo,mobi,stardict"`).
25    pub formats: String,
26
27    /// Date of the last release.
28    #[serde(with = "date_serde")]
29    pub updated: NaiveDate,
30
31    /// Number of headword entries in the dictionary.
32    pub words: u64,
33}
34
35/// Returns the download URL for the DICT.org format archive (includes etymologies).
36///
37/// Pattern: `https://www.reader-dict.com/file/{lang}/dictorg-{lang}-{lang}.zip`
38pub(super) fn download_url(lang: &str) -> String {
39    format!(
40        "https://www.reader-dict.com/file/{lang}/dictorg-{lang}-{lang}.zip",
41        lang = lang
42    )
43}
44
45/// Returns the download URL for the DICT.org format archive **without** etymologies.
46///
47/// Pattern: `https://www.reader-dict.com/file/{lang}/dictorg-{lang}-{lang}-noetym.zip`
48pub(super) fn download_url_no_etym(lang: &str) -> String {
49    format!(
50        "https://www.reader-dict.com/file/{lang}/dictorg-{lang}-{lang}-noetym.zip",
51        lang = lang
52    )
53}
54
55mod date_serde {
56    use chrono::NaiveDate;
57    use serde::{Deserialize, Deserializer, Serialize, Serializer};
58
59    const FORMAT: &str = "%Y-%m-%d";
60
61    pub fn serialize<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
62    where
63        S: Serializer,
64    {
65        date.format(FORMAT).to_string().serialize(serializer)
66    }
67
68    pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
69    where
70        D: Deserializer<'de>,
71    {
72        let s = String::deserialize(deserializer)?;
73        NaiveDate::parse_from_str(&s, FORMAT).map_err(serde::de::Error::custom)
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    fn make_entry() -> DictionaryEntry {
82        DictionaryEntry {
83            formats: "df,dic,dictorg,kobo,mobi,stardict".to_string(),
84            updated: NaiveDate::from_ymd_opt(2026, 4, 1).unwrap(),
85            words: 1_381_375,
86        }
87    }
88
89    #[test]
90    fn test_download_url_english() {
91        assert_eq!(
92            download_url("en"),
93            "https://www.reader-dict.com/file/en/dictorg-en-en.zip"
94        );
95    }
96
97    #[test]
98    fn test_download_url_no_etym_english() {
99        assert_eq!(
100            download_url_no_etym("en"),
101            "https://www.reader-dict.com/file/en/dictorg-en-en-noetym.zip"
102        );
103    }
104
105    #[test]
106    fn test_download_url_french() {
107        assert_eq!(
108            download_url("fr"),
109            "https://www.reader-dict.com/file/fr/dictorg-fr-fr.zip"
110        );
111    }
112
113    #[test]
114    fn test_deserialize_response() {
115        let json = r#"{
116            "en": {
117                "en": { "formats": "df,dic,dictorg,kobo,mobi,stardict", "updated": "2026-04-01", "words": 1381375 },
118                "fr": { "formats": "df,dic,dictorg,kobo,mobi,stardict", "updated": "2026-04-01", "words": 50000 }
119            },
120            "fr": {
121                "fr": { "formats": "df,dic,dictorg,kobo,mobi,stardict", "updated": "2026-03-01", "words": 2050655 }
122            }
123        }"#;
124
125        let resp: DictionariesResponse = serde_json::from_str(json).unwrap();
126
127        let en_entry = resp.get("en").and_then(|m| m.get("en")).unwrap();
128        assert_eq!(en_entry.words, 1_381_375);
129        assert_eq!(
130            en_entry.updated,
131            NaiveDate::from_ymd_opt(2026, 4, 1).unwrap()
132        );
133
134        let fr_entry = resp.get("fr").and_then(|m| m.get("fr")).unwrap();
135        assert_eq!(fr_entry.words, 2_050_655);
136
137        assert_eq!(*en_entry, make_entry());
138    }
139
140    #[test]
141    fn test_monolingual_filter() {
142        let json = r#"{
143            "en": {
144                "en": { "formats": "df,dic,dictorg,kobo,mobi,stardict", "updated": "2026-04-01", "words": 1381375 },
145                "fr": { "formats": "df,dic,dictorg,kobo,mobi,stardict", "updated": "2026-04-01", "words": 50000 }
146            },
147            "af": {
148                "en": { "formats": "df,dic,dictorg,kobo,mobi,stardict", "updated": "2026-04-01", "words": 8934 }
149            }
150        }"#;
151
152        let resp: DictionariesResponse = serde_json::from_str(json).unwrap();
153
154        let monolingual: Vec<(&str, &DictionaryEntry)> = resp
155            .iter()
156            .filter_map(|(lang, targets)| targets.get(lang.as_str()).map(|e| (lang.as_str(), e)))
157            .collect();
158
159        assert_eq!(monolingual.len(), 1);
160        assert_eq!(monolingual[0].0, "en");
161    }
162}