es_fluent_generate/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use clap::ValueEnum;
4use es_fluent_core::meta::TypeKind;
5use es_fluent_core::namer::FluentKey;
6use es_fluent_core::registry::{FtlTypeInfo, FtlVariant};
7use fluent_syntax::{ast, parser};
8use std::collections::HashMap;
9use std::{fs, path::Path};
10
11pub mod clean;
12pub mod error;
13pub mod formatting;
14pub mod value;
15
16use error::FluentGenerateError;
17use value::ValueFormatter;
18
19/// The mode to use when parsing Fluent files.
20#[derive(Clone, Debug, Default, PartialEq, ValueEnum)]
21pub enum FluentParseMode {
22    /// Overwrite existing translations.
23    Aggressive,
24    /// Preserve existing translations.
25    #[default]
26    Conservative,
27}
28
29impl std::fmt::Display for FluentParseMode {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            Self::Aggressive => write!(f, "aggressive"),
33            Self::Conservative => write!(f, "conservative"),
34        }
35    }
36}
37
38/// Generates a Fluent translation file from a list of `FtlTypeInfo` objects.
39pub fn generate<P: AsRef<Path>>(
40    crate_name: &str,
41    i18n_path: P,
42    items: Vec<FtlTypeInfo>,
43    mode: FluentParseMode,
44    dry_run: bool,
45) -> Result<bool, FluentGenerateError> {
46    let i18n_path = i18n_path.as_ref();
47
48    if !dry_run {
49        fs::create_dir_all(i18n_path)?;
50    }
51
52    let file_path = i18n_path.join(format!("{}.ftl", crate_name));
53
54    let existing_resource = read_existing_resource(&file_path)?;
55
56    let final_resource = if matches!(mode, FluentParseMode::Aggressive) {
57        // In aggressive mode, completely replace with new content
58        build_target_resource(&items)
59    } else {
60        // In conservative mode, merge with existing content
61        smart_merge(existing_resource, &items, MergeBehavior::Append)
62    };
63
64    write_updated_resource(
65        &file_path,
66        &final_resource,
67        dry_run,
68        formatting::sort_ftl_resource,
69    )
70}
71
72pub(crate) fn print_diff(old: &str, new: &str) {
73    use colored::Colorize as _;
74    use similar::{ChangeTag, TextDiff};
75
76    let diff = TextDiff::from_lines(old, new);
77
78    for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
79        if idx > 0 {
80            println!("{}", "  ...".dimmed());
81        }
82        for op in group {
83            for change in diff.iter_changes(op) {
84                let sign = match change.tag() {
85                    ChangeTag::Delete => "-",
86                    ChangeTag::Insert => "+",
87                    ChangeTag::Equal => " ",
88                };
89                let line = format!("{} {}", sign, change);
90                match change.tag() {
91                    ChangeTag::Delete => print!("{}", line.red()),
92                    ChangeTag::Insert => print!("{}", line.green()),
93                    ChangeTag::Equal => print!("{}", line.dimmed()),
94                }
95            }
96        }
97    }
98}
99
100/// Read and parse an existing FTL resource file.
101///
102/// Returns an empty resource if the file doesn't exist or is empty.
103/// Logs warnings for parsing errors but continues with partial parse.
104fn read_existing_resource(file_path: &Path) -> Result<ast::Resource<String>, FluentGenerateError> {
105    if !file_path.exists() {
106        return Ok(ast::Resource { body: Vec::new() });
107    }
108
109    let content = fs::read_to_string(file_path)?;
110    if content.trim().is_empty() {
111        return Ok(ast::Resource { body: Vec::new() });
112    }
113
114    match parser::parse(content) {
115        Ok(res) => Ok(res),
116        Err((res, errors)) => {
117            tracing::warn!(
118                "Warning: Encountered parsing errors in {}: {:?}",
119                file_path.display(),
120                errors
121            );
122            Ok(res)
123        },
124    }
125}
126
127/// Write an updated resource to disk, handling change detection and dry-run mode.
128///
129/// Returns `true` if the file was changed (or would be changed in dry-run mode).
130fn write_updated_resource(
131    file_path: &Path,
132    resource: &ast::Resource<String>,
133    dry_run: bool,
134    formatter: impl Fn(&ast::Resource<String>) -> String,
135) -> Result<bool, FluentGenerateError> {
136    let is_empty = resource.body.is_empty();
137    let final_content = if is_empty {
138        String::new()
139    } else {
140        formatter(resource)
141    };
142
143    let current_content = if file_path.exists() {
144        fs::read_to_string(file_path)?
145    } else {
146        String::new()
147    };
148
149    // Determine if content has changed
150    let has_changed = match is_empty {
151        true => current_content != final_content && !current_content.trim().is_empty(),
152        false => current_content.trim() != final_content.trim(),
153    };
154
155    if !has_changed {
156        log_unchanged(file_path, is_empty, dry_run);
157        return Ok(false);
158    }
159
160    write_or_preview(
161        file_path,
162        &current_content,
163        &final_content,
164        is_empty,
165        dry_run,
166    )?;
167    Ok(true)
168}
169
170/// Log that a file was unchanged (only when not in dry-run mode).
171fn log_unchanged(file_path: &Path, is_empty: bool, dry_run: bool) {
172    if dry_run {
173        return;
174    }
175    let msg = match is_empty {
176        true => format!(
177            "FTL file unchanged (empty or no items): {}",
178            file_path.display()
179        ),
180        false => format!("FTL file unchanged: {}", file_path.display()),
181    };
182    tracing::debug!("{}", msg);
183}
184
185/// Write changes to disk or preview them in dry-run mode.
186fn write_or_preview(
187    file_path: &Path,
188    current_content: &str,
189    final_content: &str,
190    is_empty: bool,
191    dry_run: bool,
192) -> Result<(), FluentGenerateError> {
193    if dry_run {
194        let display_path = fs::canonicalize(file_path).unwrap_or_else(|_| file_path.to_path_buf());
195        let msg = match (is_empty, !current_content.trim().is_empty()) {
196            (true, true) => format!(
197                "Would write empty FTL file (no items): {}",
198                display_path.display()
199            ),
200            (true, false) => format!("Would write empty FTL file: {}", display_path.display()),
201            (false, _) => format!("Would update FTL file: {}", display_path.display()),
202        };
203        println!("{}", msg);
204        print_diff(current_content, final_content);
205        println!();
206        return Ok(());
207    }
208
209    fs::write(file_path, final_content)?;
210    let msg = match is_empty {
211        true => format!("Wrote empty FTL file (no items): {}", file_path.display()),
212        false => format!("Updated FTL file: {}", file_path.display()),
213    };
214    tracing::info!("{}", msg);
215    Ok(())
216}
217
218/// Compares two type infos, putting "this" types first.
219fn compare_type_infos(a: &FtlTypeInfo, b: &FtlTypeInfo) -> std::cmp::Ordering {
220    // Infer is_this from variants
221    let a_is_this = a
222        .variants
223        .iter()
224        .any(|v| v.ftl_key.to_string().ends_with(FluentKey::THIS_SUFFIX));
225    let b_is_this = b
226        .variants
227        .iter()
228        .any(|v| v.ftl_key.to_string().ends_with(FluentKey::THIS_SUFFIX));
229
230    formatting::compare_with_this_priority(a_is_this, &a.type_name, b_is_this, &b.type_name)
231}
232
233#[derive(Clone, Copy, Debug, PartialEq)]
234pub(crate) enum MergeBehavior {
235    /// Add new keys and preserve existing ones.
236    Append,
237    /// Remove orphan keys and empty groups, do not add new keys.
238    Clean,
239}
240
241pub(crate) fn smart_merge(
242    existing: ast::Resource<String>,
243    items: &[FtlTypeInfo],
244    behavior: MergeBehavior,
245) -> ast::Resource<String> {
246    let mut pending_items = merge_ftl_type_infos(items);
247    pending_items.sort_by(compare_type_infos);
248
249    let mut item_map: HashMap<String, FtlTypeInfo> = pending_items
250        .into_iter()
251        .map(|i| (i.type_name.clone(), i))
252        .collect();
253
254    let mut new_body = Vec::new();
255    let mut current_group_name: Option<String> = None;
256    let cleanup = matches!(behavior, MergeBehavior::Clean);
257
258    for entry in existing.body {
259        match entry {
260            ast::Entry::GroupComment(ref comment) => {
261                if let Some(ref old_group) = current_group_name
262                    && let Some(info) = item_map.get_mut(old_group)
263                    && !info.variants.is_empty()
264                {
265                    // Only append missing variants if we are appending
266                    if matches!(behavior, MergeBehavior::Append) {
267                        for variant in &info.variants {
268                            new_body.push(create_message_entry(variant));
269                        }
270                    }
271                    info.variants.clear();
272                }
273
274                if let Some(content) = comment.content.first() {
275                    let trimmed = content.trim();
276                    current_group_name = Some(trimmed.to_string());
277                } else {
278                    current_group_name = None;
279                }
280
281                let keep_group = if let Some(ref group_name) = current_group_name {
282                    !cleanup || item_map.contains_key(group_name)
283                } else {
284                    true
285                };
286
287                if keep_group {
288                    new_body.push(entry);
289                }
290            },
291            ast::Entry::Message(ref msg) => {
292                let key = &msg.id.name;
293                let mut handled = false;
294
295                if let Some(ref group_name) = current_group_name
296                    && let Some(info) = item_map.get_mut(group_name)
297                    && let Some(idx) = info
298                        .variants
299                        .iter()
300                        .position(|v| v.ftl_key.to_string() == *key)
301                {
302                    info.variants.remove(idx);
303                    handled = true;
304                }
305
306                if !handled {
307                    for info in item_map.values_mut() {
308                        if let Some(idx) = info
309                            .variants
310                            .iter()
311                            .position(|v| v.ftl_key.to_string() == *key)
312                        {
313                            info.variants.remove(idx);
314                            handled = true;
315                            break;
316                        }
317                    }
318                }
319
320                if handled || !cleanup {
321                    new_body.push(entry);
322                }
323            },
324            ast::Entry::Term(ref term) => {
325                let key = format!("{}{}", FluentKey::DELIMITER, term.id.name);
326                let mut handled = false;
327                for info in item_map.values_mut() {
328                    if let Some(idx) = info
329                        .variants
330                        .iter()
331                        .position(|v| v.ftl_key.to_string() == key)
332                    {
333                        info.variants.remove(idx);
334                        handled = true;
335                        break;
336                    }
337                }
338
339                if handled || !cleanup {
340                    new_body.push(entry);
341                }
342            },
343            ast::Entry::Junk { .. } => {
344                new_body.push(entry);
345            },
346            _ => {
347                new_body.push(entry);
348            },
349        }
350    }
351
352    // Correctly handle the end of the last group
353    if let Some(ref last_group) = current_group_name
354        && let Some(info) = item_map.get_mut(last_group)
355        && !info.variants.is_empty()
356    {
357        // Only append missing variants if we are appending
358        if matches!(behavior, MergeBehavior::Append) {
359            for variant in &info.variants {
360                new_body.push(create_message_entry(variant));
361            }
362        }
363        info.variants.clear();
364    }
365
366    // Only append remaining new groups if we are appending
367    if matches!(behavior, MergeBehavior::Append) {
368        let mut remaining_groups: Vec<_> = item_map.into_iter().collect();
369        remaining_groups.sort_by(|(_, a), (_, b)| compare_type_infos(a, b));
370
371        for (type_name, info) in remaining_groups {
372            if !info.variants.is_empty() {
373                new_body.push(create_group_comment_entry(&type_name));
374                for variant in info.variants {
375                    new_body.push(create_message_entry(&variant));
376                }
377            }
378        }
379    }
380
381    ast::Resource { body: new_body }
382}
383
384fn create_group_comment_entry(type_name: &str) -> ast::Entry<String> {
385    ast::Entry::GroupComment(ast::Comment {
386        content: vec![type_name.to_owned()],
387    })
388}
389
390fn create_message_entry(variant: &FtlVariant) -> ast::Entry<String> {
391    let message_id = ast::Identifier {
392        name: variant.ftl_key.to_string(),
393    };
394
395    let base_value = ValueFormatter::expand(&variant.name);
396
397    let mut elements = vec![ast::PatternElement::TextElement { value: base_value }];
398
399    for arg_name in &variant.args {
400        elements.push(ast::PatternElement::TextElement { value: " ".into() });
401
402        elements.push(ast::PatternElement::Placeable {
403            expression: ast::Expression::Inline(ast::InlineExpression::VariableReference {
404                id: ast::Identifier {
405                    name: arg_name.into(),
406                },
407            }),
408        });
409    }
410
411    let pattern = ast::Pattern { elements };
412
413    ast::Entry::Message(ast::Message {
414        id: message_id,
415        value: Some(pattern),
416        attributes: Vec::new(),
417        comment: None,
418    })
419}
420
421fn merge_ftl_type_infos(items: &[FtlTypeInfo]) -> Vec<FtlTypeInfo> {
422    use std::collections::BTreeMap;
423
424    // Group by type_name, also track module_path
425    let mut grouped: BTreeMap<String, (TypeKind, Vec<FtlVariant>, String)> = BTreeMap::new();
426
427    for item in items {
428        let entry = grouped
429            .entry(item.type_name.clone())
430            .or_insert_with(|| (item.type_kind.clone(), Vec::new(), item.module_path.clone()));
431        entry.1.extend(item.variants.clone());
432    }
433
434    grouped
435        .into_iter()
436        .map(|(type_name, (type_kind, mut variants, module_path))| {
437            variants.sort_by(|a, b| {
438                let a_is_this = a.ftl_key.to_string().ends_with(FluentKey::THIS_SUFFIX);
439                let b_is_this = b.ftl_key.to_string().ends_with(FluentKey::THIS_SUFFIX);
440                formatting::compare_with_this_priority(a_is_this, &a.name, b_is_this, &b.name)
441            });
442            variants.dedup();
443
444            FtlTypeInfo {
445                type_kind,
446                type_name,
447                variants,
448                file_path: None,
449                module_path,
450            }
451        })
452        .collect()
453}
454
455fn build_target_resource(items: &[FtlTypeInfo]) -> ast::Resource<String> {
456    let items = merge_ftl_type_infos(items);
457    let mut body: Vec<ast::Entry<String>> = Vec::new();
458    let mut sorted_items = items.to_vec();
459    sorted_items.sort_by(compare_type_infos);
460
461    for info in &sorted_items {
462        body.push(create_group_comment_entry(&info.type_name));
463
464        for variant in &info.variants {
465            body.push(create_message_entry(variant));
466        }
467    }
468
469    ast::Resource { body }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use es_fluent_core::{meta::TypeKind, namer::FluentKey};
476    use proc_macro2::Ident;
477    use std::fs;
478    use tempfile::TempDir;
479
480    #[test]
481    fn test_value_formatter_expand() {
482        assert_eq!(ValueFormatter::expand("simple-key"), "Key");
483        assert_eq!(ValueFormatter::expand("another-test-value"), "Value");
484        assert_eq!(ValueFormatter::expand("single"), "Single");
485    }
486
487    #[test]
488    fn test_generate_empty_items() {
489        let temp_dir = TempDir::new().unwrap();
490        let i18n_path = temp_dir.path().join("i18n");
491
492        let result = generate(
493            "test_crate",
494            &i18n_path,
495            vec![],
496            FluentParseMode::Conservative,
497            false,
498        );
499        assert!(result.is_ok());
500
501        let ftl_file_path = i18n_path.join("test_crate.ftl");
502        assert!(!ftl_file_path.exists());
503    }
504
505    #[test]
506    fn test_generate_with_items() {
507        let temp_dir = TempDir::new().unwrap();
508        let i18n_path = temp_dir.path().join("i18n");
509
510        let ftl_key = FluentKey::from(&Ident::new("TestEnum", proc_macro2::Span::call_site()))
511            .join("Variant1");
512        let variant = FtlVariant {
513            name: "variant1".to_string(),
514            ftl_key,
515            args: Vec::new(),
516            module_path: "test".to_string(),
517        };
518
519        let type_info = FtlTypeInfo {
520            type_kind: TypeKind::Enum,
521            type_name: "TestEnum".to_string(),
522            variants: vec![variant],
523            file_path: None,
524            module_path: "test".to_string(),
525        };
526
527        let result = generate(
528            "test_crate",
529            &i18n_path,
530            vec![type_info],
531            FluentParseMode::Conservative,
532            false,
533        );
534        assert!(result.is_ok());
535
536        let ftl_file_path = i18n_path.join("test_crate.ftl");
537        assert!(ftl_file_path.exists());
538
539        let content = fs::read_to_string(ftl_file_path).unwrap();
540        assert!(content.contains("TestEnum"));
541        assert!(content.contains("Variant1"));
542    }
543
544    #[test]
545    fn test_generate_aggressive_mode() {
546        let temp_dir = TempDir::new().unwrap();
547        let i18n_path = temp_dir.path().join("i18n");
548
549        let ftl_file_path = i18n_path.join("test_crate.ftl");
550        fs::create_dir_all(&i18n_path).unwrap();
551        fs::write(&ftl_file_path, "existing-message = Existing Content").unwrap();
552
553        let ftl_key = FluentKey::from(&Ident::new("TestEnum", proc_macro2::Span::call_site()))
554            .join("Variant1");
555        let variant = FtlVariant {
556            name: "variant1".to_string(),
557            ftl_key,
558            args: Vec::new(),
559            module_path: "test".to_string(),
560        };
561
562        let type_info = FtlTypeInfo {
563            type_kind: TypeKind::Enum,
564            type_name: "TestEnum".to_string(),
565            variants: vec![variant],
566            file_path: None,
567            module_path: "test".to_string(),
568        };
569
570        let result = generate(
571            "test_crate",
572            &i18n_path,
573            vec![type_info],
574            FluentParseMode::Aggressive,
575            false,
576        );
577        assert!(result.is_ok());
578
579        let content = fs::read_to_string(&ftl_file_path).unwrap();
580        assert!(!content.contains("existing-message"));
581        assert!(content.contains("TestEnum"));
582        assert!(content.contains("Variant1"));
583    }
584
585    #[test]
586    fn test_generate_conservative_mode() {
587        let temp_dir = TempDir::new().unwrap();
588        let i18n_path = temp_dir.path().join("i18n");
589
590        let ftl_file_path = i18n_path.join("test_crate.ftl");
591        fs::create_dir_all(&i18n_path).unwrap();
592        fs::write(&ftl_file_path, "existing-message = Existing Content").unwrap();
593
594        let ftl_key = FluentKey::from(&Ident::new("TestEnum", proc_macro2::Span::call_site()))
595            .join("Variant1");
596        let variant = FtlVariant {
597            name: "variant1".to_string(),
598            ftl_key,
599            args: Vec::new(),
600            module_path: "test".to_string(),
601        };
602
603        let type_info = FtlTypeInfo {
604            type_kind: TypeKind::Enum,
605            type_name: "TestEnum".to_string(),
606            variants: vec![variant],
607            file_path: None,
608            module_path: "test".to_string(),
609        };
610
611        let result = generate(
612            "test_crate",
613            &i18n_path,
614            vec![type_info],
615            FluentParseMode::Conservative,
616            false,
617        );
618        assert!(result.is_ok());
619
620        let content = fs::read_to_string(&ftl_file_path).unwrap();
621        assert!(content.contains("existing-message"));
622        assert!(content.contains("TestEnum"));
623        assert!(content.contains("Variant1"));
624    }
625    #[test]
626    fn test_generate_clean_mode() {
627        let temp_dir = TempDir::new().unwrap();
628        let i18n_path = temp_dir.path().join("i18n");
629
630        let ftl_file_path = i18n_path.join("test_crate.ftl");
631        fs::create_dir_all(&i18n_path).unwrap();
632
633        let initial_content = "
634## OrphanGroup
635
636what-Hi = Hi
637awdawd = awdwa
638
639## ExistingGroup
640
641existing-key = Existing Value
642";
643        fs::write(&ftl_file_path, initial_content).unwrap();
644
645        // Define items that match ExistingGroup but NOT OrphanGroup
646        let ftl_key = FluentKey::from(&Ident::new("ExistingGroup", proc_macro2::Span::call_site()))
647            .join("ExistingKey");
648        let variant = FtlVariant {
649            name: "ExistingKey".to_string(),
650            ftl_key,
651            args: Vec::new(),
652            module_path: "test".to_string(),
653        };
654
655        let type_info = FtlTypeInfo {
656            type_kind: TypeKind::Enum,
657            type_name: "ExistingGroup".to_string(),
658            variants: vec![variant],
659            file_path: None,
660            module_path: "test".to_string(),
661        };
662
663        let result = crate::clean::clean("test_crate", &i18n_path, vec![type_info], false);
664        assert!(result.is_ok());
665
666        let content = fs::read_to_string(&ftl_file_path).unwrap();
667
668        // Should NOT contain orphan content
669        assert!(!content.contains("## OrphanGroup"));
670        assert!(!content.contains("what-Hi"));
671        assert!(!content.contains("awdawd"));
672
673        // Should contain existing content that is still valid
674        assert!(content.contains("## ExistingGroup"));
675    }
676
677    #[test]
678    fn test_this_types_sorted_first() {
679        let temp_dir = TempDir::new().unwrap();
680        let i18n_path = temp_dir.path().join("i18n");
681
682        // Create types: Apple, Banana, Banana_this (should come first)
683        let apple_variant = FtlVariant {
684            name: "Red".to_string(),
685            ftl_key: FluentKey::from(&Ident::new("Apple", proc_macro2::Span::call_site()))
686                .join("Red"),
687            args: Vec::new(),
688            module_path: "test".to_string(),
689        };
690        let apple = FtlTypeInfo {
691            type_kind: TypeKind::Enum,
692            type_name: "Apple".to_string(),
693            variants: vec![apple_variant],
694            file_path: None,
695            module_path: "test".to_string(),
696        };
697
698        let banana_variant = FtlVariant {
699            name: "Yellow".to_string(),
700            ftl_key: FluentKey::from(&Ident::new("Banana", proc_macro2::Span::call_site()))
701                .join("Yellow"),
702            args: Vec::new(),
703            module_path: "test".to_string(),
704        };
705        let banana = FtlTypeInfo {
706            type_kind: TypeKind::Enum,
707            type_name: "Banana".to_string(),
708            variants: vec![banana_variant],
709            file_path: None,
710            module_path: "test".to_string(),
711        };
712
713        // This type should come first despite alphabetical order
714        // Use proper 'this' key suffix for inference!
715        let banana_this_ident = Ident::new("BananaThis", proc_macro2::Span::call_site());
716        let banana_this_key = FluentKey::new_this(&banana_this_ident); // "banana_this_this" effectively or similar
717
718        let banana_this_variant = FtlVariant {
719            name: "this".to_string(),
720            ftl_key: banana_this_key,
721            args: Vec::new(),
722            module_path: "test".to_string(),
723        };
724        let banana_this = FtlTypeInfo {
725            type_kind: TypeKind::Struct,
726            type_name: "BananaThis".to_string(),
727            variants: vec![banana_this_variant],
728            file_path: None,
729            module_path: "test".to_string(),
730        };
731
732        let result = generate(
733            "test_crate",
734            &i18n_path,
735            vec![apple.clone(), banana.clone(), banana_this.clone()],
736            FluentParseMode::Aggressive,
737            false,
738        );
739        assert!(result.is_ok());
740
741        let ftl_file_path = i18n_path.join("test_crate.ftl");
742        let content = fs::read_to_string(&ftl_file_path).unwrap();
743
744        // BananaThis (is_this=true) should come before Apple and Banana
745        let banana_this_pos = content.find("## BananaThis").expect("BananaThis missing");
746        let apple_pos = content.find("## Apple").expect("Apple missing");
747        let banana_pos = content.find("## Banana\n").expect("Banana missing");
748
749        assert!(
750            banana_this_pos < apple_pos,
751            "BananaThis (is_this=true) should come before Apple"
752        );
753        assert!(
754            banana_this_pos < banana_pos,
755            "BananaThis (is_this=true) should come before Banana"
756        );
757        // Apple should come before Banana alphabetically
758        assert!(apple_pos < banana_pos, "Apple should come before Banana");
759    }
760
761    #[test]
762    fn test_this_variants_sorted_first_within_group() {
763        let temp_dir = TempDir::new().unwrap();
764        let i18n_path = temp_dir.path().join("i18n");
765
766        // Create a type with multiple variants, one being a "this" variant
767        // Ensure keys have proper suffixes for inference
768        let fruit_ident = Ident::new("Fruit", proc_macro2::Span::call_site());
769        let this_key = FluentKey::new_this(&fruit_ident); // e.g. fruit_this
770
771        let this_variant = FtlVariant {
772            name: "this".to_string(),
773            ftl_key: this_key,
774            args: Vec::new(),
775            module_path: "test".to_string(),
776        };
777        let apple_variant = FtlVariant {
778            name: "Apple".to_string(),
779            ftl_key: FluentKey::from(&fruit_ident).join("Apple"),
780            args: Vec::new(),
781            module_path: "test".to_string(),
782        };
783        let banana_variant = FtlVariant {
784            name: "Banana".to_string(),
785            ftl_key: FluentKey::from(&fruit_ident).join("Banana"),
786            args: Vec::new(),
787            module_path: "test".to_string(),
788        };
789
790        let fruit = FtlTypeInfo {
791            type_kind: TypeKind::Enum,
792            type_name: "Fruit".to_string(),
793            // Deliberately put variants in wrong order
794            variants: vec![
795                banana_variant.clone(),
796                this_variant.clone(),
797                apple_variant.clone(),
798            ],
799            file_path: None,
800            module_path: "test".to_string(),
801        };
802
803        let result = generate(
804            "test_crate",
805            &i18n_path,
806            vec![fruit],
807            FluentParseMode::Aggressive,
808            false,
809        );
810        assert!(result.is_ok());
811
812        let ftl_file_path = i18n_path.join("test_crate.ftl");
813        let content = fs::read_to_string(&ftl_file_path).unwrap();
814
815        // The "this" variant (fruit) should come first, then Apple, then Banana
816        let this_pos = content
817            .find("fruit_this =")
818            .expect("this variant (fruit_this) missing");
819        let apple_pos = content.find("fruit-Apple").expect("Apple variant missing");
820        let banana_pos = content
821            .find("fruit-Banana")
822            .expect("Banana variant missing");
823
824        assert!(
825            this_pos < apple_pos,
826            "This variant should come before Apple"
827        );
828        assert!(
829            this_pos < banana_pos,
830            "This variant should come before Banana"
831        );
832        assert!(apple_pos < banana_pos, "Apple should come before Banana");
833    }
834}