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)]
12pub struct CatalogCoverageOptions<'a> {
13    /// Source locale whose active message identities define expected coverage.
14    pub source_locale: &'a str,
15    /// Optional target locale filter. Empty means all non-source locales present in `catalogs`.
16    pub locales: &'a [&'a str],
17    /// Whether the report should include one detail row per classified message.
18    pub include_details: bool,
19}
20
21impl<'a> CatalogCoverageOptions<'a> {
22    /// Creates coverage options with the required source locale set.
23    #[must_use]
24    pub fn new(source_locale: &'a str) -> Self {
25        Self {
26            source_locale,
27            ..Self::default()
28        }
29    }
30
31    /// Returns options that include per-message detail rows.
32    #[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/// Structured catalog coverage report.
40#[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    /// Active source messages considered expected by the report.
45    pub source_messages: usize,
46    /// Target locales included in the report.
47    pub target_locales: usize,
48    /// Per-locale coverage rollups in deterministic locale order.
49    pub locales: Vec<CatalogLocaleCoverage>,
50}
51
52/// Coverage counters for one target locale.
53#[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    /// Target locale represented by these counters.
58    pub locale: String,
59    /// Active source messages expected for this locale.
60    pub total: usize,
61    /// Expected messages with non-empty, non-fuzzy active translations.
62    pub translated: usize,
63    /// Expected messages with no target entry.
64    pub missing: usize,
65    /// Expected messages with an empty effective translation.
66    pub empty: usize,
67    /// Expected messages with an active fuzzy target entry.
68    pub fuzzy: usize,
69    /// Expected messages with only an obsolete target entry.
70    pub obsolete: usize,
71    /// Active target messages that are not present in the active source set.
72    pub extra: usize,
73    /// Optional per-message detail rows.
74    pub details: Vec<CatalogCoverageMessage>,
75}
76
77impl CatalogLocaleCoverage {
78    /// Returns messages that still need translator attention.
79    #[must_use]
80    pub const fn incomplete(&self) -> usize {
81        self.total.saturating_sub(self.translated)
82    }
83
84    /// Returns completion as a `0.0..=1.0` ratio.
85    #[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    /// Returns completion as a `0.0..=100.0` percentage.
95    #[must_use]
96    pub fn completion_percent(&self) -> f64 {
97        self.completion_ratio() * 100.0
98    }
99}
100
101/// One classified message row in a coverage report.
102#[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    /// Locale associated with this row.
107    pub locale: String,
108    /// Canonical gettext identity for the row.
109    pub source_key: CatalogMessageKey,
110    /// Canonical status assigned by the shared message-status classifier.
111    pub status: CatalogMessageStatus,
112}
113
114/// Builds a read-only completeness and coverage report for normalized catalogs.
115///
116/// The report uses the source locale's active messages as the expected set.
117/// Fuzzy, empty, obsolete, and absent target messages do not count as
118/// translated. Active target-only messages are counted as `extra` and do not
119/// affect the completion denominator.
120///
121/// # Errors
122///
123/// Returns [`ApiError::InvalidArguments`] when locales are missing, empty,
124/// duplicated, or when a requested target locale is absent from the catalog set.
125pub 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}