1#[cfg(feature = "serde")]
2use serde::{Deserialize, Serialize};
3
4use super::catalog_index::{index_catalogs, select_target_locales};
5use super::message_status::{
6 CatalogMessageStatus, active_message_keys, classify_expected_message, is_extra_target_message,
7};
8use super::{ApiError, CatalogMessageKey, NormalizedParsedCatalog, validate_source_locale};
9
10#[derive(Debug, Clone, PartialEq, Eq, Default)]
12pub struct CatalogCoverageOptions<'a> {
13 pub source_locale: &'a str,
15 pub locales: &'a [&'a str],
17 pub include_details: bool,
19}
20
21impl<'a> CatalogCoverageOptions<'a> {
22 #[must_use]
24 pub fn new(source_locale: &'a str) -> Self {
25 Self {
26 source_locale,
27 ..Self::default()
28 }
29 }
30
31 #[must_use]
33 pub const fn with_details(mut self, include_details: bool) -> Self {
34 self.include_details = include_details;
35 self
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Default)]
41#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
42#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
43pub struct CatalogCoverageReport {
44 pub source_messages: usize,
46 pub target_locales: usize,
48 pub locales: Vec<CatalogLocaleCoverage>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Default)]
54#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
55#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
56pub struct CatalogLocaleCoverage {
57 pub locale: String,
59 pub total: usize,
61 pub translated: usize,
63 pub missing: usize,
65 pub empty: usize,
67 pub fuzzy: usize,
69 pub obsolete: usize,
71 pub extra: usize,
73 pub details: Vec<CatalogCoverageMessage>,
75}
76
77impl CatalogLocaleCoverage {
78 #[must_use]
80 pub const fn incomplete(&self) -> usize {
81 self.total.saturating_sub(self.translated)
82 }
83
84 #[must_use]
86 pub fn completion_ratio(&self) -> f64 {
87 if self.total == 0 {
88 1.0
89 } else {
90 self.translated as f64 / self.total as f64
91 }
92 }
93
94 #[must_use]
96 pub fn completion_percent(&self) -> f64 {
97 self.completion_ratio() * 100.0
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
104#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
105pub struct CatalogCoverageMessage {
106 pub locale: String,
108 pub source_key: CatalogMessageKey,
110 pub status: CatalogMessageStatus,
112}
113
114pub fn catalog_coverage(
126 catalogs: &[&NormalizedParsedCatalog],
127 options: &CatalogCoverageOptions<'_>,
128) -> Result<CatalogCoverageReport, ApiError> {
129 validate_source_locale(options.source_locale)?;
130 let catalog_index = index_catalogs(catalogs, "catalog_coverage")?;
131 let source_catalog = catalog_index
132 .get(options.source_locale)
133 .copied()
134 .ok_or_else(|| {
135 ApiError::InvalidArguments(format!(
136 "catalog_coverage did not receive source locale {:?}",
137 options.source_locale
138 ))
139 })?;
140 let source_keys = active_message_keys(source_catalog);
141 let target_locales = select_target_locales(
142 &catalog_index,
143 options.source_locale,
144 options.locales,
145 "catalog_coverage",
146 )?;
147 let mut locale_reports = Vec::with_capacity(target_locales.len());
148
149 for target_locale in &target_locales {
150 let target_catalog = catalog_index
151 .get(target_locale.as_str())
152 .expect("selected target locale must exist");
153 locale_reports.push(coverage_for_locale(
154 target_locale,
155 target_catalog,
156 &source_keys,
157 options.include_details,
158 ));
159 }
160
161 Ok(CatalogCoverageReport {
162 source_messages: source_keys.len(),
163 target_locales: target_locales.len(),
164 locales: locale_reports,
165 })
166}
167
168fn coverage_for_locale(
169 locale: &str,
170 target_catalog: &NormalizedParsedCatalog,
171 source_keys: &std::collections::BTreeSet<CatalogMessageKey>,
172 include_details: bool,
173) -> CatalogLocaleCoverage {
174 let mut coverage = CatalogLocaleCoverage {
175 locale: locale.to_owned(),
176 total: source_keys.len(),
177 ..CatalogLocaleCoverage::default()
178 };
179
180 for source_key in source_keys {
181 let status = classify_expected_message(target_catalog, source_key);
182 increment_status(&mut coverage, status);
183 if include_details {
184 coverage.details.push(CatalogCoverageMessage {
185 locale: locale.to_owned(),
186 source_key: source_key.clone(),
187 status,
188 });
189 }
190 }
191
192 for (key, message) in target_catalog.iter() {
193 if is_extra_target_message(source_keys, key, message) {
194 increment_status(&mut coverage, CatalogMessageStatus::Extra);
195 if include_details {
196 coverage.details.push(CatalogCoverageMessage {
197 locale: locale.to_owned(),
198 source_key: key.clone(),
199 status: CatalogMessageStatus::Extra,
200 });
201 }
202 }
203 }
204
205 coverage
206}
207
208fn increment_status(coverage: &mut CatalogLocaleCoverage, status: CatalogMessageStatus) {
209 match status {
210 CatalogMessageStatus::Translated => coverage.translated += 1,
211 CatalogMessageStatus::Fuzzy => coverage.fuzzy += 1,
212 CatalogMessageStatus::Missing => coverage.missing += 1,
213 CatalogMessageStatus::Empty => coverage.empty += 1,
214 CatalogMessageStatus::Obsolete => coverage.obsolete += 1,
215 CatalogMessageStatus::Extra => coverage.extra += 1,
216 }
217}