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