intl_model/
lib.rs

1// Copyright 2020 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5//! # The JSON message internationalization format
6//!
7//! This module contains the code used to turn a dictionary of localized messages into a
8//! JSON-formatted file usable by Fuchsia's localization lookup system.
9//!
10//! The data model used for the JSON schema is defined below.  No formal JSON schema has been
11//! specified yet. (But probably should be!)
12
13use anyhow::Result;
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16use std::io;
17
18/// The message catalog.  Maps unique IDs (as generated by the message_id::gen_ids) to individual
19/// message.  The catalog does not know about the locale it is intended for, and additional
20/// metadata is needed to assert that.  See [Model] for details on how it fits together.
21pub type Messages = BTreeMap<u64, String>;
22
23#[cfg(test)]
24pub fn as_messages(pairs: &Vec<(u64, String)>) -> Messages {
25    let mut result = Messages::new();
26    for (k, v) in pairs {
27        result.insert(*k, v.clone());
28    }
29    result as Messages
30}
31
32/// The data model for a set of internationalized messages.  Every
33/// file has a locale ID that it applies to, as well as the number
34/// of total messages analyzed when this locale was produced
35///
36/// Use [Model::from_json_reader] to make a new instance of the
37/// Model from its JSON serialization.  Use [Model::from_dictionaries] to make a new instance of
38/// the Model based on the supplied dictionaries.
39///
40/// In tests, you can use [Model::from_parts] to create [Model] quickly without any checks.
41#[derive(Serialize, Deserialize, Debug, Clone, Default, Eq, PartialEq)]
42pub struct Model {
43    /// The locale for the messages described in this file.  An
44    /// example value could be "en-US".
45    locale_id: String,
46
47    /// The source-of-truth locale from which this model was
48    /// generated.  Source of truth locales exist so as to provide
49    /// fallback messages or such.
50    source_locale_id: String,
51
52    /// The total number of messages that are expected to exist in
53    /// this bundle.  This number is not the same as the number of
54    /// key-value pairs in [messages] below, though.
55    num_messages: usize,
56
57    /// The message catalog, listing the mapping of message IDs to
58    /// respective messages.  This mapping does not define a fallback
59    /// language.  The ordering of the messages in this map is not
60    /// defined: all serializations that end up with an identical
61    /// map model are equally valid.
62    messages: Messages,
63}
64
65impl Model {
66    pub fn locale(&self) -> &str {
67        &self.locale_id
68    }
69
70    pub fn messages(&self) -> &Messages {
71        &self.messages
72    }
73
74    /// Deserializes the Model from the supplied reader.  Use to
75    /// create a functional Model from JSON.
76    pub fn from_json_reader<R: io::Read>(r: R) -> Result<Model> {
77        serde_json::from_reader(r).map_err(|e| e.into())
78    }
79
80    /// Writes the Model into the supplied writer encoded as JSON.  Use to persist the Model into
81    /// JSON.  The message ordering is not guaranteed.
82    pub fn to_json_writer<W: io::Write>(&self, writer: W) -> Result<()> {
83        serde_json::to_writer(writer, self)
84            .map_err(|e| -> io::Error { e.into() })
85            .map_err(|e| -> anyhow::Error { e.into() })
86    }
87
88    /// Creates a [Model] quickly from supplied parts and without any checks,
89    /// for use in tests only.
90    pub fn from_parts(
91        locale_id: &str,
92        source_locale_id: &str,
93        num_messages: usize,
94        messages: Messages,
95    ) -> Model {
96        Model {
97            locale_id: locale_id.to_string(),
98            source_locale_id: source_locale_id.to_string(),
99            num_messages,
100            messages,
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use anyhow::Context;
109
110    #[test]
111    fn read_json_test() -> Result<()> {
112        let message = r#"
113        {
114            "locale_id": "ru-RU",
115            "source_locale_id": "en-US",
116            "num_messages": 1000,
117            "messages": {
118                "42": "Привет",
119                "100": "Что это сорок-два?"
120            }
121        }
122        "#;
123        let model = Model::from_json_reader(message.as_bytes())
124            .with_context(|| format!("while loading JSON: \n{}", message))?;
125        assert_eq!(model.locale_id, "ru-RU");
126        assert_eq!(model.num_messages, 1000);
127        assert_eq!(model.messages.get(&42).unwrap(), "Привет");
128        Ok(())
129    }
130
131    /// Note that we don't care about the message ordering just yet.
132    #[test]
133    fn round_trip_test() -> Result<()> {
134        struct TestCase {
135            name: &'static str,
136            message: &'static str,
137        }
138        let tests = vec![
139            TestCase {
140                name: "Random text in Russian",
141                message: r#"
142                {
143                    "locale_id": "ru-RU",
144                    "source_locale_id": "en-US",
145                    "num_messages": 42,
146                    "messages": {
147                        "42": "Привет",
148                        "100": "Что это сорок-два?"
149                    }
150                }
151                "#,
152            },
153            TestCase {
154                name: "Serbian pangram",
155                message: r#"
156                {
157                    "locale_id": "sr-RS",
158                    "source_locale_id": "en-US",
159                    "num_messages": 2,
160                    "messages": {
161                        "1": "Љубазни фењерџија чађавог лица хоће да ми покаже штос"
162                    }
163                }
164                "#,
165            },
166        ];
167        for test in tests {
168            let model = Model::from_json_reader(test.message.as_bytes()).with_context(|| {
169                format!("in test '{}', while loading JSON: \n{}", test.name, test.message)
170            })?;
171            let mut output: Vec<u8> = vec![];
172            model
173                .to_json_writer(&mut output)
174                .with_context(|| format!("while writing JSON, in test '{}'", test.name))?;
175            let model2 = Model::from_json_reader(&output[..])
176                .with_context(|| format!("while reading JSON again, in test '{}'", test.name))?;
177            assert_eq!(model, model2);
178        }
179        Ok(())
180    }
181}