intl_model/
lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// Copyright 2020 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

//! # The JSON message internationalization format
//!
//! This module contains the code used to turn a dictionary of localized messages into a
//! JSON-formatted file usable by Fuchsia's localization lookup system.
//!
//! The data model used for the JSON schema is defined below.  No formal JSON schema has been
//! specified yet. (But probably should be!)

use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io;

/// The message catalog.  Maps unique IDs (as generated by the message_id::gen_ids) to individual
/// message.  The catalog does not know about the locale it is intended for, and additional
/// metadata is needed to assert that.  See [Model] for details on how it fits together.
pub type Messages = BTreeMap<u64, String>;

#[cfg(test)]
pub fn as_messages(pairs: &Vec<(u64, String)>) -> Messages {
    let mut result = Messages::new();
    for (k, v) in pairs {
        result.insert(*k, v.clone());
    }
    result as Messages
}

/// The data model for a set of internationalized messages.  Every
/// file has a locale ID that it applies to, as well as the number
/// of total messages analyzed when this locale was produced
///
/// Use [Model::from_json_reader] to make a new instance of the
/// Model from its JSON serialization.  Use [Model::from_dictionaries] to make a new instance of
/// the Model based on the supplied dictionaries.
///
/// In tests, you can use [Model::from_parts] to create [Model] quickly without any checks.
#[derive(Serialize, Deserialize, Debug, Clone, Default, Eq, PartialEq)]
pub struct Model {
    /// The locale for the messages described in this file.  An
    /// example value could be "en-US".
    locale_id: String,

    /// The source-of-truth locale from which this model was
    /// generated.  Source of truth locales exist so as to provide
    /// fallback messages or such.
    source_locale_id: String,

    /// The total number of messages that are expected to exist in
    /// this bundle.  This number is not the same as the number of
    /// key-value pairs in [messages] below, though.
    num_messages: usize,

    /// The message catalog, listing the mapping of message IDs to
    /// respective messages.  This mapping does not define a fallback
    /// language.  The ordering of the messages in this map is not
    /// defined: all serializations that end up with an identical
    /// map model are equally valid.
    messages: Messages,
}

impl Model {
    pub fn locale(&self) -> &str {
        &self.locale_id
    }

    pub fn messages(&self) -> &Messages {
        &self.messages
    }

    /// Deserializes the Model from the supplied reader.  Use to
    /// create a functional Model from JSON.
    pub fn from_json_reader<R: io::Read>(r: R) -> Result<Model> {
        serde_json::from_reader(r).map_err(|e| e.into())
    }

    /// Writes the Model into the supplied writer encoded as JSON.  Use to persist the Model into
    /// JSON.  The message ordering is not guaranteed.
    pub fn to_json_writer<W: io::Write>(&self, writer: W) -> Result<()> {
        serde_json::to_writer(writer, self)
            .map_err(|e| -> io::Error { e.into() })
            .map_err(|e| -> anyhow::Error { e.into() })
    }

    /// Creates a [Model] quickly from supplied parts and without any checks,
    /// for use in tests only.
    pub fn from_parts(
        locale_id: &str,
        source_locale_id: &str,
        num_messages: usize,
        messages: Messages,
    ) -> Model {
        Model {
            locale_id: locale_id.to_string(),
            source_locale_id: source_locale_id.to_string(),
            num_messages,
            messages,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use anyhow::Context;

    #[test]
    fn read_json_test() -> Result<()> {
        let message = r#"
        {
            "locale_id": "ru-RU",
            "source_locale_id": "en-US",
            "num_messages": 1000,
            "messages": {
                "42": "Привет",
                "100": "Что это сорок-два?"
            }
        }
        "#;
        let model = Model::from_json_reader(message.as_bytes())
            .with_context(|| format!("while loading JSON: \n{}", message))?;
        assert_eq!(model.locale_id, "ru-RU");
        assert_eq!(model.num_messages, 1000);
        assert_eq!(model.messages.get(&42).unwrap(), "Привет");
        Ok(())
    }

    /// Note that we don't care about the message ordering just yet.
    #[test]
    fn round_trip_test() -> Result<()> {
        struct TestCase {
            name: &'static str,
            message: &'static str,
        }
        let tests = vec![
            TestCase {
                name: "Random text in Russian",
                message: r#"
                {
                    "locale_id": "ru-RU",
                    "source_locale_id": "en-US",
                    "num_messages": 42,
                    "messages": {
                        "42": "Привет",
                        "100": "Что это сорок-два?"
                    }
                }
                "#,
            },
            TestCase {
                name: "Serbian pangram",
                message: r#"
                {
                    "locale_id": "sr-RS",
                    "source_locale_id": "en-US",
                    "num_messages": 2,
                    "messages": {
                        "1": "Љубазни фењерџија чађавог лица хоће да ми покаже штос"
                    }
                }
                "#,
            },
        ];
        for test in tests {
            let model = Model::from_json_reader(test.message.as_bytes()).with_context(|| {
                format!("in test '{}', while loading JSON: \n{}", test.name, test.message)
            })?;
            let mut output: Vec<u8> = vec![];
            model
                .to_json_writer(&mut output)
                .with_context(|| format!("while writing JSON, in test '{}'", test.name))?;
            let model2 = Model::from_json_reader(&output[..])
                .with_context(|| format!("while reading JSON again, in test '{}'", test.name))?;
            assert_eq!(model, model2);
        }
        Ok(())
    }
}