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)]
12#[non_exhaustive]
13pub struct CatalogCoverageOptions<'a> {
14 pub source_locale: &'a str,
16 pub locales: &'a [&'a str],
18 pub include_details: bool,
20}
21
22impl<'a> CatalogCoverageOptions<'a> {
23 #[must_use]
25 pub fn new(source_locale: &'a str) -> Self {
26 Self {
27 source_locale,
28 ..Self::default()
29 }
30 }
31
32 #[must_use]
34 pub const fn with_details(mut self, include_details: bool) -> Self {
35 self.include_details = include_details;
36 self
37 }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Default)]
42#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
43#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
44pub struct CatalogCoverageReport {
45 pub source_messages: usize,
47 pub target_locales: usize,
49 pub locales: Vec<CatalogLocaleCoverage>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Default)]
55#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
56#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
57pub struct CatalogLocaleCoverage {
58 pub locale: String,
60 pub total: usize,
62 pub translated: usize,
64 pub missing: usize,
66 pub empty: usize,
68 pub obsolete: usize,
70 pub extra: usize,
72 pub details: Vec<CatalogCoverageMessage>,
74}
75
76impl CatalogLocaleCoverage {
77 #[must_use]
83 pub const fn fuzzy(&self) -> usize {
84 self.total
85 .saturating_sub(self.translated)
86 .saturating_sub(self.missing)
87 .saturating_sub(self.empty)
88 .saturating_sub(self.obsolete)
89 }
90
91 #[must_use]
93 pub const fn incomplete(&self) -> usize {
94 self.total.saturating_sub(self.translated)
95 }
96
97 #[must_use]
99 pub fn completion_ratio(&self) -> f64 {
100 if self.total == 0 {
101 1.0
102 } else {
103 self.translated as f64 / self.total as f64
104 }
105 }
106
107 #[must_use]
109 pub fn completion_percent(&self) -> f64 {
110 self.completion_ratio() * 100.0
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
116#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
117#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
118pub struct CatalogCoverageMessage {
119 pub locale: String,
121 pub source_key: CatalogMessageKey,
123 pub status: CatalogMessageStatus,
125}
126
127pub fn measure_catalog_coverage(
167 catalogs: &[&NormalizedParsedCatalog],
168 options: &CatalogCoverageOptions<'_>,
169) -> Result<CatalogCoverageReport, ApiError> {
170 validate_source_locale(options.source_locale)?;
171 let catalog_index = index_catalogs(catalogs, "measure_catalog_coverage")?;
172 let source_catalog = catalog_index
173 .get(options.source_locale)
174 .copied()
175 .ok_or_else(|| {
176 ApiError::InvalidArguments(format!(
177 "measure_catalog_coverage did not receive source locale {:?}",
178 options.source_locale
179 ))
180 })?;
181 let source_keys = active_message_keys(source_catalog);
182 let target_locales = select_target_locales(
183 &catalog_index,
184 options.source_locale,
185 options.locales,
186 "measure_catalog_coverage",
187 )?;
188 let mut locale_reports = Vec::with_capacity(target_locales.len());
189
190 for target_locale in &target_locales {
191 let target_catalog = catalog_index
192 .get(target_locale.as_str())
193 .expect("selected target locale must exist");
194 target_catalog.require_review_state("measure_catalog_coverage")?;
195 locale_reports.push(coverage_for_locale(
196 target_locale,
197 target_catalog,
198 &source_keys,
199 options.include_details,
200 ));
201 }
202
203 Ok(CatalogCoverageReport {
204 source_messages: source_keys.len(),
205 target_locales: target_locales.len(),
206 locales: locale_reports,
207 })
208}
209
210fn coverage_for_locale(
211 locale: &str,
212 target_catalog: &NormalizedParsedCatalog,
213 source_keys: &std::collections::BTreeSet<CatalogMessageKey>,
214 include_details: bool,
215) -> CatalogLocaleCoverage {
216 let mut coverage = CatalogLocaleCoverage {
217 locale: locale.to_owned(),
218 total: source_keys.len(),
219 ..CatalogLocaleCoverage::default()
220 };
221
222 for source_key in source_keys {
223 let status = classify_expected_message(target_catalog, source_key);
224 increment_status(&mut coverage, status);
225 if include_details {
226 coverage.details.push(CatalogCoverageMessage {
227 locale: locale.to_owned(),
228 source_key: source_key.clone(),
229 status,
230 });
231 }
232 }
233
234 for (key, message) in target_catalog.iter() {
235 if is_extra_target_message(source_keys, key, message) {
236 increment_status(&mut coverage, CatalogMessageStatus::Extra);
237 if include_details {
238 coverage.details.push(CatalogCoverageMessage {
239 locale: locale.to_owned(),
240 source_key: key.clone(),
241 status: CatalogMessageStatus::Extra,
242 });
243 }
244 }
245 }
246
247 coverage
248}
249
250fn increment_status(coverage: &mut CatalogLocaleCoverage, status: CatalogMessageStatus) {
251 match status {
252 CatalogMessageStatus::Translated => coverage.translated += 1,
253 CatalogMessageStatus::Missing => coverage.missing += 1,
254 CatalogMessageStatus::Empty => coverage.empty += 1,
255 CatalogMessageStatus::Obsolete => coverage.obsolete += 1,
256 CatalogMessageStatus::Extra => coverage.extra += 1,
257 CatalogMessageStatus::Fuzzy => {}
258 }
259}