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(
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 ¤t_index,
298 options.source_locale,
299 options.locales,
300 "review_catalogs",
301 )?;
302 let source_changes = source_change_report(
303 &previous_source_keys,
304 ¤t_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 ¤t_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 &[¤t_source, ¤t_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 &[¤t_source, ¤t_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 &[¤t_source, ¤t_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}