Skip to main content

turnframe_runtime/
copy.rs

1//! Server copy: the sentences the runtime writes itself, which a user reads. Each copy
2//! struct ships English and Italian; a deployment declares the languages it serves with
3//! [`OrchestratorBuilder::locales`](crate::orchestrator::OrchestratorBuilder::locales), and
4//! building fails while any sentence has no text in one of them.
5
6use turnframe_core::locale::{Locale, LocalizedText};
7
8/// Copy a user reads, sentence by sentence, by the name of its field.
9pub trait ServerCopy {
10    /// The copy's name, for a report.
11    fn name(&self) -> &'static str;
12    /// Every sentence, by the name of its field.
13    fn entries(&self) -> Vec<(&'static str, &LocalizedText)>;
14    /// Every sentence, to change in place.
15    fn entries_mut(&mut self) -> Vec<(&'static str, &mut LocalizedText)>;
16
17    /// Lays `texts`, by field name, over this copy as its text in `locale`: a new
18    /// language, or the built-in one replaced. A field left out keeps what it had.
19    #[must_use]
20    fn translated(mut self, locale: impl Into<Locale>, texts: &[(&str, &str)]) -> Self
21    where
22        Self: Sized,
23    {
24        let locale = locale.into();
25        for (name, text) in self.entries_mut() {
26            if let Some((_, translation)) = texts.iter().find(|(field, _)| *field == name) {
27                text.translations
28                    .insert(locale.clone(), (*translation).to_owned());
29            }
30        }
31        self
32    }
33}
34
35/// The sentences of `copy` with no text in the language of `locale`. The default text is
36/// English, so English is always served.
37#[must_use]
38pub fn missing(copy: &dyn ServerCopy, locale: &Locale) -> Vec<&'static str> {
39    if locale.same_language(&Locale::from("en")) {
40        return Vec::new();
41    }
42    copy.entries()
43        .into_iter()
44        .filter(|(_, text)| {
45            !text
46                .translations
47                .keys()
48                .any(|translated| translated.same_language(locale))
49        })
50        .map(|(name, _)| name)
51        .collect()
52}
53
54/// Implements [`ServerCopy`] over every field of a copy struct; a field left out does not
55/// compile, so no sentence escapes the check.
56macro_rules! server_copy {
57    ($ty:ty, [$($field:ident),* $(,)?]) => {
58        impl $crate::copy::ServerCopy for $ty {
59            fn name(&self) -> &'static str {
60                stringify!($ty)
61            }
62
63            fn entries(&self) -> Vec<(&'static str, &turnframe_core::locale::LocalizedText)> {
64                let Self { $($field),* } = self;
65                vec![$((stringify!($field), $field)),*]
66            }
67
68            fn entries_mut(
69                &mut self,
70            ) -> Vec<(&'static str, &mut turnframe_core::locale::LocalizedText)> {
71                let Self { $($field),* } = self;
72                vec![$((stringify!($field), $field)),*]
73            }
74        }
75    };
76}
77pub(crate) use server_copy;
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::attachments::AttachmentCopy;
83    use crate::compose::{AskCopy, CompositionCopy};
84    use crate::policy::ConfirmationCopy;
85    use crate::reduce::NoticeCopy;
86
87    fn built_in() -> Vec<Box<dyn ServerCopy>> {
88        vec![
89            Box::new(NoticeCopy::default()),
90            Box::new(CompositionCopy::default()),
91            Box::new(AskCopy::default()),
92            Box::new(ConfirmationCopy::default()),
93            Box::new(AttachmentCopy::default()),
94        ]
95    }
96
97    fn placeholders(text: &str) -> Vec<&str> {
98        let mut found: Vec<&str> = text
99            .match_indices('{')
100            .filter_map(|(at, _)| text[at..].find('}').map(|end| &text[at..=at + end]))
101            .collect();
102        found.sort_unstable();
103        found
104    }
105
106    #[test]
107    fn every_built_in_sentence_speaks_italian_with_the_same_placeholders() {
108        let italian = Locale::from("it-IT");
109        for copy in built_in() {
110            assert!(
111                missing(copy.as_ref(), &italian).is_empty(),
112                "{}",
113                copy.name()
114            );
115            for (name, text) in copy.entries() {
116                assert_eq!(
117                    placeholders(text.resolve(&italian)),
118                    placeholders(&text.default),
119                    "{}.{name}",
120                    copy.name()
121                );
122            }
123        }
124    }
125
126    #[test]
127    fn a_language_no_sentence_speaks_is_reported_sentence_by_sentence() {
128        let german = Locale::from("de-DE");
129        let copy = AskCopy::default();
130        assert_eq!(missing(&copy, &german).len(), copy.entries().len());
131        assert!(missing(&copy, &Locale::from("en-GB")).is_empty());
132    }
133}