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#[derive(Debug, Clone, PartialEq, clap::ValueEnum)]
10pub enum ConflictStrategy {
11 First,
13 Last,
15 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
57pub 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 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 let mut codec = Codec::from_codecs(input_codecs);
111
112 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 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 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
235fn 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 {
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 if input.ends_with(".json") || input.ends_with(".yaml") || input.ends_with(".yml") {
284 crate::validation::validate_custom_format_file(input)
286 .map_err(|e| format!("Failed to validate {}: {}", input, e))?;
287
288 let file_content = std::fs::read_to_string(input)
290 .map_err(|e| format!("Error reading file {}: {}", input, e))?;
291
292 crate::formats::validate_custom_format_content(input, &file_content)
294 .map_err(|e| format!("Invalid custom format {}: {}", input, e))?;
295
296 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
310fn 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}