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}