Skip to main content

es_fluent_generate/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use clap::ValueEnum;
4use es_fluent_derive_core::namer::FluentKey;
5use es_fluent_derive_core::registry::{FtlTypeInfo, FtlVariant};
6use fluent_syntax::{ast, parser};
7use indexmap::IndexMap;
8use std::{collections::HashSet, fs, path::Path};
9
10pub mod clean;
11pub mod error;
12pub mod formatting;
13pub mod value;
14
15use es_fluent_derive_core::EsFluentResult;
16use value::ValueFormatter;
17
18/// The mode to use when parsing Fluent files.
19#[derive(Clone, Debug, Default, strum::Display, PartialEq, ValueEnum)]
20#[strum(serialize_all = "snake_case")]
21pub enum FluentParseMode {
22    /// Overwrite existing translations.
23    Aggressive,
24    /// Preserve existing translations.
25    #[default]
26    Conservative,
27}
28
29// Internal owned types for merge operations
30#[derive(Clone, Debug, Eq, Hash, PartialEq)]
31struct OwnedVariant {
32    name: String,
33    ftl_key: String,
34    args: Vec<String>,
35}
36
37impl From<&FtlVariant> for OwnedVariant {
38    fn from(v: &FtlVariant) -> Self {
39        Self {
40            name: v.name.to_string(),
41            ftl_key: v.ftl_key.to_string(),
42            args: v.args.iter().map(|s| s.to_string()).collect(),
43        }
44    }
45}
46
47#[derive(Clone, Debug)]
48struct OwnedTypeInfo {
49    type_name: String,
50    variants: Vec<OwnedVariant>,
51}
52
53impl From<&FtlTypeInfo> for OwnedTypeInfo {
54    fn from(info: &FtlTypeInfo) -> Self {
55        Self {
56            type_name: info.type_name.to_string(),
57            variants: info.variants.iter().map(OwnedVariant::from).collect(),
58        }
59    }
60}
61
62/// Generates a Fluent translation file from a list of `FtlTypeInfo` objects.
63pub fn generate<P: AsRef<Path>, M: AsRef<Path>, I: AsRef<FtlTypeInfo>>(
64    crate_name: &str,
65    i18n_path: P,
66    manifest_dir: M,
67    items: &[I],
68    mode: FluentParseMode,
69    dry_run: bool,
70) -> EsFluentResult<bool> {
71    let i18n_path = i18n_path.as_ref();
72    let manifest_dir = manifest_dir.as_ref();
73    let items_ref: Vec<&FtlTypeInfo> = items.iter().map(|i| i.as_ref()).collect();
74
75    // Group items by namespace
76    let mut namespaced: IndexMap<Option<String>, Vec<&FtlTypeInfo>> = IndexMap::new();
77    for item in &items_ref {
78        let namespace = item.resolved_namespace(manifest_dir);
79        namespaced.entry(namespace).or_default().push(item);
80    }
81
82    let mut any_changed = false;
83
84    for (namespace, ns_items) in namespaced {
85        let (dir_path, file_path) = match namespace {
86            Some(ns) => {
87                // Namespaced items go to {i18n_path}/{crate_name}/{namespace}.ftl
88                let dir = i18n_path.join(crate_name);
89                let file = dir.join(format!("{}.ftl", ns));
90                (dir, file)
91            },
92            None => {
93                // Non-namespaced items go to {i18n_path}/{crate_name}.ftl
94                (
95                    i18n_path.to_path_buf(),
96                    i18n_path.join(format!("{}.ftl", crate_name)),
97                )
98            },
99        };
100
101        if !dry_run {
102            fs::create_dir_all(&dir_path)?;
103        }
104
105        let existing_resource = read_existing_resource(&file_path)?;
106
107        let final_resource = if matches!(mode, FluentParseMode::Aggressive) {
108            build_target_resource(&ns_items)
109        } else {
110            smart_merge(existing_resource, &ns_items, MergeBehavior::Append)
111        };
112
113        if write_updated_resource(
114            &file_path,
115            &final_resource,
116            dry_run,
117            formatting::sort_ftl_resource,
118        )? {
119            any_changed = true;
120        }
121    }
122
123    Ok(any_changed)
124}
125
126pub(crate) fn print_diff(old: &str, new: &str) {
127    use colored::Colorize as _;
128    use similar::{ChangeTag, TextDiff};
129
130    let diff = TextDiff::from_lines(old, new);
131
132    for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
133        if idx > 0 {
134            println!("{}", "  ...".dimmed());
135        }
136        for op in group {
137            for change in diff.iter_changes(op) {
138                let sign = match change.tag() {
139                    ChangeTag::Delete => "-",
140                    ChangeTag::Insert => "+",
141                    ChangeTag::Equal => " ",
142                };
143                let line = format!("{} {}", sign, change);
144                match change.tag() {
145                    ChangeTag::Delete => print!("{}", line.red()),
146                    ChangeTag::Insert => print!("{}", line.green()),
147                    ChangeTag::Equal => print!("{}", line.dimmed()),
148                }
149            }
150        }
151    }
152}
153
154/// Read and parse an existing FTL resource file.
155///
156/// Returns an empty resource if the file doesn't exist or is empty.
157/// Logs warnings for parsing errors but continues with partial parse.
158fn read_existing_resource(file_path: &Path) -> EsFluentResult<ast::Resource<String>> {
159    if !file_path.exists() {
160        return Ok(ast::Resource { body: Vec::new() });
161    }
162
163    let content = fs::read_to_string(file_path)?;
164    if content.trim().is_empty() {
165        return Ok(ast::Resource { body: Vec::new() });
166    }
167
168    match parser::parse(content) {
169        Ok(res) => Ok(res),
170        Err((res, errors)) => {
171            tracing::warn!(
172                "Warning: Encountered parsing errors in {}: {:?}",
173                file_path.display(),
174                errors
175            );
176            Ok(res)
177        },
178    }
179}
180
181/// Write an updated resource to disk, handling change detection and dry-run mode.
182///
183/// Returns `true` if the file was changed (or would be changed in dry-run mode).
184fn write_updated_resource(
185    file_path: &Path,
186    resource: &ast::Resource<String>,
187    dry_run: bool,
188    formatter: impl Fn(&ast::Resource<String>) -> String,
189) -> EsFluentResult<bool> {
190    let is_empty = resource.body.is_empty();
191    let final_content = if is_empty {
192        String::new()
193    } else {
194        formatter(resource)
195    };
196
197    let current_content = if file_path.exists() {
198        fs::read_to_string(file_path)?
199    } else {
200        String::new()
201    };
202
203    // Determine if content has changed
204    let has_changed = match is_empty {
205        true => current_content != final_content && !current_content.trim().is_empty(),
206        false => current_content.trim() != final_content.trim(),
207    };
208
209    if !has_changed {
210        log_unchanged(file_path, is_empty, dry_run);
211        return Ok(false);
212    }
213
214    write_or_preview(
215        file_path,
216        &current_content,
217        &final_content,
218        is_empty,
219        dry_run,
220    )?;
221    Ok(true)
222}
223
224/// Log that a file was unchanged (only when not in dry-run mode).
225fn log_unchanged(file_path: &Path, is_empty: bool, dry_run: bool) {
226    if dry_run {
227        return;
228    }
229    let msg = match is_empty {
230        true => format!(
231            "FTL file unchanged (empty or no items): {}",
232            file_path.display()
233        ),
234        false => format!("FTL file unchanged: {}", file_path.display()),
235    };
236    tracing::debug!("{}", msg);
237}
238
239/// Write changes to disk or preview them in dry-run mode.
240fn write_or_preview(
241    file_path: &Path,
242    current_content: &str,
243    final_content: &str,
244    is_empty: bool,
245    dry_run: bool,
246) -> EsFluentResult<()> {
247    if dry_run {
248        let display_path = fs::canonicalize(file_path).unwrap_or_else(|_| file_path.to_path_buf());
249        let msg = match (is_empty, !current_content.trim().is_empty()) {
250            (true, true) => format!(
251                "Would write empty FTL file (no items): {}",
252                display_path.display()
253            ),
254            (true, false) => format!("Would write empty FTL file: {}", display_path.display()),
255            (false, _) => format!("Would update FTL file: {}", display_path.display()),
256        };
257        println!("{}", msg);
258        print_diff(current_content, final_content);
259        println!();
260        return Ok(());
261    }
262
263    if let Some(parent) = file_path.parent() {
264        fs::create_dir_all(parent)?;
265    }
266
267    fs::write(file_path, final_content)?;
268    let msg = match is_empty {
269        true => format!("Wrote empty FTL file (no items): {}", file_path.display()),
270        false => format!("Updated FTL file: {}", file_path.display()),
271    };
272    tracing::info!("{}", msg);
273    Ok(())
274}
275
276/// Compares two type infos, putting "this" types first.
277fn compare_type_infos(a: &OwnedTypeInfo, b: &OwnedTypeInfo) -> std::cmp::Ordering {
278    // Infer is_this from variants
279    let a_is_this = a
280        .variants
281        .iter()
282        .any(|v| v.ftl_key.ends_with(FluentKey::THIS_SUFFIX));
283    let b_is_this = b
284        .variants
285        .iter()
286        .any(|v| v.ftl_key.ends_with(FluentKey::THIS_SUFFIX));
287
288    formatting::compare_with_this_priority(a_is_this, &a.type_name, b_is_this, &b.type_name)
289}
290
291#[derive(Clone, Copy, Debug, PartialEq)]
292pub(crate) enum MergeBehavior {
293    /// Add new keys and preserve existing ones.
294    Append,
295    /// Remove orphan keys and empty groups, do not add new keys.
296    Clean,
297}
298
299pub(crate) fn smart_merge(
300    existing: ast::Resource<String>,
301    items: &[&FtlTypeInfo],
302    behavior: MergeBehavior,
303) -> ast::Resource<String> {
304    let mut pending_items = merge_ftl_type_infos(items);
305    pending_items.sort_by(compare_type_infos);
306
307    let mut item_map: IndexMap<String, OwnedTypeInfo> = pending_items
308        .into_iter()
309        .map(|i| (i.type_name.clone(), i))
310        .collect();
311    let mut key_to_group: IndexMap<String, String> = IndexMap::new();
312    for (group_name, info) in &item_map {
313        for variant in &info.variants {
314            key_to_group.insert(variant.ftl_key.clone(), group_name.clone());
315        }
316    }
317    let mut relocated_by_group: IndexMap<String, Vec<ast::Entry<String>>> = IndexMap::new();
318    let mut late_relocated_by_group: IndexMap<String, Vec<ast::Entry<String>>> = IndexMap::new();
319    let mut seen_groups: HashSet<String> = HashSet::new();
320    let existing_keys = collect_existing_keys(&existing);
321    let mut seen_keys: HashSet<String> = HashSet::new();
322
323    let mut new_body = Vec::new();
324    let mut current_group_name: Option<String> = None;
325    let cleanup = matches!(behavior, MergeBehavior::Clean);
326
327    for entry in existing.body {
328        match entry {
329            ast::Entry::GroupComment(ref comment) => {
330                if let Some(ref old_group) = current_group_name
331                    && let Some(info) = item_map.get_mut(old_group)
332                {
333                    // Only append missing variants if we are appending
334                    if matches!(behavior, MergeBehavior::Append) {
335                        if let Some(entries) = relocated_by_group.shift_remove(old_group) {
336                            new_body.extend(entries);
337                        }
338                        if !info.variants.is_empty() {
339                            for variant in &info.variants {
340                                if !existing_keys.contains(&variant.ftl_key) {
341                                    seen_keys.insert(variant.ftl_key.clone());
342                                    new_body.push(create_message_entry(variant));
343                                }
344                            }
345                        }
346                    }
347                    info.variants.clear();
348                }
349
350                if let Some(content) = comment.content.first() {
351                    let trimmed = content.trim();
352                    current_group_name = Some(trimmed.to_string());
353                } else {
354                    current_group_name = None;
355                }
356
357                let keep_group = if let Some(ref group_name) = current_group_name {
358                    !cleanup || item_map.contains_key(group_name)
359                } else {
360                    true
361                };
362
363                if keep_group {
364                    new_body.push(entry);
365                }
366
367                if let Some(ref group_name) = current_group_name {
368                    seen_groups.insert(group_name.clone());
369                }
370            },
371            ast::Entry::Message(msg) => {
372                let key = msg.id.name.clone();
373                let mut handled = false;
374                let mut relocate_to: Option<String> = None;
375
376                if seen_keys.contains(&key) {
377                    continue;
378                }
379
380                if let Some(expected_group) = key_to_group.get(&key).cloned() {
381                    if current_group_name.as_deref() != Some(expected_group.as_str())
382                        && matches!(behavior, MergeBehavior::Append)
383                    {
384                        relocate_to = Some(expected_group.clone());
385                    }
386                    handled = true;
387
388                    if let Some(info) = item_map.get_mut(&expected_group)
389                        && let Some(idx) = info.variants.iter().position(|v| v.ftl_key == key)
390                    {
391                        info.variants.remove(idx);
392                    }
393                } else if !handled {
394                    for info in item_map.values_mut() {
395                        if let Some(idx) = info.variants.iter().position(|v| v.ftl_key == key) {
396                            info.variants.remove(idx);
397                            handled = true;
398                            break;
399                        }
400                    }
401                }
402
403                if let Some(group_name) = relocate_to {
404                    seen_keys.insert(key);
405                    if seen_groups.contains(&group_name) {
406                        late_relocated_by_group
407                            .entry(group_name)
408                            .or_default()
409                            .push(ast::Entry::Message(msg));
410                    } else {
411                        relocated_by_group
412                            .entry(group_name)
413                            .or_default()
414                            .push(ast::Entry::Message(msg));
415                    }
416                } else if handled || !cleanup {
417                    seen_keys.insert(key);
418                    new_body.push(ast::Entry::Message(msg));
419                }
420            },
421            ast::Entry::Term(ref term) => {
422                let key = format!("{}{}", FluentKey::DELIMITER, term.id.name);
423                let mut handled = false;
424                if seen_keys.contains(&key) {
425                    continue;
426                }
427                for info in item_map.values_mut() {
428                    if let Some(idx) = info.variants.iter().position(|v| v.ftl_key == key) {
429                        info.variants.remove(idx);
430                        handled = true;
431                        break;
432                    }
433                }
434
435                if handled || !cleanup {
436                    seen_keys.insert(key);
437                    new_body.push(entry);
438                }
439            },
440            ast::Entry::Junk { .. } => {
441                new_body.push(entry);
442            },
443            _ => {
444                new_body.push(entry);
445            },
446        }
447    }
448
449    // Correctly handle the end of the last group
450    if let Some(ref last_group) = current_group_name
451        && let Some(info) = item_map.get_mut(last_group)
452    {
453        // Only append missing variants if we are appending
454        if matches!(behavior, MergeBehavior::Append) {
455            if let Some(entries) = relocated_by_group.shift_remove(last_group) {
456                new_body.extend(entries);
457            }
458            if !info.variants.is_empty() {
459                for variant in &info.variants {
460                    if !existing_keys.contains(&variant.ftl_key) {
461                        seen_keys.insert(variant.ftl_key.clone());
462                        new_body.push(create_message_entry(variant));
463                    }
464                }
465            }
466        }
467        info.variants.clear();
468    }
469
470    // Only append remaining new groups if we are appending
471    if matches!(behavior, MergeBehavior::Append) {
472        let mut remaining_groups: Vec<_> = item_map.into_iter().collect();
473        remaining_groups.sort_by(|(_, a), (_, b)| compare_type_infos(a, b));
474
475        for (type_name, info) in remaining_groups {
476            let relocated = relocated_by_group.shift_remove(&type_name);
477            let has_missing = info
478                .variants
479                .iter()
480                .any(|variant| !existing_keys.contains(&variant.ftl_key));
481            if has_missing || relocated.is_some() {
482                new_body.push(create_group_comment_entry(&type_name));
483                if let Some(entries) = relocated {
484                    new_body.extend(entries);
485                }
486                for variant in info.variants {
487                    if !existing_keys.contains(&variant.ftl_key) {
488                        seen_keys.insert(variant.ftl_key.clone());
489                        new_body.push(create_message_entry(&variant));
490                    }
491                }
492            }
493        }
494    }
495
496    let mut resource = ast::Resource { body: new_body };
497
498    if matches!(behavior, MergeBehavior::Append) && !late_relocated_by_group.is_empty() {
499        insert_late_relocated(&mut resource.body, &late_relocated_by_group);
500    }
501    if cleanup {
502        remove_empty_group_comments(resource)
503    } else {
504        resource
505    }
506}
507
508fn group_comment_name(comment: &ast::Comment<String>) -> Option<String> {
509    comment
510        .content
511        .first()
512        .map(|line| line.trim())
513        .filter(|line| !line.is_empty())
514        .map(|line| line.to_string())
515}
516
517fn collect_existing_keys(resource: &ast::Resource<String>) -> HashSet<String> {
518    let mut keys = HashSet::new();
519    for entry in &resource.body {
520        match entry {
521            ast::Entry::Message(msg) => {
522                keys.insert(msg.id.name.clone());
523            },
524            ast::Entry::Term(term) => {
525                keys.insert(format!("{}{}", FluentKey::DELIMITER, term.id.name));
526            },
527            _ => {},
528        }
529    }
530    keys
531}
532
533fn insert_late_relocated(
534    body: &mut Vec<ast::Entry<String>>,
535    late_relocated_by_group: &IndexMap<String, Vec<ast::Entry<String>>>,
536) {
537    let mut group_positions: Vec<(String, usize)> = Vec::new();
538    for (idx, entry) in body.iter().enumerate() {
539        if let ast::Entry::GroupComment(comment) = entry
540            && let Some(name) = group_comment_name(comment)
541        {
542            group_positions.push((name, idx));
543        }
544    }
545
546    if group_positions.is_empty() {
547        return;
548    }
549
550    let mut inserted: HashSet<String> = HashSet::new();
551    for (i, (name, _start)) in group_positions.iter().enumerate().rev() {
552        if inserted.contains(name) {
553            continue;
554        }
555        let end = if i + 1 < group_positions.len() {
556            group_positions[i + 1].1
557        } else {
558            body.len()
559        };
560        if let Some(entries) = late_relocated_by_group.get(name)
561            && !entries.is_empty()
562        {
563            body.splice(end..end, entries.clone());
564        }
565        inserted.insert(name.clone());
566    }
567}
568
569fn remove_empty_group_comments(resource: ast::Resource<String>) -> ast::Resource<String> {
570    let mut body: Vec<ast::Entry<String>> = Vec::with_capacity(resource.body.len());
571    let mut pending_group: Option<ast::Entry<String>> = None;
572    let mut pending_entries: Vec<ast::Entry<String>> = Vec::new();
573    let mut has_message = false;
574
575    let flush_pending = |body: &mut Vec<ast::Entry<String>>,
576                         pending_group: &mut Option<ast::Entry<String>>,
577                         pending_entries: &mut Vec<ast::Entry<String>>,
578                         has_message: &mut bool| {
579        if let Some(group_comment) = pending_group.take() {
580            if *has_message {
581                body.push(group_comment);
582            }
583            body.append(pending_entries);
584        }
585        *has_message = false;
586    };
587
588    for entry in resource.body {
589        match entry {
590            ast::Entry::GroupComment(_) => {
591                flush_pending(
592                    &mut body,
593                    &mut pending_group,
594                    &mut pending_entries,
595                    &mut has_message,
596                );
597                pending_group = Some(entry);
598                pending_entries = Vec::new();
599            },
600            ast::Entry::Message(_) | ast::Entry::Term(_) => {
601                if pending_group.is_some() {
602                    has_message = true;
603                    pending_entries.push(entry);
604                } else {
605                    body.push(entry);
606                }
607            },
608            _ => {
609                if pending_group.is_some() {
610                    pending_entries.push(entry);
611                } else {
612                    body.push(entry);
613                }
614            },
615        }
616    }
617
618    flush_pending(
619        &mut body,
620        &mut pending_group,
621        &mut pending_entries,
622        &mut has_message,
623    );
624
625    ast::Resource { body }
626}
627
628fn create_group_comment_entry(type_name: &str) -> ast::Entry<String> {
629    ast::Entry::GroupComment(ast::Comment {
630        content: vec![type_name.to_owned()],
631    })
632}
633
634fn create_message_entry(variant: &OwnedVariant) -> ast::Entry<String> {
635    let message_id = ast::Identifier {
636        name: variant.ftl_key.clone(),
637    };
638
639    let base_value = ValueFormatter::expand(&variant.name);
640
641    let mut elements = vec![ast::PatternElement::TextElement { value: base_value }];
642
643    for arg_name in &variant.args {
644        elements.push(ast::PatternElement::TextElement { value: " ".into() });
645
646        elements.push(ast::PatternElement::Placeable {
647            expression: ast::Expression::Inline(ast::InlineExpression::VariableReference {
648                id: ast::Identifier {
649                    name: arg_name.clone(),
650                },
651            }),
652        });
653    }
654
655    let pattern = ast::Pattern { elements };
656
657    ast::Entry::Message(ast::Message {
658        id: message_id,
659        value: Some(pattern),
660        attributes: Vec::new(),
661        comment: None,
662    })
663}
664
665fn merge_ftl_type_infos(items: &[&FtlTypeInfo]) -> Vec<OwnedTypeInfo> {
666    use std::collections::BTreeMap;
667
668    // Group by type_name. Callers already separate items by namespace.
669    let mut grouped: BTreeMap<String, Vec<OwnedVariant>> = BTreeMap::new();
670
671    for item in items {
672        let entry = grouped.entry(item.type_name.to_string()).or_default();
673        entry.extend(item.variants.iter().map(OwnedVariant::from));
674    }
675
676    grouped
677        .into_iter()
678        .map(|(type_name, mut variants)| {
679            variants.sort_by(|a, b| {
680                let a_is_this = a.ftl_key.ends_with(FluentKey::THIS_SUFFIX);
681                let b_is_this = b.ftl_key.ends_with(FluentKey::THIS_SUFFIX);
682                formatting::compare_with_this_priority(a_is_this, &a.name, b_is_this, &b.name)
683            });
684            variants.dedup();
685
686            OwnedTypeInfo {
687                type_name,
688                variants,
689            }
690        })
691        .collect()
692}
693
694fn build_target_resource(items: &[&FtlTypeInfo]) -> ast::Resource<String> {
695    let items = merge_ftl_type_infos(items);
696    let mut body: Vec<ast::Entry<String>> = Vec::new();
697    let mut sorted_items = items.to_vec();
698    sorted_items.sort_by(compare_type_infos);
699
700    for info in &sorted_items {
701        body.push(create_group_comment_entry(&info.type_name));
702
703        for variant in &info.variants {
704            body.push(create_message_entry(variant));
705        }
706    }
707
708    ast::Resource { body }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use es_fluent_derive_core::meta::TypeKind;
715    use std::path::PathBuf;
716    use tempfile::tempdir;
717
718    fn leak_str(s: impl ToString) -> &'static str {
719        s.to_string().leak()
720    }
721
722    fn leak_slice<T>(items: Vec<T>) -> &'static [T] {
723        items.leak()
724    }
725
726    fn test_variant(name: &str, ftl_key: &str, args: &[&str]) -> FtlVariant {
727        FtlVariant {
728            name: leak_str(name),
729            ftl_key: leak_str(ftl_key),
730            args: leak_slice(args.iter().map(|arg| leak_str(arg)).collect()),
731            module_path: "test",
732            line: 0,
733        }
734    }
735
736    fn test_type(name: &str, variants: Vec<FtlVariant>) -> FtlTypeInfo {
737        FtlTypeInfo {
738            type_kind: TypeKind::Struct,
739            type_name: leak_str(name),
740            variants: leak_slice(variants),
741            file_path: "",
742            module_path: "test",
743            namespace: None,
744        }
745    }
746
747    fn parse_resource_allowing_errors(input: &str) -> ast::Resource<String> {
748        parser::parse(input.to_string()).unwrap_or_else(|(resource, _)| resource)
749    }
750
751    #[test]
752    fn owned_type_info_and_entry_helpers_work() {
753        let info = test_type(
754            "Greeter",
755            vec![test_variant("HelloName", "greeter-hello_name", &["name"])],
756        );
757
758        let owned = OwnedTypeInfo::from(&info);
759        assert_eq!(owned.type_name, "Greeter");
760        assert_eq!(owned.variants.len(), 1);
761        assert_eq!(owned.variants[0].ftl_key, "greeter-hello_name");
762
763        let message = create_message_entry(&owned.variants[0]);
764        assert!(matches!(
765            &message,
766            ast::Entry::Message(msg) if msg.id.name == "greeter-hello_name"
767        ));
768
769        let group = create_group_comment_entry("Greeter");
770        assert!(matches!(
771            &group,
772            ast::Entry::GroupComment(comment)
773                if group_comment_name(comment) == Some("Greeter".to_string())
774        ));
775    }
776
777    #[test]
778    fn read_existing_and_write_updated_resource_cover_io_branches() {
779        let temp = tempdir().expect("tempdir");
780        let file_path = temp.path().join("example.ftl");
781
782        let missing = read_existing_resource(&file_path).expect("missing resource");
783        assert!(missing.body.is_empty());
784
785        std::fs::write(&file_path, "   \n").expect("write whitespace");
786        let empty = read_existing_resource(&file_path).expect("empty resource");
787        assert!(empty.body.is_empty());
788
789        std::fs::write(&file_path, "broken = {\n").expect("write invalid");
790        let partial = read_existing_resource(&file_path).expect("partial parse");
791        assert!(!partial.body.is_empty());
792
793        let updated = parse_resource_allowing_errors("updated = value\n");
794        let dry_changed =
795            write_updated_resource(&file_path, &updated, true, formatting::sort_ftl_resource)
796                .expect("dry run");
797        assert!(dry_changed);
798        assert!(
799            std::fs::read_to_string(&file_path)
800                .expect("read")
801                .contains("broken")
802        );
803
804        let changed =
805            write_updated_resource(&file_path, &updated, false, formatting::sort_ftl_resource)
806                .expect("write update");
807        assert!(changed);
808        let unchanged =
809            write_updated_resource(&file_path, &updated, false, formatting::sort_ftl_resource)
810                .expect("write unchanged");
811        assert!(!unchanged);
812
813        let empty_resource = ast::Resource { body: vec![] };
814        let emptied = write_updated_resource(
815            &file_path,
816            &empty_resource,
817            false,
818            formatting::sort_ftl_resource,
819        )
820        .expect("write empty");
821        assert!(emptied);
822        assert_eq!(std::fs::read_to_string(&file_path).expect("read empty"), "");
823    }
824
825    #[test]
826    fn write_or_preview_and_print_diff_cover_preview_and_write_paths() {
827        let temp = tempdir().expect("tempdir");
828        let file_path = temp.path().join("nested/preview.ftl");
829
830        write_or_preview(&file_path, "old = value\n", "new = value\n", false, true)
831            .expect("dry-run preview");
832        print_diff("old = value\n", "new = value\n");
833
834        write_or_preview(&file_path, "", "", true, false).expect("real write");
835        assert!(file_path.exists());
836    }
837
838    #[test]
839    fn write_updated_resource_covers_unchanged_empty_and_dry_run_empty_paths() {
840        let temp = tempdir().expect("tempdir");
841        let file_path = temp.path().join("empty.ftl");
842        std::fs::write(&file_path, "").expect("write empty file");
843
844        let empty_resource = ast::Resource { body: vec![] };
845        let unchanged = write_updated_resource(
846            &file_path,
847            &empty_resource,
848            false,
849            formatting::sort_ftl_resource,
850        )
851        .expect("unchanged empty write");
852        assert!(!unchanged);
853
854        let unchanged_dry_run = write_updated_resource(
855            &file_path,
856            &empty_resource,
857            true,
858            formatting::sort_ftl_resource,
859        )
860        .expect("unchanged dry run");
861        assert!(!unchanged_dry_run);
862
863        write_or_preview(&file_path, "old = value\n", "", true, true)
864            .expect("dry-run empty from non-empty");
865        write_or_preview(&file_path, "", "", true, true).expect("dry-run empty from empty");
866    }
867
868    #[test]
869    fn print_diff_handles_equal_lines_and_multiple_groups() {
870        let old = "line1 = old\nkeep1 = 1\nkeep2 = 2\nkeep3 = 3\nkeep4 = 4\nkeep5 = 5\nkeep6 = 6\nkeep7 = 7\nkeep8 = 8\nkeep9 = 9\nkeep10 = 10\nline12 = old\n";
871        let new = "line1 = new\nkeep1 = 1\nkeep2 = 2\nkeep3 = 3\nkeep4 = 4\nkeep5 = 5\nkeep6 = 6\nkeep7 = 7\nkeep8 = 8\nkeep9 = 9\nkeep10 = 10\nline12 = new\n";
872        print_diff(old, new);
873    }
874
875    #[test]
876    fn collect_existing_keys_and_remove_empty_group_comments_cover_terms_and_pending_groups() {
877        let resource = parse_resource_allowing_errors(
878            "## Empty\n# orphan-comment\n\n## Keep\nkeep = yes\n\n-shared = shared\n",
879        );
880
881        let keys = collect_existing_keys(&resource);
882        assert!(keys.contains("keep"));
883        assert!(keys.contains("-shared"));
884
885        let cleaned = remove_empty_group_comments(resource);
886        let formatted = formatting::sort_ftl_resource(&cleaned);
887        assert!(!formatted.contains("## Empty"));
888        assert!(formatted.contains("## Keep"));
889        assert!(formatted.contains("-shared = shared"));
890    }
891
892    #[test]
893    fn remove_empty_group_comments_keeps_top_level_entries_without_group() {
894        let resource = parse_resource_allowing_errors("top-level = value\n# loose comment\n");
895        let cleaned = remove_empty_group_comments(resource);
896        let formatted = formatting::sort_ftl_resource(&cleaned);
897        assert!(formatted.contains("top-level = value"));
898        assert!(formatted.contains("# loose comment"));
899    }
900
901    #[test]
902    fn insert_late_relocated_handles_empty_groups_and_duplicate_names() {
903        let mut no_groups = vec![create_message_entry(&OwnedVariant {
904            name: "Only".to_string(),
905            ftl_key: "only-key".to_string(),
906            args: vec![],
907        })];
908        let mut late = IndexMap::new();
909        late.insert(
910            "MissingGroup".to_string(),
911            vec![create_message_entry(&OwnedVariant {
912                name: "Late".to_string(),
913                ftl_key: "late-key".to_string(),
914                args: vec![],
915            })],
916        );
917        insert_late_relocated(&mut no_groups, &late);
918        assert_eq!(no_groups.len(), 1);
919
920        let mut body = parse_resource_allowing_errors(
921            "## GroupA\ngroup_a-A1 = A1\n\n## GroupB\ngroup_b-B1 = B1\n\n## GroupA\ngroup_a-A2 = A2\n",
922        )
923        .body;
924        let mut late_for_group = IndexMap::new();
925        late_for_group.insert(
926            "GroupA".to_string(),
927            vec![create_message_entry(&OwnedVariant {
928                name: "LateA".to_string(),
929                ftl_key: "group_a-late".to_string(),
930                args: vec![],
931            })],
932        );
933        insert_late_relocated(&mut body, &late_for_group);
934
935        let inserted_count = body
936            .iter()
937            .filter(
938                |entry| matches!(entry, ast::Entry::Message(msg) if msg.id.name == "group_a-late"),
939            )
940            .count();
941        assert_eq!(inserted_count, 1);
942    }
943
944    #[test]
945    fn smart_merge_covers_relocation_terms_junk_and_cleanup_modes() {
946        let group_a = test_type("GroupA", vec![test_variant("A1", "group_a-A1", &[])]);
947        let group_b = test_type(
948            "GroupB",
949            vec![
950                test_variant("B1", "group_b-B1", &[]),
951                test_variant("SharedTerm", "-shared_term", &[]),
952            ],
953        );
954        let items = vec![&group_a, &group_b];
955
956        let existing_append = parse_resource_allowing_errors(
957            "## GroupA\ngroup_b-B1 = wrong-group\n\n## GroupB\n-shared_term = shared\nbroken = {\n",
958        );
959        let merged_append = smart_merge(existing_append, &items, MergeBehavior::Append);
960        let merged_append_text = formatting::sort_ftl_resource(&merged_append);
961        assert!(merged_append_text.contains("## GroupA"));
962        assert!(merged_append_text.contains("## GroupB"));
963        assert!(merged_append_text.contains("group_b-B1 = wrong-group"));
964        assert!(merged_append_text.contains("-shared_term = shared"));
965
966        let existing_clean = parse_resource_allowing_errors(
967            "## GroupA\ngroup_b-B1 = wrong-group\n\n## GroupB\n-shared_term = shared\nbroken = {\n",
968        );
969        let merged_clean = smart_merge(existing_clean, &items, MergeBehavior::Clean);
970        let merged_clean_text = formatting::sort_ftl_resource(&merged_clean);
971        assert!(merged_clean_text.contains("-shared_term = shared"));
972        assert!(merged_clean_text.contains("group_b-B1 = wrong-group"));
973        assert!(!merged_clean_text.contains("group_a-A1"));
974    }
975
976    #[test]
977    fn smart_merge_handles_duplicates_empty_group_headers_and_comment_entries() {
978        let group_a = test_type(
979            "GroupA",
980            vec![
981                test_variant("A1", "dup-key", &[]),
982                test_variant("SharedTerm", "-dup-term", &[]),
983            ],
984        );
985        let items = vec![&group_a];
986
987        let mut existing = parse_resource_allowing_errors(
988            "## GroupA\ndup-key = first\ndup-key = second\n-dup-term = one\n-dup-term = two\n",
989        );
990        existing.body.push(ast::Entry::Comment(ast::Comment {
991            content: vec!["loose-comment".to_string()],
992        }));
993        existing
994            .body
995            .push(ast::Entry::GroupComment(ast::Comment { content: vec![] }));
996
997        let merged = smart_merge(existing, &items, MergeBehavior::Append);
998        let merged_text = formatting::sort_ftl_resource(&merged);
999        assert_eq!(merged_text.matches("dup-key =").count(), 1);
1000        assert_eq!(merged_text.matches("-dup-term =").count(), 1);
1001        assert!(merged_text.contains("# loose-comment"));
1002    }
1003
1004    #[test]
1005    fn smart_merge_appends_relocated_entries_for_group_switch_and_missing_group_header() {
1006        let group_x = test_type("GroupX", vec![]);
1007        let group_a = test_type(
1008            "GroupA",
1009            vec![
1010                test_variant("A1", "group_a-A1", &[]),
1011                test_variant("A2", "group_a-A2", &[]),
1012            ],
1013        );
1014        let group_b = test_type("GroupB", vec![test_variant("B1", "group_b-B1", &[])]);
1015        let group_c = test_type("GroupC", vec![test_variant("C1", "group_c-C1", &[])]);
1016        let items = vec![&group_x, &group_a, &group_b, &group_c];
1017
1018        let existing = parse_resource_allowing_errors(
1019            "## GroupX\ngroup_a-A1 = moved-to-a\ngroup_b-B1 = moved-to-b\n\n## GroupA\ngroup_a-A2 = keep-a2\n\n## GroupC\ngroup_c-C1 = keep-c1\n",
1020        );
1021        let merged = smart_merge(existing, &items, MergeBehavior::Append);
1022        let merged_text = formatting::sort_ftl_resource(&merged);
1023
1024        assert!(merged_text.contains("group_a-A1 = moved-to-a"));
1025        assert!(merged_text.contains("## GroupB"));
1026        assert!(merged_text.contains("group_b-B1 = moved-to-b"));
1027    }
1028
1029    #[test]
1030    fn generate_creates_namespaced_directories_and_handles_dry_run() {
1031        let temp = tempdir().expect("tempdir");
1032        let i18n_root = temp.path().join("i18n");
1033
1034        let mut namespaced = test_type("NamespacedType", vec![test_variant("A1", "ns-a1", &[])]);
1035        namespaced.namespace = Some(es_fluent_derive_core::registry::NamespaceRule::Literal(
1036            "ui",
1037        ));
1038        let items = vec![&namespaced];
1039
1040        let changed = generate(
1041            "crate-name",
1042            &i18n_root,
1043            temp.path(),
1044            &items,
1045            FluentParseMode::Conservative,
1046            false,
1047        )
1048        .expect("generate namespaced");
1049        assert!(changed);
1050        assert!(i18n_root.join("crate-name/ui.ftl").exists());
1051
1052        let dry_run_path = PathBuf::from("dry_run/absent.ftl");
1053        write_or_preview(&dry_run_path, "a = b\n", "a = c\n", false, true).expect("dry run");
1054    }
1055}