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#[derive(Debug, Clone, PartialEq, Eq, Default)]
17pub struct CatalogReviewOptions<'a> {
18 pub source_locale: &'a str,
20 pub locales: &'a [&'a str],
22 pub include_details: bool,
24}
25
26impl<'a> CatalogReviewOptions<'a> {
27 #[must_use]
29 pub fn new(source_locale: &'a str) -> Self {
30 Self {
31 source_locale,
32 ..Self::default()
33 }
34 }
35
36 #[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#[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 pub summary: CatalogReviewSummary,
51 pub source_changes: CatalogSourceChangeReport,
53 pub locales: Vec<CatalogLocaleReview>,
55}
56
57#[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 pub source_added: usize,
64 pub source_removed: usize,
66 pub target_locales: usize,
68 pub translation_changed: usize,
70 pub machine_translation_current: usize,
72 pub machine_translation_stale: usize,
74 pub machine_translation_absent: usize,
76 pub machine_translation_invalid: usize,
78}
79
80#[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 pub added: usize,
87 pub removed: usize,
89 pub details: Vec<CatalogSourceChange>,
91}
92
93#[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 pub source_key: CatalogMessageKey,
100 pub kind: CatalogSourceChangeKind,
102}
103
104#[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 Added,
112 Removed,
114}
115
116#[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 pub locale: String,
123 pub coverage: CatalogLocaleCoverage,
125 pub translations: CatalogTranslationChangeReport,
127 pub machine_translation: CatalogMachineTranslationReview,
129}
130
131#[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 pub changed: usize,
138 pub details: Vec<CatalogTranslationChange>,
140}
141
142#[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 pub locale: String,
149 pub source_key: CatalogMessageKey,
151 pub previous: CatalogReviewTranslation,
153 pub current: CatalogReviewTranslation,
155}
156
157#[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(String),
167 Plural(BTreeMap<String, String>),
169}
170
171#[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 pub current: usize,
178 pub stale: usize,
180 pub absent: usize,
182 pub invalid: usize,
184 pub details: Vec<CatalogMachineTranslationMessage>,
186}
187
188#[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 pub locale: String,
195 pub source_key: CatalogMessageKey,
197 pub status: CatalogMachineTranslationStatus,
199}
200
201#[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 Current,
209 Stale,
211 Absent,
213 Invalid,
215}
216
217pub 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 ¤t_index,
252 options.source_locale,
253 options.locales,
254 "catalog_review",
255 )?;
256 let source_changes = source_change_report(
257 &previous_source_keys,
258 ¤t_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 ¤t_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 &[¤t_source, ¤t_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 &[¤t_source, ¤t_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 &[¤t_source, ¤t_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}