Skip to main content

ferrocat_po/api/
review.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6use super::catalog_index::{index_catalogs, select_target_locales};
7use super::message_status::{active_message_keys, classify_expected_message};
8use super::mt::validate_machine_metadata;
9use super::{
10    ApiError, CatalogCoverageOptions, CatalogLocaleCoverage, CatalogMessage, CatalogMessageKey,
11    CatalogMessageStatus, EffectiveTranslationRef, NormalizedParsedCatalog,
12    machine_translation_hash, measure_catalog_coverage, validate_source_locale,
13};
14
15/// Options controlling catalog review reports.
16#[derive(Debug, Clone, PartialEq, Eq, Default)]
17#[non_exhaustive]
18pub struct CatalogReviewOptions<'a> {
19    /// Source locale whose active identities define the expected current set.
20    pub source_locale: &'a str,
21    /// Optional target locale filter. Empty means all current non-source locales.
22    pub locales: &'a [&'a str],
23    /// Whether detail vectors should be populated in addition to counters.
24    pub include_details: bool,
25}
26
27impl<'a> CatalogReviewOptions<'a> {
28    /// Creates review options with the required source locale set.
29    #[must_use]
30    pub fn new(source_locale: &'a str) -> Self {
31        Self {
32            source_locale,
33            ..Self::default()
34        }
35    }
36
37    /// Returns options that include source, translation, and metadata detail rows.
38    #[must_use]
39    pub const fn with_details(mut self, include_details: bool) -> Self {
40        self.include_details = include_details;
41        self
42    }
43}
44
45/// Read-only catalog review report comparing two normalized catalog states.
46#[derive(Debug, Clone, PartialEq, Eq, Default)]
47#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
48#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
49pub struct CatalogReviewReport {
50    /// Aggregate counters across the compared catalog states.
51    pub summary: CatalogReviewSummary,
52    /// Source identity additions and removals.
53    pub source_changes: CatalogSourceChangeReport,
54    /// Per-locale target review sections.
55    pub locales: Vec<CatalogLocaleReview>,
56}
57
58/// Aggregate counters for a catalog review report.
59#[derive(Debug, Clone, PartialEq, Eq, Default)]
60#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
61#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
62pub struct CatalogReviewSummary {
63    /// Active source identities added in the current state.
64    pub source_added: usize,
65    /// Active source identities removed from the current state.
66    pub source_removed: usize,
67    /// Target locales included in the report.
68    pub target_locales: usize,
69    /// Target translations that changed between previous and current states.
70    pub translation_changed: usize,
71    /// Current active target messages with valid current machine-translation metadata.
72    pub machine_translation_current: usize,
73    /// Current active target messages with stale machine-translation metadata.
74    pub machine_translation_stale: usize,
75    /// Current active target messages without machine-translation metadata.
76    pub machine_translation_absent: usize,
77    /// Current active target messages with invalid machine-translation metadata.
78    pub machine_translation_invalid: usize,
79}
80
81/// Source identity add/remove summary.
82#[derive(Debug, Clone, PartialEq, Eq, Default)]
83#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
84#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
85pub struct CatalogSourceChangeReport {
86    /// Number of active source identities added in the current state.
87    pub added: usize,
88    /// Number of active source identities removed from the current state.
89    pub removed: usize,
90    /// Optional source change details.
91    pub details: Vec<CatalogSourceChange>,
92}
93
94/// One source identity change.
95#[derive(Debug, Clone, PartialEq, Eq)]
96#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
97#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
98pub struct CatalogSourceChange {
99    /// Canonical gettext identity that changed.
100    pub source_key: CatalogMessageKey,
101    /// Add/remove classification.
102    pub kind: CatalogSourceChangeKind,
103}
104
105/// Kind of source identity change.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
108#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
109#[non_exhaustive]
110pub enum CatalogSourceChangeKind {
111    /// Identity exists in current source but not previous source.
112    Added,
113    /// Identity existed in previous source but not current source.
114    Removed,
115}
116
117/// Review details for one target locale.
118#[derive(Debug, Clone, PartialEq, Eq, Default)]
119#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
120#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
121pub struct CatalogLocaleReview {
122    /// Locale represented by this review section.
123    pub locale: String,
124    /// Current coverage/status rollup for this locale.
125    pub coverage: CatalogLocaleCoverage,
126    /// Translation changes against the previous state.
127    pub translations: CatalogTranslationChangeReport,
128    /// Machine-translation metadata state in the current target catalog.
129    pub machine_translation: CatalogMachineTranslationReview,
130}
131
132/// Translation change summary for one locale.
133#[derive(Debug, Clone, PartialEq, Eq, Default)]
134#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
135#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
136pub struct CatalogTranslationChangeReport {
137    /// Number of active translations whose effective value changed.
138    pub changed: usize,
139    /// Optional changed translation details.
140    pub details: Vec<CatalogTranslationChange>,
141}
142
143/// One target translation change.
144#[derive(Debug, Clone, PartialEq, Eq)]
145#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
146#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
147pub struct CatalogTranslationChange {
148    /// Target locale whose translation changed.
149    pub locale: String,
150    /// Canonical gettext identity for the changed translation.
151    pub source_key: CatalogMessageKey,
152    /// Previous effective translation value.
153    pub previous: CatalogReviewTranslation,
154    /// Current effective translation value.
155    pub current: CatalogReviewTranslation,
156}
157
158/// Owned translation value used in review reports.
159#[derive(Debug, Clone, PartialEq, Eq)]
160#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
161#[cfg_attr(
162    feature = "serde",
163    serde(tag = "kind", content = "value", rename_all = "snake_case")
164)]
165pub enum CatalogReviewTranslation {
166    /// Singular translation value.
167    Singular(String),
168    /// Plural translation values keyed by plural category.
169    Plural(BTreeMap<String, String>),
170}
171
172/// Machine-translation metadata summary for one locale.
173#[derive(Debug, Clone, PartialEq, Eq, Default)]
174#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
175#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
176pub struct CatalogMachineTranslationReview {
177    /// Active target messages whose metadata hash matches the current translation.
178    pub current: usize,
179    /// Active target messages whose metadata hash no longer matches the translation.
180    pub stale: usize,
181    /// Active target messages without machine-translation metadata.
182    pub absent: usize,
183    /// Active target messages with invalid metadata, when detectable.
184    pub invalid: usize,
185    /// Optional per-message metadata detail rows.
186    pub details: Vec<CatalogMachineTranslationMessage>,
187}
188
189/// One machine-translation metadata classification.
190#[derive(Debug, Clone, PartialEq, Eq)]
191#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
192#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
193pub struct CatalogMachineTranslationMessage {
194    /// Target locale associated with the metadata row.
195    pub locale: String,
196    /// Canonical gettext identity for the metadata row.
197    pub source_key: CatalogMessageKey,
198    /// Machine-translation metadata status.
199    pub status: CatalogMachineTranslationStatus,
200}
201
202/// Machine-translation metadata freshness status.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
205#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
206#[non_exhaustive]
207pub enum CatalogMachineTranslationStatus {
208    /// Metadata hash matches the current effective translation.
209    Current,
210    /// Metadata hash does not match the current effective translation.
211    Stale,
212    /// No machine-translation metadata is present.
213    Absent,
214    /// Metadata is invalid in a way the parsed catalog representation can expose.
215    Invalid,
216}
217
218/// Compares previous and current normalized catalog states for translator review.
219///
220/// Source changes are deterministic identity additions/removals by
221/// `msgctxt + msgid`; semantic rename detection is intentionally out of scope.
222/// Target status rollups reuse [`CatalogMessageStatus`] and therefore match
223/// [`super::audit_catalogs`] and [`super::measure_catalog_coverage`] semantics.
224/// Translation change details are limited to source identities whose current
225/// target has a non-empty active translation. Fuzzy translations remain
226/// eligible because their review marker and value change are independent
227/// signals; missing, empty, and obsolete current entries are surfaced by the
228/// coverage counters.
229///
230/// # Examples
231///
232/// ```rust
233/// use ferrocat_po::{
234///     CatalogReviewOptions, ParseCatalogOptions, parse_catalog_for_review, review_catalogs,
235/// };
236///
237/// let previous_source = parse_catalog_for_review(
238///     ParseCatalogOptions::new("msgid \"Save\"\nmsgstr \"\"\n", "en").with_locale("en"),
239/// )?;
240/// let previous_target = parse_catalog_for_review(
241///     ParseCatalogOptions::new("msgid \"Save\"\nmsgstr \"Speichern\"\n", "en")
242///         .with_locale("de"),
243/// )?;
244///
245/// let current_source = parse_catalog_for_review(
246///     ParseCatalogOptions::new(
247///         "msgid \"Save\"\nmsgstr \"\"\n\nmsgid \"Cancel\"\nmsgstr \"\"\n",
248///         "en",
249///     )
250///     .with_locale("en"),
251/// )?;
252/// let current_target = parse_catalog_for_review(
253///     ParseCatalogOptions::new(
254///         "msgid \"Save\"\nmsgstr \"Sichern\"\n\nmsgid \"Cancel\"\nmsgstr \"\"\n",
255///         "en",
256///     )
257///     .with_locale("de"),
258/// )?;
259///
260/// let options = CatalogReviewOptions::new("en").with_details(true);
261/// let report = review_catalogs(
262///     &[&previous_source, &previous_target],
263///     &[&current_source, &current_target],
264///     &options,
265/// )?;
266///
267/// assert_eq!(report.summary.source_added, 1);
268/// assert_eq!(report.summary.translation_changed, 1);
269/// assert_eq!(report.locales[0].coverage.empty, 1);
270/// # Ok::<(), ferrocat_po::ApiError>(())
271/// ```
272///
273/// # Errors
274///
275/// Returns [`ApiError::InvalidArguments`] when either catalog state is missing
276/// required locales, contains duplicate locales, or a requested target locale is
277/// not available in the current catalog state, or when a current target was not
278/// parsed with [`super::parse_catalog_for_review`].
279pub fn review_catalogs(
280    previous_catalogs: &[&NormalizedParsedCatalog],
281    current_catalogs: &[&NormalizedParsedCatalog],
282    options: &CatalogReviewOptions<'_>,
283) -> Result<CatalogReviewReport, ApiError> {
284    validate_source_locale(options.source_locale)?;
285    let previous_index = index_catalogs(previous_catalogs, "review_catalogs previous")?;
286    let current_index = index_catalogs(current_catalogs, "review_catalogs current")?;
287    let previous_source = previous_index
288        .get(options.source_locale)
289        .copied()
290        .ok_or_else(|| missing_source_error("previous", options.source_locale))?;
291    let current_source = current_index
292        .get(options.source_locale)
293        .copied()
294        .ok_or_else(|| missing_source_error("current", options.source_locale))?;
295    let previous_source_keys = active_message_keys(previous_source);
296    let current_source_keys = active_message_keys(current_source);
297    let target_locales = select_target_locales(
298        &current_index,
299        options.source_locale,
300        options.locales,
301        "review_catalogs",
302    )?;
303    let source_changes = source_change_report(
304        &previous_source_keys,
305        &current_source_keys,
306        options.include_details,
307    );
308    let coverage_options = CatalogCoverageOptions {
309        source_locale: options.source_locale,
310        locales: options.locales,
311        include_details: options.include_details,
312    };
313    let coverage = measure_catalog_coverage(current_catalogs, &coverage_options)?;
314    let mut locales = Vec::with_capacity(target_locales.len());
315
316    for locale in target_locales {
317        let current_target = current_index
318            .get(locale.as_str())
319            .expect("selected target locale must exist");
320        let previous_target = previous_index.get(locale.as_str()).copied();
321        let locale_coverage = coverage
322            .locales
323            .iter()
324            .find(|entry| entry.locale == locale)
325            .expect("coverage locale must exist")
326            .clone();
327        let translations = translation_change_report(
328            &locale,
329            previous_target,
330            current_target,
331            &current_source_keys,
332            options.include_details,
333        );
334        let machine_translation =
335            machine_translation_review(&locale, current_target, options.include_details);
336        locales.push(CatalogLocaleReview {
337            locale,
338            coverage: locale_coverage,
339            translations,
340            machine_translation,
341        });
342    }
343
344    let summary = review_summary(&source_changes, &locales);
345    Ok(CatalogReviewReport {
346        summary,
347        source_changes,
348        locales,
349    })
350}
351
352fn missing_source_error(state: &str, source_locale: &str) -> ApiError {
353    ApiError::InvalidArguments(format!(
354        "review_catalogs {state} catalogs did not receive source locale {source_locale:?}"
355    ))
356}
357
358fn source_change_report(
359    previous_keys: &BTreeSet<CatalogMessageKey>,
360    current_keys: &BTreeSet<CatalogMessageKey>,
361    include_details: bool,
362) -> CatalogSourceChangeReport {
363    let added_keys = current_keys.difference(previous_keys);
364    let removed_keys = previous_keys.difference(current_keys);
365    let mut report = CatalogSourceChangeReport {
366        added: added_keys.clone().count(),
367        removed: removed_keys.clone().count(),
368        details: Vec::new(),
369    };
370
371    if include_details {
372        report
373            .details
374            .extend(added_keys.map(|source_key| CatalogSourceChange {
375                source_key: source_key.clone(),
376                kind: CatalogSourceChangeKind::Added,
377            }));
378        report
379            .details
380            .extend(removed_keys.map(|source_key| CatalogSourceChange {
381                source_key: source_key.clone(),
382                kind: CatalogSourceChangeKind::Removed,
383            }));
384    }
385
386    report
387}
388
389fn translation_change_report(
390    locale: &str,
391    previous_target: Option<&NormalizedParsedCatalog>,
392    current_target: &NormalizedParsedCatalog,
393    current_source_keys: &BTreeSet<CatalogMessageKey>,
394    include_details: bool,
395) -> CatalogTranslationChangeReport {
396    let Some(previous_target) = previous_target else {
397        return CatalogTranslationChangeReport::default();
398    };
399    let mut report = CatalogTranslationChangeReport::default();
400
401    for source_key in current_source_keys {
402        if !matches!(
403            classify_expected_message(current_target, source_key),
404            CatalogMessageStatus::Translated | CatalogMessageStatus::Fuzzy
405        ) {
406            continue;
407        }
408        let Some(previous_message) = previous_target
409            .get(source_key)
410            .filter(|message| message.obsolete.is_none())
411        else {
412            continue;
413        };
414        let current_message = current_target
415            .get(source_key)
416            .filter(|message| message.obsolete.is_none())
417            .expect("translated classification must have an active current message");
418        let previous = previous_message.effective_translation();
419        let current = current_message.effective_translation();
420        if previous == current {
421            continue;
422        }
423        report.changed += 1;
424        if include_details {
425            report.details.push(CatalogTranslationChange {
426                locale: locale.to_owned(),
427                source_key: source_key.clone(),
428                previous: owned_translation(previous),
429                current: owned_translation(current),
430            });
431        }
432    }
433
434    report
435}
436
437fn machine_translation_review(
438    locale: &str,
439    current_target: &NormalizedParsedCatalog,
440    include_details: bool,
441) -> CatalogMachineTranslationReview {
442    let mut report = CatalogMachineTranslationReview::default();
443
444    for (source_key, message) in current_target.iter() {
445        if message.obsolete.is_some() {
446            continue;
447        }
448        let status = machine_translation_status(message);
449        increment_machine_translation_status(&mut report, status);
450        if include_details {
451            report.details.push(CatalogMachineTranslationMessage {
452                locale: locale.to_owned(),
453                source_key: source_key.clone(),
454                status,
455            });
456        }
457    }
458
459    report
460}
461
462fn machine_translation_status(message: &CatalogMessage) -> CatalogMachineTranslationStatus {
463    let Some(metadata) = message.machine.as_ref() else {
464        return CatalogMachineTranslationStatus::Absent;
465    };
466    if validate_machine_metadata(metadata).is_err() {
467        return CatalogMachineTranslationStatus::Invalid;
468    }
469    if metadata.lock == machine_translation_hash(message.effective_translation()) {
470        CatalogMachineTranslationStatus::Current
471    } else {
472        CatalogMachineTranslationStatus::Stale
473    }
474}
475
476fn increment_machine_translation_status(
477    report: &mut CatalogMachineTranslationReview,
478    status: CatalogMachineTranslationStatus,
479) {
480    match status {
481        CatalogMachineTranslationStatus::Current => report.current += 1,
482        CatalogMachineTranslationStatus::Stale => report.stale += 1,
483        CatalogMachineTranslationStatus::Absent => report.absent += 1,
484        CatalogMachineTranslationStatus::Invalid => report.invalid += 1,
485    }
486}
487
488fn review_summary(
489    source_changes: &CatalogSourceChangeReport,
490    locales: &[CatalogLocaleReview],
491) -> CatalogReviewSummary {
492    let mut summary = CatalogReviewSummary {
493        source_added: source_changes.added,
494        source_removed: source_changes.removed,
495        target_locales: locales.len(),
496        ..CatalogReviewSummary::default()
497    };
498    for locale in locales {
499        summary.translation_changed += locale.translations.changed;
500        summary.machine_translation_current += locale.machine_translation.current;
501        summary.machine_translation_stale += locale.machine_translation.stale;
502        summary.machine_translation_absent += locale.machine_translation.absent;
503        summary.machine_translation_invalid += locale.machine_translation.invalid;
504    }
505    summary
506}
507
508fn owned_translation(value: EffectiveTranslationRef<'_>) -> CatalogReviewTranslation {
509    match value {
510        EffectiveTranslationRef::Singular(value) => {
511            CatalogReviewTranslation::Singular(value.to_owned())
512        }
513        EffectiveTranslationRef::Plural(values) => CatalogReviewTranslation::Plural(values.clone()),
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use std::collections::BTreeMap;
520
521    use super::{
522        CatalogMachineTranslationStatus, CatalogReviewOptions, CatalogReviewTranslation,
523        CatalogSourceChangeKind, review_catalogs,
524    };
525    use crate::api::{
526        AiProvenance, ApiError, CatalogMessage, CatalogMessageKey, CatalogMode, CatalogSemantics,
527        EffectiveTranslationRef, MachineMetadata, ParseCatalogOptions, ParsedCatalog,
528        TranslationShape, machine_translation_hash, parse_catalog_for_review,
529    };
530
531    fn catalog(content: &str, locale: &str) -> crate::api::NormalizedParsedCatalog {
532        catalog_with_mode(content, Some(locale), CatalogMode::IcuPo)
533    }
534
535    fn catalog_with_locale(
536        content: &str,
537        locale: Option<&str>,
538    ) -> crate::api::NormalizedParsedCatalog {
539        catalog_with_mode(content, locale, CatalogMode::IcuPo)
540    }
541
542    fn gettext_catalog(content: &str, locale: &str) -> crate::api::NormalizedParsedCatalog {
543        catalog_with_mode(content, Some(locale), CatalogMode::GettextPo)
544    }
545
546    fn catalog_with_mode(
547        content: &str,
548        locale: Option<&str>,
549        mode: CatalogMode,
550    ) -> crate::api::NormalizedParsedCatalog {
551        parse_catalog_for_review(ParseCatalogOptions {
552            locale,
553            mode,
554            ..ParseCatalogOptions::new(content, "en")
555        })
556        .expect("parse catalog")
557    }
558
559    fn catalog_with_messages(
560        locale: &str,
561        messages: Vec<CatalogMessage>,
562    ) -> crate::api::NormalizedParsedCatalog {
563        ParsedCatalog {
564            locale: Some(locale.to_owned()),
565            semantics: CatalogSemantics::IcuNative,
566            headers: BTreeMap::new(),
567            messages,
568            diagnostics: Vec::new(),
569        }
570        .into_normalized_view_assuming_no_fuzzy()
571        .expect("normalize catalog")
572    }
573
574    fn error_debug(error: ApiError) -> String {
575        format!("{error:?}")
576    }
577
578    #[test]
579    fn review_catalogs_reports_source_and_target_changes() {
580        let previous_source = catalog(
581            "msgid \"Hello\"\nmsgstr \"Hello\"\n\nmsgid \"Removed\"\nmsgstr \"Removed\"\n",
582            "en",
583        );
584        let current_source = catalog(
585            "msgid \"Hello\"\nmsgstr \"Hello\"\n\nmsgid \"Added\"\nmsgstr \"Added\"\n",
586            "en",
587        );
588        let previous_target = catalog(
589            "msgid \"Hello\"\nmsgstr \"Hallo\"\n\nmsgid \"Removed\"\nmsgstr \"Entfernt\"\n",
590            "de",
591        );
592        let current_target = catalog(
593            "msgid \"Hello\"\nmsgstr \"Hallo neu\"\n\nmsgid \"Added\"\nmsgstr \"\"\n\nmsgid \"Extra\"\nmsgstr \"Extra\"\n",
594            "de",
595        );
596
597        let report = review_catalogs(
598            &[&previous_source, &previous_target],
599            &[&current_source, &current_target],
600            &CatalogReviewOptions::new("en").with_details(true),
601        )
602        .expect("review");
603        let locale = &report.locales[0];
604
605        assert_eq!(report.summary.source_added, 1);
606        assert_eq!(report.summary.source_removed, 1);
607        assert_eq!(report.summary.translation_changed, 1);
608        assert!(report.source_changes.details.iter().any(|change| {
609            change.source_key == CatalogMessageKey::new("Added", None)
610                && change.kind == CatalogSourceChangeKind::Added
611        }));
612        assert_eq!(locale.coverage.empty, 1);
613        assert_eq!(locale.coverage.extra, 1);
614        assert_eq!(
615            locale.translations.details[0].current,
616            CatalogReviewTranslation::Singular("Hallo neu".to_owned())
617        );
618    }
619
620    #[test]
621    fn review_catalogs_reports_machine_translation_freshness() {
622        let hash = machine_translation_hash(EffectiveTranslationRef::Singular("Hallo"));
623        let source = catalog(
624            "msgid \"Hello\"\nmsgstr \"Hello\"\n\nmsgid \"Stale\"\nmsgstr \"Stale\"\n\nmsgid \"Absent\"\nmsgstr \"Absent\"\n",
625            "en",
626        );
627        let target = catalog(
628            &format!(
629                concat!(
630                    "#@ lock: {}\n#@ ai: openai/gpt-5.5-high\n",
631                    "msgid \"Hello\"\nmsgstr \"Hallo\"\n\n",
632                    "#@ lock: old\n#@ ai: openai/gpt-5.5-high\n",
633                    "msgid \"Stale\"\nmsgstr \"Alt\"\n\n",
634                    "msgid \"Absent\"\nmsgstr \"Ohne\"\n",
635                ),
636                hash
637            ),
638            "de",
639        );
640
641        let report = review_catalogs(
642            &[&source, &target],
643            &[&source, &target],
644            &CatalogReviewOptions::new("en").with_details(true),
645        )
646        .expect("review");
647        let machine_translation = &report.locales[0].machine_translation;
648
649        assert_eq!(machine_translation.current, 1);
650        assert_eq!(machine_translation.stale, 1);
651        assert_eq!(machine_translation.absent, 1);
652        assert!(machine_translation.details.iter().any(|detail| {
653            detail.source_key == CatalogMessageKey::new("Stale", None)
654                && detail.status == CatalogMachineTranslationStatus::Stale
655        }));
656    }
657
658    #[test]
659    fn review_catalogs_reports_invalid_machine_translation_metadata() {
660        let source = catalog("msgid \"Hello\"\nmsgstr \"Hello\"\n", "en");
661        let target = catalog_with_messages(
662            "de",
663            vec![CatalogMessage {
664                msgid: "Hello".to_owned(),
665                msgctxt: None,
666                translation: TranslationShape::Singular {
667                    value: "Hallo".to_owned(),
668                },
669                comments: Vec::new(),
670                origin: crate::PoVec::new(),
671                obsolete: None,
672                machine: Some(MachineMetadata {
673                    lock: machine_translation_hash(EffectiveTranslationRef::Singular("Hallo")),
674                    ai: Some(AiProvenance {
675                        model: String::new(),
676                        confidence: None,
677                    }),
678                }),
679            }],
680        );
681
682        let report = review_catalogs(
683            &[&source, &target],
684            &[&source, &target],
685            &CatalogReviewOptions::new("en").with_details(true),
686        )
687        .expect("review");
688        let machine_translation = &report.locales[0].machine_translation;
689
690        assert_eq!(machine_translation.invalid, 1);
691        assert_eq!(report.summary.machine_translation_invalid, 1);
692        assert!(machine_translation.details.iter().any(|detail| {
693            detail.source_key == CatalogMessageKey::new("Hello", None)
694                && detail.status == CatalogMachineTranslationStatus::Invalid
695        }));
696    }
697
698    #[test]
699    fn review_catalogs_can_return_summary_only() {
700        let source = catalog("msgid \"Hello\"\nmsgstr \"Hello\"\n", "en");
701        let target = catalog("msgid \"Hello\"\nmsgstr \"Hallo\"\n", "de");
702
703        let report = review_catalogs(
704            &[&source, &target],
705            &[&source, &target],
706            &CatalogReviewOptions::new("en"),
707        )
708        .expect("review");
709
710        assert!(report.source_changes.details.is_empty());
711        assert!(report.locales[0].translations.details.is_empty());
712        assert!(report.locales[0].machine_translation.details.is_empty());
713        assert!(report.locales[0].coverage.details.is_empty());
714        assert_eq!(report.locales[0].coverage.translated, 1);
715    }
716
717    #[test]
718    fn review_catalogs_rejects_invalid_locale_inputs() {
719        let source = catalog("msgid \"Hello\"\nmsgstr \"Hello\"\n", "en");
720        let duplicate_source = catalog("msgid \"Bye\"\nmsgstr \"Bye\"\n", "en");
721        let missing_locale = catalog_with_locale("msgid \"Hello\"\nmsgstr \"Hallo\"\n", None);
722        let target = catalog("msgid \"Hello\"\nmsgstr \"Hallo\"\n", "de");
723        let requested = ["fr"];
724
725        let missing_previous_source = review_catalogs(
726            &[&target],
727            &[&source, &target],
728            &CatalogReviewOptions::new("en"),
729        )
730        .expect_err("missing previous source should fail");
731        assert!(error_debug(missing_previous_source).contains("previous catalogs"));
732
733        let missing_current_source = review_catalogs(
734            &[&source, &target],
735            &[&target],
736            &CatalogReviewOptions::new("en"),
737        )
738        .expect_err("missing current source should fail");
739        assert!(error_debug(missing_current_source).contains("current catalogs"));
740
741        let undeclared_locale = review_catalogs(
742            &[&missing_locale],
743            &[&source, &target],
744            &CatalogReviewOptions::new("en"),
745        )
746        .expect_err("missing declared locale should fail");
747        assert!(error_debug(undeclared_locale).contains("declare a locale"));
748
749        let duplicate_locale = review_catalogs(
750            &[&source, &duplicate_source],
751            &[&source, &target],
752            &CatalogReviewOptions::new("en"),
753        )
754        .expect_err("duplicate locale should fail");
755        assert!(error_debug(duplicate_locale).contains("duplicate catalog locale"));
756
757        let missing_requested = review_catalogs(
758            &[&source, &target],
759            &[&source, &target],
760            &CatalogReviewOptions {
761                locales: &requested,
762                ..CatalogReviewOptions::new("en")
763            },
764        )
765        .expect_err("missing requested locale should fail");
766        assert!(error_debug(missing_requested).contains("requested locale"));
767    }
768
769    #[test]
770    fn review_catalogs_filters_requested_locales_and_handles_new_target_locale() {
771        let source = catalog("msgid \"Hello\"\nmsgstr \"Hello\"\n", "en");
772        let de = catalog("msgid \"Hello\"\nmsgstr \"Hallo\"\n", "de");
773        let fr = catalog("msgid \"Hello\"\nmsgstr \"Bonjour\"\n", "fr");
774        let requested = ["en", "de", "de", "fr"];
775
776        let report = review_catalogs(
777            &[&source],
778            &[&source, &de, &fr],
779            &CatalogReviewOptions {
780                locales: &requested,
781                ..CatalogReviewOptions::new("en")
782            },
783        )
784        .expect("review");
785
786        assert_eq!(report.summary.target_locales, 2);
787        assert_eq!(report.locales[0].locale, "de");
788        assert_eq!(report.locales[1].locale, "fr");
789        assert_eq!(report.summary.translation_changed, 0);
790        assert_eq!(report.locales[0].translations.changed, 0);
791    }
792
793    #[test]
794    fn review_catalogs_ignores_new_messages_when_tracking_translation_changes() {
795        let previous_source = catalog("msgid \"Hello\"\nmsgstr \"Hello\"\n", "en");
796        let current_source = catalog(
797            "msgid \"Hello\"\nmsgstr \"Hello\"\n\nmsgid \"Added\"\nmsgstr \"Added\"\n",
798            "en",
799        );
800        let previous_target = catalog("msgid \"Hello\"\nmsgstr \"Hallo\"\n", "de");
801        let current_target = catalog(
802            "msgid \"Hello\"\nmsgstr \"Hallo\"\n\nmsgid \"Added\"\nmsgstr \"Neu\"\n",
803            "de",
804        );
805
806        let report = review_catalogs(
807            &[&previous_source, &previous_target],
808            &[&current_source, &current_target],
809            &CatalogReviewOptions::new("en").with_details(true),
810        )
811        .expect("review");
812
813        assert_eq!(report.locales[0].translations.changed, 0);
814        assert!(report.locales[0].translations.details.is_empty());
815    }
816
817    #[test]
818    fn review_catalogs_reports_plural_translation_changes() {
819        let previous_source = gettext_catalog(
820            concat!(
821                "msgid \"book\"\n",
822                "msgid_plural \"books\"\n",
823                "msgstr[0] \"book\"\n",
824                "msgstr[1] \"books\"\n",
825            ),
826            "en",
827        );
828        let current_source = gettext_catalog(
829            concat!(
830                "msgid \"book\"\n",
831                "msgid_plural \"books\"\n",
832                "msgstr[0] \"book\"\n",
833                "msgstr[1] \"books\"\n",
834            ),
835            "en",
836        );
837        let previous_target = gettext_catalog(
838            concat!(
839                "msgid \"book\"\n",
840                "msgid_plural \"books\"\n",
841                "msgstr[0] \"Buch\"\n",
842                "msgstr[1] \"Buecher\"\n",
843            ),
844            "de",
845        );
846        let current_target = gettext_catalog(
847            concat!(
848                "msgid \"book\"\n",
849                "msgid_plural \"books\"\n",
850                "msgstr[0] \"Buch\"\n",
851                "msgstr[1] \"Buecher neu\"\n",
852            ),
853            "de",
854        );
855
856        let report = review_catalogs(
857            &[&previous_source, &previous_target],
858            &[&current_source, &current_target],
859            &CatalogReviewOptions::new("en").with_details(true),
860        )
861        .expect("review");
862        let detail = &report.locales[0].translations.details[0];
863
864        assert_eq!(report.locales[0].translations.changed, 1);
865        assert!(matches!(
866            detail.current,
867            CatalogReviewTranslation::Plural(_)
868        ));
869    }
870
871    #[test]
872    fn review_catalogs_skips_obsolete_machine_translation_entries() {
873        let source = catalog("msgid \"Hello\"\nmsgstr \"Hello\"\n", "en");
874        let target = catalog(
875            "msgid \"Hello\"\nmsgstr \"Hallo\"\n\n#~ msgid \"Old\"\n#~ msgstr \"Alt\"\n",
876            "de",
877        );
878
879        let report = review_catalogs(
880            &[&source, &target],
881            &[&source, &target],
882            &CatalogReviewOptions::new("en").with_details(true),
883        )
884        .expect("review");
885        let machine_translation = &report.locales[0].machine_translation;
886
887        assert_eq!(machine_translation.absent, 1);
888        assert_eq!(machine_translation.details.len(), 1);
889        assert_eq!(
890            machine_translation.details[0].source_key,
891            CatalogMessageKey::new("Hello", None)
892        );
893    }
894}