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