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#[derive(Clone, Debug, Default, strum::Display, PartialEq, ValueEnum)]
20#[strum(serialize_all = "snake_case")]
21pub enum FluentParseMode {
22 Aggressive,
24 #[default]
26 Conservative,
27}
28
29#[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
62pub 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 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 let dir = i18n_path.join(crate_name);
89 let file = dir.join(format!("{}.ftl", ns));
90 (dir, file)
91 },
92 None => {
93 (
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
154fn 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
181fn 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 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 ¤t_content,
217 &final_content,
218 is_empty,
219 dry_run,
220 )?;
221 Ok(true)
222}
223
224fn 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
239fn 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
276fn compare_type_infos(a: &OwnedTypeInfo, b: &OwnedTypeInfo) -> std::cmp::Ordering {
278 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 Append,
295 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 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 if let Some(ref last_group) = current_group_name
451 && let Some(info) = item_map.get_mut(last_group)
452 {
453 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 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 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}