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 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 messages that still need translator attention.
78    #[must_use]
79    pub const fn incomplete(&self) -> usize {
80        self.total.saturating_sub(self.translated)
81    }
82
83    /// Returns completion as a `0.0..=1.0` ratio.
84    #[must_use]
85    pub fn completion_ratio(&self) -> f64 {
86        if self.total == 0 {
87            1.0
88        } else {
89            self.translated as f64 / self.total as f64
90        }
91    }
92
93    /// Returns completion as a `0.0..=100.0` percentage.
94    #[must_use]
95    pub fn completion_percent(&self) -> f64 {
96        self.completion_ratio() * 100.0
97    }
98}
99
100/// One classified message row in a coverage report.
101#[derive(Debug, Clone, PartialEq, Eq)]
102#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
103#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
104pub struct CatalogCoverageMessage {
105    /// Locale associated with this row.
106    pub locale: String,
107    /// Canonical gettext identity for the row.
108    pub source_key: CatalogMessageKey,
109    /// Canonical status assigned by the shared message-status classifier.
110    pub status: CatalogMessageStatus,
111}
112
113/// Builds a read-only completeness and coverage report for normalized catalogs.
114///
115/// The report uses the source locale's active messages as the expected set.
116/// Empty, obsolete, and absent target messages do not count as translated.
117/// Active target-only messages are counted as `extra` and do not affect the
118/// completion denominator.
119///
120/// # Examples
121///
122/// ```rust
123/// use ferrocat_po::{
124///     CatalogCoverageOptions, CatalogMessageStatus, ParseCatalogOptions, measure_catalog_coverage,
125///     parse_catalog,
126/// };
127///
128/// let source = parse_catalog(
129///     ParseCatalogOptions::new("msgid \"Save\"\nmsgstr \"\"\n", "en").with_locale("en"),
130/// )?
131/// .into_normalized_view()?;
132/// let target = parse_catalog(
133///     ParseCatalogOptions::new("msgid \"Save\"\nmsgstr \"Speichern\"\n", "en")
134///         .with_locale("de"),
135/// )?
136/// .into_normalized_view()?;
137///
138/// let options = CatalogCoverageOptions::new("en").with_details(true);
139/// let report = measure_catalog_coverage(&[&source, &target], &options)?;
140///
141/// assert_eq!(report.source_messages, 1);
142/// assert_eq!(report.locales[0].locale, "de");
143/// assert_eq!(report.locales[0].translated, 1);
144/// assert_eq!(report.locales[0].details[0].status, CatalogMessageStatus::Translated);
145/// # Ok::<(), ferrocat_po::ApiError>(())
146/// ```
147///
148/// # Errors
149///
150/// Returns [`ApiError::InvalidArguments`] when locales are missing, empty,
151/// duplicated, or when a requested target locale is absent from the catalog set.
152pub fn measure_catalog_coverage(
153    catalogs: &[&NormalizedParsedCatalog],
154    options: &CatalogCoverageOptions<'_>,
155) -> Result<CatalogCoverageReport, ApiError> {
156    validate_source_locale(options.source_locale)?;
157    let catalog_index = index_catalogs(catalogs, "measure_catalog_coverage")?;
158    let source_catalog = catalog_index
159        .get(options.source_locale)
160        .copied()
161        .ok_or_else(|| {
162            ApiError::InvalidArguments(format!(
163                "measure_catalog_coverage did not receive source locale {:?}",
164                options.source_locale
165            ))
166        })?;
167    let source_keys = active_message_keys(source_catalog);
168    let target_locales = select_target_locales(
169        &catalog_index,
170        options.source_locale,
171        options.locales,
172        "measure_catalog_coverage",
173    )?;
174    let mut locale_reports = Vec::with_capacity(target_locales.len());
175
176    for target_locale in &target_locales {
177        let target_catalog = catalog_index
178            .get(target_locale.as_str())
179            .expect("selected target locale must exist");
180        locale_reports.push(coverage_for_locale(
181            target_locale,
182            target_catalog,
183            &source_keys,
184            options.include_details,
185        ));
186    }
187
188    Ok(CatalogCoverageReport {
189        source_messages: source_keys.len(),
190        target_locales: target_locales.len(),
191        locales: locale_reports,
192    })
193}
194
195fn coverage_for_locale(
196    locale: &str,
197    target_catalog: &NormalizedParsedCatalog,
198    source_keys: &std::collections::BTreeSet<CatalogMessageKey>,
199    include_details: bool,
200) -> CatalogLocaleCoverage {
201    let mut coverage = CatalogLocaleCoverage {
202        locale: locale.to_owned(),
203        total: source_keys.len(),
204        ..CatalogLocaleCoverage::default()
205    };
206
207    for source_key in source_keys {
208        let status = classify_expected_message(target_catalog, source_key);
209        increment_status(&mut coverage, status);
210        if include_details {
211            coverage.details.push(CatalogCoverageMessage {
212                locale: locale.to_owned(),
213                source_key: source_key.clone(),
214                status,
215            });
216        }
217    }
218
219    for (key, message) in target_catalog.iter() {
220        if is_extra_target_message(source_keys, key, message) {
221            increment_status(&mut coverage, CatalogMessageStatus::Extra);
222            if include_details {
223                coverage.details.push(CatalogCoverageMessage {
224                    locale: locale.to_owned(),
225                    source_key: key.clone(),
226                    status: CatalogMessageStatus::Extra,
227                });
228            }
229        }
230    }
231
232    coverage
233}
234
235fn increment_status(coverage: &mut CatalogLocaleCoverage, status: CatalogMessageStatus) {
236    match status {
237        CatalogMessageStatus::Translated => coverage.translated += 1,
238        CatalogMessageStatus::Missing => coverage.missing += 1,
239        CatalogMessageStatus::Empty => coverage.empty += 1,
240        CatalogMessageStatus::Obsolete => coverage.obsolete += 1,
241        CatalogMessageStatus::Extra => coverage.extra += 1,
242    }
243}