Skip to main content

langcodec_cli/
merge.rs

1use crate::formats::parse_custom_format;
2use crate::transformers::custom_format_to_resource;
3use crate::ui;
4
5use langcodec::{Codec, ReadOptions, converter};
6use rayon::prelude::*;
7
8/// Strategy for handling conflicts when merging localization files.
9#[derive(Debug, Clone, PartialEq, clap::ValueEnum)]
10pub enum ConflictStrategy {
11    /// Keep the first occurrence of a key
12    First,
13    /// Keep the last occurrence of a key (default)
14    Last,
15    /// Skip conflicting entries
16    Skip,
17}
18
19fn resolve_merge_output_format(
20    output: &str,
21    lang: Option<&String>,
22) -> Result<langcodec::FormatType, String> {
23    let mut output_format = converter::infer_format_from_path(output)
24        .ok_or_else(|| format!("Cannot infer format from output path: {}", output))?;
25
26    let path_language = match &output_format {
27        langcodec::FormatType::Strings(Some(language))
28        | langcodec::FormatType::AndroidStrings(Some(language)) => Some(language.clone()),
29        _ => None,
30    };
31
32    match &output_format {
33        langcodec::FormatType::Strings(_) | langcodec::FormatType::AndroidStrings(_) => {
34            if let Some(language) = lang {
35                if let Some(path_language) = path_language
36                    && path_language != *language
37                {
38                    return Err(format!(
39                        "--lang '{}' conflicts with language '{}' implied by output path '{}'",
40                        language, path_language, output
41                    ));
42                }
43                output_format = output_format.with_language(Some(language.clone()));
44            }
45            Ok(output_format)
46        }
47        langcodec::FormatType::Xliff(_) => Err(
48            ".xliff is not supported by `merge` in v1. Use `convert` for XLIFF generation."
49                .to_string(),
50        ),
51        langcodec::FormatType::Xcstrings
52        | langcodec::FormatType::CSV
53        | langcodec::FormatType::TSV => Ok(output_format),
54    }
55}
56
57/// Run the merge command: merge multiple localization files into one output file.
58pub fn run_merge_command(
59    inputs: Vec<String>,
60    output: String,
61    strategy: ConflictStrategy,
62    lang: Option<String>,
63    source_language_override: Option<String>,
64    version_override: Option<String>,
65    strict: bool,
66) {
67    if inputs.is_empty() {
68        eprintln!(
69            "{}",
70            ui::status_line_stderr(
71                ui::Tone::Error,
72                "Error: At least one input file is required."
73            )
74        );
75        std::process::exit(1);
76    }
77
78    // Read all input files concurrently into Codecs, then combine and merge
79    println!(
80        "{}",
81        ui::status_line_stdout(
82            ui::Tone::Info,
83            &format!("Reading {} input files...", inputs.len()),
84        )
85    );
86    let read_results: Vec<Result<Codec, String>> = inputs
87        .par_iter()
88        .map(|input| read_input_to_codec(input, lang.clone(), strict))
89        .collect();
90
91    let mut input_codecs: Vec<Codec> = Vec::with_capacity(read_results.len());
92    for (idx, res) in read_results.into_iter().enumerate() {
93        match res {
94            Ok(c) => input_codecs.push(c),
95            Err(e) => {
96                println!(
97                    "{}",
98                    ui::status_line_stdout(
99                        ui::Tone::Error,
100                        &format!("Error reading input file {}/{}", idx + 1, inputs.len()),
101                    )
102                );
103                eprintln!("{}", e);
104                std::process::exit(1);
105            }
106        }
107    }
108
109    // Combine all input codecs first, then merge by language
110    let mut codec = Codec::from_codecs(input_codecs);
111
112    // Skip validation for merge operations since we expect multiple resources with potentially duplicate languages
113
114    // Merge resources using the new lib crate method
115    println!(
116        "{}",
117        ui::status_line_stdout(ui::Tone::Info, "Merging resources...")
118    );
119    let conflict_strategy = match strategy {
120        ConflictStrategy::First => langcodec::types::ConflictStrategy::First,
121        ConflictStrategy::Last => langcodec::types::ConflictStrategy::Last,
122        ConflictStrategy::Skip => langcodec::types::ConflictStrategy::Skip,
123    };
124
125    let merge_count = codec.merge_resources(&conflict_strategy);
126    println!(
127        "{}",
128        ui::status_line_stdout(
129            ui::Tone::Success,
130            &format!("Merged {} language groups", merge_count),
131        )
132    );
133
134    println!(
135        "{}",
136        ui::status_line_stdout(ui::Tone::Info, "Writing merged output...")
137    );
138    match resolve_merge_output_format(&output, lang.as_ref()) {
139        Ok(format) => {
140            println!(
141                "{}",
142                ui::status_line_stdout(
143                    ui::Tone::Info,
144                    &format!("Converting resources to format: {:?}", format),
145                )
146            );
147            // Set source_language field in the resources to make sure xcstrings format would not throw an error
148            // First, try to get the source language from the first resource if it exists; otherwise, the first resource's language
149            // would be used as the source language. If the two checks fail, the default value "en" would be used.
150            let source_language = source_language_override
151                .filter(|s| !s.trim().is_empty())
152                .unwrap_or_else(|| {
153                    codec
154                        .resources
155                        .first()
156                        .and_then(|r| {
157                            r.metadata
158                                .custom
159                                .get("source_language")
160                                .cloned()
161                                .filter(|s| !s.trim().is_empty())
162                        })
163                        .unwrap_or_else(|| {
164                            codec
165                                .resources
166                                .first()
167                                .map(|r| r.metadata.language.clone())
168                                .unwrap_or("en".to_string())
169                        })
170                });
171
172            println!(
173                "{}",
174                ui::status_line_stdout(
175                    ui::Tone::Accent,
176                    &format!("Setting metadata.source_language to: {}", source_language),
177                )
178            );
179
180            // Set version field in the resources to make sure xcstrings format would not throw an error
181            let version = version_override.unwrap_or_else(|| {
182                codec
183                    .resources
184                    .first()
185                    .and_then(|r| r.metadata.custom.get("version").cloned())
186                    .unwrap_or_else(|| "1.0".to_string())
187            });
188
189            println!(
190                "{}",
191                ui::status_line_stdout(
192                    ui::Tone::Accent,
193                    &format!("Setting metadata.version to: {}", version),
194                )
195            );
196
197            codec.iter_mut().for_each(|r| {
198                r.metadata
199                    .custom
200                    .insert("source_language".to_string(), source_language.clone());
201                r.metadata
202                    .custom
203                    .insert("version".to_string(), version.clone());
204            });
205
206            if let Err(e) = converter::convert_resources_to_format(codec.resources, &output, format)
207            {
208                println!(
209                    "{}",
210                    ui::status_line_stdout(ui::Tone::Error, "Error converting resources to format")
211                );
212                eprintln!("Error converting to {}: {}", output, e);
213                std::process::exit(1);
214            }
215        }
216        Err(e) => {
217            println!(
218                "{}",
219                ui::status_line_stdout(ui::Tone::Error, "Error writing output file")
220            );
221            eprintln!("Error writing to {}: {}", output, e);
222            std::process::exit(1);
223        }
224    }
225
226    println!(
227        "{}",
228        ui::status_line_stdout(
229            ui::Tone::Success,
230            &format!("Successfully merged {} files into {}", inputs.len(), output),
231        )
232    );
233}
234
235/// Read a single input file into a vector of Resources, supporting both standard and custom formats
236fn read_input_to_resources(
237    input: &str,
238    lang: Option<String>,
239    strict: bool,
240) -> Result<Vec<langcodec::Resource>, String> {
241    if strict {
242        if input.ends_with(".json") || input.ends_with(".yaml") || input.ends_with(".yml") {
243            crate::validation::validate_custom_format_file(input)
244                .map_err(|e| format!("Failed to validate {}: {}", input, e))?;
245
246            let file_content = std::fs::read_to_string(input)
247                .map_err(|e| format!("Error reading file {}: {}", input, e))?;
248
249            crate::formats::validate_custom_format_content(input, &file_content)
250                .map_err(|e| format!("Invalid custom format {}: {}", input, e))?;
251
252            let resources = custom_format_to_resource(
253                input.to_string(),
254                parse_custom_format("json-language-map")
255                    .map_err(|e| format!("Failed to parse custom format: {}", e))?,
256            )
257            .map_err(|e| format!("Failed to convert custom format {}: {}", input, e))?;
258
259            return Ok(resources);
260        }
261
262        let mut local_codec = Codec::new();
263        local_codec
264            .read_file_by_extension_with_options(
265                input,
266                &ReadOptions::new()
267                    .with_language_hint(lang)
268                    .with_strict(true),
269            )
270            .map_err(|e| format!("Error reading {}: {}", input, e))?;
271        return Ok(local_codec.resources);
272    }
273
274    // Try standard format via lib crate (uses extension + language inference)
275    {
276        let mut local_codec = Codec::new();
277        if let Ok(()) = local_codec.read_file_by_extension(input, lang.clone()) {
278            return Ok(local_codec.resources);
279        }
280    }
281
282    // Try custom JSON/YAML formats (for merge, we follow the existing JSON-language-map behavior)
283    if input.ends_with(".json") || input.ends_with(".yaml") || input.ends_with(".yml") {
284        // Validate custom format file
285        crate::validation::validate_custom_format_file(input)
286            .map_err(|e| format!("Failed to validate {}: {}", input, e))?;
287
288        // Auto-detect format based on file content
289        let file_content = std::fs::read_to_string(input)
290            .map_err(|e| format!("Error reading file {}: {}", input, e))?;
291
292        // Validate file content (ignore returned format; keep parity with existing merge behavior)
293        crate::formats::validate_custom_format_content(input, &file_content)
294            .map_err(|e| format!("Invalid custom format {}: {}", input, e))?;
295
296        // Convert custom format to Resource using JSON language map to match current merge behavior
297        let resources = custom_format_to_resource(
298            input.to_string(),
299            parse_custom_format("json-language-map")
300                .map_err(|e| format!("Failed to parse custom format: {}", e))?,
301        )
302        .map_err(|e| format!("Failed to convert custom format {}: {}", input, e))?;
303
304        return Ok(resources);
305    }
306
307    Err(format!("Error reading {}: unsupported format", input))
308}
309
310/// Read a single input into a Codec (wrapper over read_input_to_resources)
311fn read_input_to_codec(input: &str, lang: Option<String>, strict: bool) -> Result<Codec, String> {
312    let resources = read_input_to_resources(input, lang, strict)?;
313    Ok(Codec { resources })
314}