Skip to main content

ferrocat_po/api/
coverage.rs

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/// Options controlling catalog coverage reports.
11#[derive(Debug, Clone, PartialEq, Eq, Default)]
12#[non_exhaustive]
13pub struct CatalogCoverageOptions<'a> {
14    /// Source locale whose active message identities define expected coverage.
15    pub source_locale: &'a str,
16    /// Optional target locale filter. Empty means all non-source locales present in `catalogs`.
17    pub locales: &'a [&'a str],
18    /// Whether the report should include one detail row per classified message.
19    pub include_details: bool,
20}
21
22impl<'a> CatalogCoverageOptions<'a> {
23    /// Creates coverage options with the required source locale set.
24    #[must_use]
25    pub fn new(source_locale: &'a str) -> Self {
26        Self {
27            source_locale,
28            ..Self::default()
29        }
30    }
31
32    /// Returns options that include per-message detail rows.
33    #[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/// Structured catalog coverage report.
41#[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    /// Active source messages considered expected by the report.
46    pub source_messages: usize,
47    /// Target locales included in the report.
48    pub target_locales: usize,
49    /// Per-locale coverage rollups in deterministic locale order.
50    pub locales: Vec<CatalogLocaleCoverage>,
51}
52
53/// Coverage counters for one target locale.
54#[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    /// Target locale represented by these counters.
59    pub locale: String,
60    /// Active source messages expected for this locale.
61    pub total: usize,
62    /// Expected messages with non-empty, non-fuzzy active translations.
63    pub translated: usize,
64    /// Expected messages with no target entry.
65    pub missing: usize,
66    /// Expected messages with an empty effective translation.
67    pub empty: usize,
68    /// Expected messages with only an obsolete target entry.
69    pub obsolete: usize,
70    /// Active target messages that are not present in the active source set.
71    pub extra: usize,
72    /// Optional per-message detail rows.
73    pub details: Vec<CatalogCoverageMessage>,
74}
75
76impl CatalogLocaleCoverage {
77    /// Returns expected messages with a non-empty active translation carrying
78    /// the semantic `fuzzy` review marker.
79    ///
80    /// Fuzzy is the remaining mutually exclusive expected-message state after
81    /// subtracting translated, missing, empty, and obsolete messages.
82    #[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    /// Returns messages that still need translator attention.
92    #[must_use]
93    pub const fn incomplete(&self) -> usize {
94        self.total.saturating_sub(self.translated)
95    }
96
97    /// Returns completion as a `0.0..=1.0` ratio.
98    #[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    /// Returns completion as a `0.0..=100.0` percentage.
108    #[must_use]
109    pub fn completion_percent(&self) -> f64 {
110        self.completion_ratio() * 100.0
111    }
112}
113
114/// One classified message row in a coverage report.
115#[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    /// Locale associated with this row.
120    pub locale: String,
121    /// Canonical gettext identity for the row.
122    pub source_key: CatalogMessageKey,
123    /// Canonical status assigned by the shared message-status classifier.
124    pub status: CatalogMessageStatus,
125}
126
127/// Builds a read-only completeness and coverage report for normalized catalogs.
128///
129/// The report uses the source locale's active messages as the expected set.
130/// Fuzzy, empty, obsolete, and absent target messages do not count as translated.
131/// Active target-only messages are counted as `extra` and do not affect the
132/// completion denominator.
133///
134/// # Examples
135///
136/// ```rust
137/// use ferrocat_po::{
138///     CatalogCoverageOptions, CatalogMessageStatus, ParseCatalogOptions, measure_catalog_coverage,
139///     parse_catalog_for_review,
140/// };
141///
142/// let source = parse_catalog_for_review(
143///     ParseCatalogOptions::new("msgid \"Save\"\nmsgstr \"\"\n", "en").with_locale("en"),
144/// )?;
145/// let target = parse_catalog_for_review(
146///     ParseCatalogOptions::new("msgid \"Save\"\nmsgstr \"Speichern\"\n", "en")
147///         .with_locale("de"),
148/// )?;
149///
150/// let options = CatalogCoverageOptions::new("en").with_details(true);
151/// let report = measure_catalog_coverage(&[&source, &target], &options)?;
152///
153/// assert_eq!(report.source_messages, 1);
154/// assert_eq!(report.locales[0].locale, "de");
155/// assert_eq!(report.locales[0].translated, 1);
156/// assert_eq!(report.locales[0].details[0].status, CatalogMessageStatus::Translated);
157/// # Ok::<(), ferrocat_po::ApiError>(())
158/// ```
159///
160/// # Errors
161///
162/// Returns [`ApiError::InvalidArguments`] when locales are missing, empty,
163/// duplicated, when a requested target locale is absent from the catalog set,
164/// or when a selected target was not parsed with
165/// [`super::parse_catalog_for_review`].
166pub 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}