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#[derive(Debug, Clone, PartialEq, Eq, Default)]
17#[non_exhaustive]
18pub struct CatalogReviewOptions<'a> {
19 pub source_locale: &'a str,
21 pub locales: &'a [&'a str],
23 pub include_details: bool,
25}
26
27impl<'a> CatalogReviewOptions<'a> {
28 #[must_use]
30 pub fn new(source_locale: &'a str) -> Self {
31 Self {
32 source_locale,
33 ..Self::default()
34 }
35 }
36
37 #[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#[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 pub summary: CatalogReviewSummary,
52 pub source_changes: CatalogSourceChangeReport,
54 pub locales: Vec<CatalogLocaleReview>,
56}
57
58#[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 pub source_added: usize,
65 pub source_removed: usize,
67 pub target_locales: usize,
69 pub translation_changed: usize,
71 pub machine_translation_current: usize,
73 pub machine_translation_stale: usize,
75 pub machine_translation_absent: usize,
77 pub machine_translation_invalid: usize,
79}
80
81#[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 pub added: usize,
88 pub removed: usize,
90 pub details: Vec<CatalogSourceChange>,
92}
93
94#[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 pub source_key: CatalogMessageKey,
101 pub kind: CatalogSourceChangeKind,
103}
104
105#[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 Added,
113 Removed,
115}
116
117#[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 pub locale: String,
124 pub coverage: CatalogLocaleCoverage,
126 pub translations: CatalogTranslationChangeReport,
128 pub machine_translation: CatalogMachineTranslationReview,
130}
131
132#[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 pub changed: usize,
139 pub details: Vec<CatalogTranslationChange>,
141}
142
143#[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 pub locale: String,
150 pub source_key: CatalogMessageKey,
152 pub previous: CatalogReviewTranslation,
154 pub current: CatalogReviewTranslation,
156}
157
158#[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(String),
168 Plural(BTreeMap<String, String>),
170}
171
172#[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 pub current: usize,
179 pub stale: usize,
181 pub absent: usize,
183 pub invalid: usize,
185 pub details: Vec<CatalogMachineTranslationMessage>,
187}
188
189#[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 pub locale: String,
196 pub source_key: CatalogMessageKey,
198 pub status: CatalogMachineTranslationStatus,
200}
201
202#[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 Current,
210 Stale,
212 Absent,
214 Invalid,
216}
217
218pub 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 ¤t_index,
299 options.source_locale,
300 options.locales,
301 "review_catalogs",
302 )?;
303 let source_changes = source_change_report(
304 &previous_source_keys,
305 ¤t_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 ¤t_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 &[¤t_source, ¤t_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 &[¤t_source, ¤t_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 &[¤t_source, ¤t_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}