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 status is [`CatalogMessageStatus::Translated`]; missing, empty, and
226/// obsolete current entries are surfaced by the coverage counters.
227///
228/// # Examples
229///
230/// ```rust
231/// use ferrocat_po::{CatalogReviewOptions, ParseCatalogOptions, review_catalogs, parse_catalog};
232///
233/// let previous_source = parse_catalog(
234///     ParseCatalogOptions::new("msgid \"Save\"\nmsgstr \"\"\n", "en").with_locale("en"),
235/// )?
236/// .into_normalized_view()?;
237/// let previous_target = parse_catalog(
238///     ParseCatalogOptions::new("msgid \"Save\"\nmsgstr \"Speichern\"\n", "en")
239///         .with_locale("de"),
240/// )?
241/// .into_normalized_view()?;
242///
243/// let current_source = parse_catalog(
244///     ParseCatalogOptions::new(
245///         "msgid \"Save\"\nmsgstr \"\"\n\nmsgid \"Cancel\"\nmsgstr \"\"\n",
246///         "en",
247///     )
248///     .with_locale("en"),
249/// )?
250/// .into_normalized_view()?;
251/// let current_target = parse_catalog(
252///     ParseCatalogOptions::new(
253///         "msgid \"Save\"\nmsgstr \"Sichern\"\n\nmsgid \"Cancel\"\nmsgstr \"\"\n",
254///         "en",
255///     )
256///     .with_locale("de"),
257/// )?
258/// .into_normalized_view()?;
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.
278pub fn review_catalogs(
279    previous_catalogs: &[&NormalizedParsedCatalog],
280    current_catalogs: &[&NormalizedParsedCatalog],
281    options: &CatalogReviewOptions<'_>,
282) -> Result<CatalogReviewReport, ApiError> {
283    validate_source_locale(options.source_locale)?;
284    let previous_index = index_catalogs(previous_catalogs, "review_catalogs previous")?;
285    let current_index = index_catalogs(current_catalogs, "review_catalogs current")?;
286    let previous_source = previous_index
287        .get(options.source_locale)
288        .copied()
289        .ok_or_else(|| missing_source_error("previous", options.source_locale))?;
290    let current_source = current_index
291        .get(options.source_locale)
292        .copied()
293        .ok_or_else(|| missing_source_error("current", options.source_locale))?;
294    let previous_source_keys = active_message_keys(previous_source);
295    let current_source_keys = active_message_keys(current_source);
296    let target_locales = select_target_locales(
297        &current_index,
298        options.source_locale,
299        options.locales,
300        "review_catalogs",
301    )?;
302    let source_changes = source_change_report(
303        &previous_source_keys,
304        &current_source_keys,
305        options.include_details,
306    );
307    let coverage_options = CatalogCoverageOptions {
308        source_locale: options.source_locale,
309        locales: options.locales,
310        include_details: options.include_details,
311    };
312    let coverage = measure_catalog_coverage(current_catalogs, &coverage_options)?;
313    let mut locales = Vec::with_capacity(target_locales.len());
314
315    for locale in target_locales {
316        let current_target = current_index
317            .get(locale.as_str())
318            .expect("selected target locale must exist");
319        let previous_target = previous_index.get(locale.as_str()).copied();
320        let locale_coverage = coverage
321            .locales
322            .iter()
323            .find(|entry| entry.locale == locale)
324            .expect("coverage locale must exist")
325            .clone();
326        let translations = translation_change_report(
327            &locale,
328            previous_target,
329            current_target,
330            &current_source_keys,
331            options.include_details,
332        );
333        let machine_translation =
334            machine_translation_review(&locale, current_target, options.include_details);
335        locales.push(CatalogLocaleReview {
336            locale,
337            coverage: locale_coverage,
338            translations,
339            machine_translation,
340        });
341    }
342
343    let summary = review_summary(&source_changes, &locales);
344    Ok(CatalogReviewReport {
345        summary,
346        source_changes,
347        locales,
348    })
349}
350
351fn missing_source_error(state: &str, source_locale: &str) -> ApiError {
352    ApiError::InvalidArguments(format!(
353        "review_catalogs {state} catalogs did not receive source locale {source_locale:?}"
354    ))
355}
356
357fn source_change_report(
358    previous_keys: &BTreeSet<CatalogMessageKey>,
359    current_keys: &BTreeSet<CatalogMessageKey>,
360    include_details: bool,
361) -> CatalogSourceChangeReport {
362    let added_keys = current_keys.difference(previous_keys);
363    let removed_keys = previous_keys.difference(current_keys);
364    let mut report = CatalogSourceChangeReport {
365        added: added_keys.clone().count(),
366        removed: removed_keys.clone().count(),
367        details: Vec::new(),
368    };
369
370    if include_details {
371        report
372            .details
373            .extend(added_keys.map(|source_key| CatalogSourceChange {
374                source_key: source_key.clone(),
375                kind: CatalogSourceChangeKind::Added,
376            }));
377        report
378            .details
379            .extend(removed_keys.map(|source_key| CatalogSourceChange {
380                source_key: source_key.clone(),
381                kind: CatalogSourceChangeKind::Removed,
382            }));
383    }
384
385    report
386}
387
388fn translation_change_report(
389    locale: &str,
390    previous_target: Option<&NormalizedParsedCatalog>,
391    current_target: &NormalizedParsedCatalog,
392    current_source_keys: &BTreeSet<CatalogMessageKey>,
393    include_details: bool,
394) -> CatalogTranslationChangeReport {
395    let Some(previous_target) = previous_target else {
396        return CatalogTranslationChangeReport::default();
397    };
398    let mut report = CatalogTranslationChangeReport::default();
399
400    for source_key in current_source_keys {
401        if classify_expected_message(current_target, source_key) != CatalogMessageStatus::Translated
402        {
403            continue;
404        }
405        let Some(previous_message) = previous_target
406            .get(source_key)
407            .filter(|message| message.obsolete.is_none())
408        else {
409            continue;
410        };
411        let current_message = current_target
412            .get(source_key)
413            .filter(|message| message.obsolete.is_none())
414            .expect("translated classification must have an active current message");
415        let previous = previous_message.effective_translation();
416        let current = current_message.effective_translation();
417        if previous == current {
418            continue;
419        }
420        report.changed += 1;
421        if include_details {
422            report.details.push(CatalogTranslationChange {
423                locale: locale.to_owned(),
424                source_key: source_key.clone(),
425                previous: owned_translation(previous),
426                current: owned_translation(current),
427            });
428        }
429    }
430
431    report
432}
433
434fn machine_translation_review(
435    locale: &str,
436    current_target: &NormalizedParsedCatalog,
437    include_details: bool,
438) -> CatalogMachineTranslationReview {
439    let mut report = CatalogMachineTranslationReview::default();
440
441    for (source_key, message) in current_target.iter() {
442        if message.obsolete.is_some() {
443            continue;
444        }
445        let status = machine_translation_status(message);
446        increment_machine_translation_status(&mut report, status);
447        if include_details {
448            report.details.push(CatalogMachineTranslationMessage {
449                locale: locale.to_owned(),
450                source_key: source_key.clone(),
451                status,
452            });
453        }
454    }
455
456    report
457}
458
459fn machine_translation_status(message: &CatalogMessage) -> CatalogMachineTranslationStatus {
460    let Some(metadata) = message.machine.as_ref() else {
461        return CatalogMachineTranslationStatus::Absent;
462    };
463    if validate_machine_metadata(metadata).is_err() {
464        return CatalogMachineTranslationStatus::Invalid;
465    }
466    if metadata.lock == machine_translation_hash(message.effective_translation()) {
467        CatalogMachineTranslationStatus::Current
468    } else {
469        CatalogMachineTranslationStatus::Stale
470    }
471}
472
473fn increment_machine_translation_status(
474    report: &mut CatalogMachineTranslationReview,
475    status: CatalogMachineTranslationStatus,
476) {
477    match status {
478        CatalogMachineTranslationStatus::Current => report.current += 1,
479        CatalogMachineTranslationStatus::Stale => report.stale += 1,
480        CatalogMachineTranslationStatus::Absent => report.absent += 1,
481        CatalogMachineTranslationStatus::Invalid => report.invalid += 1,
482    }
483}
484
485fn review_summary(
486    source_changes: &CatalogSourceChangeReport,
487    locales: &[CatalogLocaleReview],
488) -> CatalogReviewSummary {
489    let mut summary = CatalogReviewSummary {
490        source_added: source_changes.added,
491        source_removed: source_changes.removed,
492        target_locales: locales.len(),
493        ..CatalogReviewSummary::default()
494    };
495    for locale in locales {
496        summary.translation_changed += locale.translations.changed;
497        summary.machine_translation_current += locale.machine_translation.current;
498        summary.machine_translation_stale += locale.machine_translation.stale;
499        summary.machine_translation_absent += locale.machine_translation.absent;
500        summary.machine_translation_invalid += locale.machine_translation.invalid;
501    }
502    summary
503}
504
505fn owned_translation(value: EffectiveTranslationRef<'_>) -> CatalogReviewTranslation {
506    match value {
507        EffectiveTranslationRef::Singular(value) => {
508            CatalogReviewTranslation::Singular(value.to_owned())
509        }
510        EffectiveTranslationRef::Plural(values) => CatalogReviewTranslation::Plural(values.clone()),
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use std::collections::BTreeMap;
517
518    use super::{
519        CatalogMachineTranslationStatus, CatalogReviewOptions, CatalogReviewTranslation,
520        CatalogSourceChangeKind, review_catalogs,
521    };
522    use crate::api::{
523        AiProvenance, ApiError, CatalogMessage, CatalogMessageKey, CatalogMode, CatalogSemantics,
524        EffectiveTranslationRef, MachineMetadata, ParseCatalogOptions, ParsedCatalog,
525        TranslationShape, machine_translation_hash, parse_catalog,
526    };
527
528    fn catalog(content: &str, locale: &str) -> crate::api::NormalizedParsedCatalog {
529        catalog_with_mode(content, Some(locale), CatalogMode::IcuPo)
530    }
531
532    fn catalog_with_locale(
533        content: &str,
534        locale: Option<&str>,
535    ) -> crate::api::NormalizedParsedCatalog {
536        catalog_with_mode(content, locale, CatalogMode::IcuPo)
537    }
538
539    fn gettext_catalog(content: &str, locale: &str) -> crate::api::NormalizedParsedCatalog {
540        catalog_with_mode(content, Some(locale), CatalogMode::GettextPo)
541    }
542
543    fn catalog_with_mode(
544        content: &str,
545        locale: Option<&str>,
546        mode: CatalogMode,
547    ) -> crate::api::NormalizedParsedCatalog {
548        parse_catalog(ParseCatalogOptions {
549            locale,
550            mode,
551            ..ParseCatalogOptions::new(content, "en")
552        })
553        .expect("parse catalog")
554        .into_normalized_view()
555        .expect("normalize catalog")
556    }
557
558    fn catalog_with_messages(
559        locale: &str,
560        messages: Vec<CatalogMessage>,
561    ) -> crate::api::NormalizedParsedCatalog {
562        ParsedCatalog {
563            locale: Some(locale.to_owned()),
564            semantics: CatalogSemantics::IcuNative,
565            headers: BTreeMap::new(),
566            messages,
567            diagnostics: Vec::new(),
568        }
569        .into_normalized_view()
570        .expect("normalize catalog")
571    }
572
573    fn error_debug(error: ApiError) -> String {
574        format!("{error:?}")
575    }
576
577    #[test]
578    fn review_catalogs_reports_source_and_target_changes() {
579        let previous_source = catalog(
580            "msgid \"Hello\"\nmsgstr \"Hello\"\n\nmsgid \"Removed\"\nmsgstr \"Removed\"\n",
581            "en",
582        );
583        let current_source = catalog(
584            "msgid \"Hello\"\nmsgstr \"Hello\"\n\nmsgid \"Added\"\nmsgstr \"Added\"\n",
585            "en",
586        );
587        let previous_target = catalog(
588            "msgid \"Hello\"\nmsgstr \"Hallo\"\n\nmsgid \"Removed\"\nmsgstr \"Entfernt\"\n",
589            "de",
590        );
591        let current_target = catalog(
592            "msgid \"Hello\"\nmsgstr \"Hallo neu\"\n\nmsgid \"Added\"\nmsgstr \"\"\n\nmsgid \"Extra\"\nmsgstr \"Extra\"\n",
593            "de",
594        );
595
596        let report = review_catalogs(
597            &[&previous_source, &previous_target],
598            &[&current_source, &current_target],
599            &CatalogReviewOptions::new("en").with_details(true),
600        )
601        .expect("review");
602        let locale = &report.locales[0];
603
604        assert_eq!(report.summary.source_added, 1);
605        assert_eq!(report.summary.source_removed, 1);
606        assert_eq!(report.summary.translation_changed, 1);
607        assert!(report.source_changes.details.iter().any(|change| {
608            change.source_key == CatalogMessageKey::new("Added", None)
609                && change.kind == CatalogSourceChangeKind::Added
610        }));
611        assert_eq!(locale.coverage.empty, 1);
612        assert_eq!(locale.coverage.extra, 1);
613        assert_eq!(
614            locale.translations.details[0].current,
615            CatalogReviewTranslation::Singular("Hallo neu".to_owned())
616        );
617    }
618
619    #[test]
620    fn review_catalogs_reports_machine_translation_freshness() {
621        let hash = machine_translation_hash(EffectiveTranslationRef::Singular("Hallo"));
622        let source = catalog(
623            "msgid \"Hello\"\nmsgstr \"Hello\"\n\nmsgid \"Stale\"\nmsgstr \"Stale\"\n\nmsgid \"Absent\"\nmsgstr \"Absent\"\n",
624            "en",
625        );
626        let target = catalog(
627            &format!(
628                concat!(
629                    "#@ lock: {}\n#@ ai: openai/gpt-5.5-high\n",
630                    "msgid \"Hello\"\nmsgstr \"Hallo\"\n\n",
631                    "#@ lock: old\n#@ ai: openai/gpt-5.5-high\n",
632                    "msgid \"Stale\"\nmsgstr \"Alt\"\n\n",
633                    "msgid \"Absent\"\nmsgstr \"Ohne\"\n",
634                ),
635                hash
636            ),
637            "de",
638        );
639
640        let report = review_catalogs(
641            &[&source, &target],
642            &[&source, &target],
643            &CatalogReviewOptions::new("en").with_details(true),
644        )
645        .expect("review");
646        let machine_translation = &report.locales[0].machine_translation;
647
648        assert_eq!(machine_translation.current, 1);
649        assert_eq!(machine_translation.stale, 1);
650        assert_eq!(machine_translation.absent, 1);
651        assert!(machine_translation.details.iter().any(|detail| {
652            detail.source_key == CatalogMessageKey::new("Stale", None)
653                && detail.status == CatalogMachineTranslationStatus::Stale
654        }));
655    }
656
657    #[test]
658    fn review_catalogs_reports_invalid_machine_translation_metadata() {
659        let source = catalog("msgid \"Hello\"\nmsgstr \"Hello\"\n", "en");
660        let target = catalog_with_messages(
661            "de",
662            vec![CatalogMessage {
663                msgid: "Hello".to_owned(),
664                msgctxt: None,
665                translation: TranslationShape::Singular {
666                    value: "Hallo".to_owned(),
667                },
668                comments: Vec::new(),
669                origin: crate::PoVec::new(),
670                obsolete: None,
671                machine: Some(MachineMetadata {
672                    lock: machine_translation_hash(EffectiveTranslationRef::Singular("Hallo")),
673                    ai: Some(AiProvenance {
674                        model: String::new(),
675                        confidence: None,
676                    }),
677                }),
678            }],
679        );
680
681        let report = review_catalogs(
682            &[&source, &target],
683            &[&source, &target],
684            &CatalogReviewOptions::new("en").with_details(true),
685        )
686        .expect("review");
687        let machine_translation = &report.locales[0].machine_translation;
688
689        assert_eq!(machine_translation.invalid, 1);
690        assert_eq!(report.summary.machine_translation_invalid, 1);
691        assert!(machine_translation.details.iter().any(|detail| {
692            detail.source_key == CatalogMessageKey::new("Hello", None)
693                && detail.status == CatalogMachineTranslationStatus::Invalid
694        }));
695    }
696
697    #[test]
698    fn review_catalogs_can_return_summary_only() {
699        let source = catalog("msgid \"Hello\"\nmsgstr \"Hello\"\n", "en");
700        let target = catalog("msgid \"Hello\"\nmsgstr \"Hallo\"\n", "de");
701
702        let report = review_catalogs(
703            &[&source, &target],
704            &[&source, &target],
705            &CatalogReviewOptions::new("en"),
706        )
707        .expect("review");
708
709        assert!(report.source_changes.details.is_empty());
710        assert!(report.locales[0].translations.details.is_empty());
711        assert!(report.locales[0].machine_translation.details.is_empty());
712        assert!(report.locales[0].coverage.details.is_empty());
713        assert_eq!(report.locales[0].coverage.translated, 1);
714    }
715
716    #[test]
717    fn review_catalogs_rejects_invalid_locale_inputs() {
718        let source = catalog("msgid \"Hello\"\nmsgstr \"Hello\"\n", "en");
719        let duplicate_source = catalog("msgid \"Bye\"\nmsgstr \"Bye\"\n", "en");
720        let missing_locale = catalog_with_locale("msgid \"Hello\"\nmsgstr \"Hallo\"\n", None);
721        let target = catalog("msgid \"Hello\"\nmsgstr \"Hallo\"\n", "de");
722        let requested = ["fr"];
723
724        let missing_previous_source = review_catalogs(
725            &[&target],
726            &[&source, &target],
727            &CatalogReviewOptions::new("en"),
728        )
729        .expect_err("missing previous source should fail");
730        assert!(error_debug(missing_previous_source).contains("previous catalogs"));
731
732        let missing_current_source = review_catalogs(
733            &[&source, &target],
734            &[&target],
735            &CatalogReviewOptions::new("en"),
736        )
737        .expect_err("missing current source should fail");
738        assert!(error_debug(missing_current_source).contains("current catalogs"));
739
740        let undeclared_locale = review_catalogs(
741            &[&missing_locale],
742            &[&source, &target],
743            &CatalogReviewOptions::new("en"),
744        )
745        .expect_err("missing declared locale should fail");
746        assert!(error_debug(undeclared_locale).contains("declare a locale"));
747
748        let duplicate_locale = review_catalogs(
749            &[&source, &duplicate_source],
750            &[&source, &target],
751            &CatalogReviewOptions::new("en"),
752        )
753        .expect_err("duplicate locale should fail");
754        assert!(error_debug(duplicate_locale).contains("duplicate catalog locale"));
755
756        let missing_requested = review_catalogs(
757            &[&source, &target],
758            &[&source, &target],
759            &CatalogReviewOptions {
760                locales: &requested,
761                ..CatalogReviewOptions::new("en")
762            },
763        )
764        .expect_err("missing requested locale should fail");
765        assert!(error_debug(missing_requested).contains("requested locale"));
766    }
767
768    #[test]
769    fn review_catalogs_filters_requested_locales_and_handles_new_target_locale() {
770        let source = catalog("msgid \"Hello\"\nmsgstr \"Hello\"\n", "en");
771        let de = catalog("msgid \"Hello\"\nmsgstr \"Hallo\"\n", "de");
772        let fr = catalog("msgid \"Hello\"\nmsgstr \"Bonjour\"\n", "fr");
773        let requested = ["en", "de", "de", "fr"];
774
775        let report = review_catalogs(
776            &[&source],
777            &[&source, &de, &fr],
778            &CatalogReviewOptions {
779                locales: &requested,
780                ..CatalogReviewOptions::new("en")
781            },
782        )
783        .expect("review");
784
785        assert_eq!(report.summary.target_locales, 2);
786        assert_eq!(report.locales[0].locale, "de");
787        assert_eq!(report.locales[1].locale, "fr");
788        assert_eq!(report.summary.translation_changed, 0);
789        assert_eq!(report.locales[0].translations.changed, 0);
790    }
791
792    #[test]
793    fn review_catalogs_ignores_new_messages_when_tracking_translation_changes() {
794        let previous_source = catalog("msgid \"Hello\"\nmsgstr \"Hello\"\n", "en");
795        let current_source = catalog(
796            "msgid \"Hello\"\nmsgstr \"Hello\"\n\nmsgid \"Added\"\nmsgstr \"Added\"\n",
797            "en",
798        );
799        let previous_target = catalog("msgid \"Hello\"\nmsgstr \"Hallo\"\n", "de");
800        let current_target = catalog(
801            "msgid \"Hello\"\nmsgstr \"Hallo\"\n\nmsgid \"Added\"\nmsgstr \"Neu\"\n",
802            "de",
803        );
804
805        let report = review_catalogs(
806            &[&previous_source, &previous_target],
807            &[&current_source, &current_target],
808            &CatalogReviewOptions::new("en").with_details(true),
809        )
810        .expect("review");
811
812        assert_eq!(report.locales[0].translations.changed, 0);
813        assert!(report.locales[0].translations.details.is_empty());
814    }
815
816    #[test]
817    fn review_catalogs_reports_plural_translation_changes() {
818        let previous_source = gettext_catalog(
819            concat!(
820                "msgid \"book\"\n",
821                "msgid_plural \"books\"\n",
822                "msgstr[0] \"book\"\n",
823                "msgstr[1] \"books\"\n",
824            ),
825            "en",
826        );
827        let current_source = gettext_catalog(
828            concat!(
829                "msgid \"book\"\n",
830                "msgid_plural \"books\"\n",
831                "msgstr[0] \"book\"\n",
832                "msgstr[1] \"books\"\n",
833            ),
834            "en",
835        );
836        let previous_target = gettext_catalog(
837            concat!(
838                "msgid \"book\"\n",
839                "msgid_plural \"books\"\n",
840                "msgstr[0] \"Buch\"\n",
841                "msgstr[1] \"Buecher\"\n",
842            ),
843            "de",
844        );
845        let current_target = gettext_catalog(
846            concat!(
847                "msgid \"book\"\n",
848                "msgid_plural \"books\"\n",
849                "msgstr[0] \"Buch\"\n",
850                "msgstr[1] \"Buecher neu\"\n",
851            ),
852            "de",
853        );
854
855        let report = review_catalogs(
856            &[&previous_source, &previous_target],
857            &[&current_source, &current_target],
858            &CatalogReviewOptions::new("en").with_details(true),
859        )
860        .expect("review");
861        let detail = &report.locales[0].translations.details[0];
862
863        assert_eq!(report.locales[0].translations.changed, 1);
864        assert!(matches!(
865            detail.current,
866            CatalogReviewTranslation::Plural(_)
867        ));
868    }
869
870    #[test]
871    fn review_catalogs_skips_obsolete_machine_translation_entries() {
872        let source = catalog("msgid \"Hello\"\nmsgstr \"Hello\"\n", "en");
873        let target = catalog(
874            "msgid \"Hello\"\nmsgstr \"Hallo\"\n\n#~ msgid \"Old\"\n#~ msgstr \"Alt\"\n",
875            "de",
876        );
877
878        let report = review_catalogs(
879            &[&source, &target],
880            &[&source, &target],
881            &CatalogReviewOptions::new("en").with_details(true),
882        )
883        .expect("review");
884        let machine_translation = &report.locales[0].machine_translation;
885
886        assert_eq!(machine_translation.absent, 1);
887        assert_eq!(machine_translation.details.len(), 1);
888        assert_eq!(
889            machine_translation.details[0].source_key,
890            CatalogMessageKey::new("Hello", None)
891        );
892    }
893}