Skip to content

Commit 6d9849d

Browse files
committed
fix(core): tighten single-language output handling
1 parent 96a0755 commit 6d9849d

7 files changed

Lines changed: 729 additions & 164 deletions

File tree

langcodec-cli/src/convert.rs

Lines changed: 191 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,133 @@ pub struct ConvertOptions {
1313
pub output_format: Option<String>,
1414
pub source_language: Option<String>,
1515
pub version: Option<String>,
16+
pub output_lang: Option<String>,
1617
pub exclude_lang: Vec<String>,
1718
pub include_lang: Vec<String>,
1819
}
1920

21+
fn parse_standard_output_format(format: &str) -> Result<FormatType, String> {
22+
match format.to_lowercase().as_str() {
23+
"strings" => Ok(FormatType::Strings(None)),
24+
"android" | "androidstrings" => Ok(FormatType::AndroidStrings(None)),
25+
"xcstrings" => Ok(FormatType::Xcstrings),
26+
"csv" => Ok(FormatType::CSV),
27+
"tsv" => Ok(FormatType::TSV),
28+
_ => Err(format!(
29+
"Unsupported output format: '{}'. Supported formats: strings, android, xcstrings, csv, tsv",
30+
format
31+
)),
32+
}
33+
}
34+
35+
fn infer_output_path_language(path: &str) -> Option<String> {
36+
match langcodec::infer_format_from_path(path) {
37+
Some(FormatType::Strings(Some(lang))) | Some(FormatType::AndroidStrings(Some(lang))) => {
38+
Some(lang)
39+
}
40+
_ => None,
41+
}
42+
}
43+
44+
fn resolve_convert_output_format(
45+
output: &str,
46+
output_format_hint: Option<&String>,
47+
output_lang: Option<&String>,
48+
) -> Result<FormatType, String> {
49+
let mut output_format = if let Some(format_hint) = output_format_hint {
50+
parse_standard_output_format(format_hint)?
51+
} else {
52+
langcodec::infer_format_from_path(output)
53+
.or_else(|| langcodec::infer_format_from_extension(output))
54+
.ok_or_else(|| format!("Cannot infer output format from extension: {}", output))?
55+
};
56+
57+
let path_language = infer_output_path_language(output);
58+
59+
match &output_format {
60+
FormatType::Strings(_) | FormatType::AndroidStrings(_) => {
61+
if let Some(language) = output_lang {
62+
if let Some(path_language) = path_language
63+
&& path_language != *language
64+
{
65+
return Err(format!(
66+
"--output-lang '{}' conflicts with language '{}' implied by output path '{}'",
67+
language, path_language, output
68+
));
69+
}
70+
output_format = output_format.with_language(Some(language.clone()));
71+
} else if let Some(path_language) = path_language {
72+
output_format = output_format.with_language(Some(path_language));
73+
}
74+
Ok(output_format)
75+
}
76+
FormatType::Xcstrings | FormatType::CSV | FormatType::TSV => {
77+
if let Some(language) = output_lang {
78+
Err(format!(
79+
"--output-lang '{}' is only supported for single-language outputs (.strings, strings.xml)",
80+
language
81+
))
82+
} else {
83+
Ok(output_format)
84+
}
85+
}
86+
}
87+
}
88+
2089
pub fn run_unified_convert_command(
2190
input: String,
2291
output: String,
2392
options: ConvertOptions,
2493
strict: bool,
2594
) {
95+
if let Some(output_lang) = options.output_lang.as_ref() {
96+
if output.ends_with(".langcodec") {
97+
eprintln!(
98+
"Error: --output-lang '{}' is not supported for .langcodec output. Use --include-lang instead.",
99+
output_lang
100+
);
101+
std::process::exit(1);
102+
}
103+
104+
println!(
105+
"{}",
106+
ui::status_line_stdout(
107+
ui::Tone::Info,
108+
&format!("Converting with explicit output language '{}'", output_lang),
109+
)
110+
);
111+
match read_resources_from_any_input(&input, options.input_format.as_ref(), strict).and_then(
112+
|resources| {
113+
let output_format = resolve_convert_output_format(
114+
&output,
115+
options.output_format.as_ref(),
116+
options.output_lang.as_ref(),
117+
)?;
118+
convert_resources_to_format(resources, &output, output_format)
119+
.map_err(|e| format!("Error converting to output format: {}", e))
120+
},
121+
) {
122+
Ok(()) => {
123+
println!(
124+
"{}",
125+
ui::status_line_stdout(
126+
ui::Tone::Success,
127+
"Successfully converted with explicit output language",
128+
)
129+
);
130+
return;
131+
}
132+
Err(e) => {
133+
println!(
134+
"{}",
135+
ui::status_line_stdout(ui::Tone::Error, "Conversion failed")
136+
);
137+
eprintln!("Error: {}", e);
138+
std::process::exit(1);
139+
}
140+
}
141+
}
142+
26143
// Special handling: when targeting xcstrings, ensure required metadata exists.
27144
// If source_language/version are missing, default to en/1.0 respectively.
28145
let wants_xcstrings = output.ends_with(".xcstrings")
@@ -230,7 +347,13 @@ pub fn run_unified_convert_command(
230347
"Strict mode: converting with explicit format hints only...",
231348
)
232349
);
233-
if let Err(e) = try_explicit_format_conversion(&input, &output, input_fmt, output_fmt) {
350+
if let Err(e) = try_explicit_format_conversion(
351+
&input,
352+
&output,
353+
input_fmt,
354+
output_fmt,
355+
options.output_lang.as_ref(),
356+
) {
234357
println!(
235358
"{}",
236359
ui::status_line_stdout(ui::Tone::Error, "Strict conversion failed")
@@ -257,7 +380,13 @@ pub fn run_unified_convert_command(
257380
"Strict mode: converting custom format without fallback...",
258381
)
259382
);
260-
if let Err(e) = try_custom_format_conversion(&input, &output, &options.input_format) {
383+
if let Err(e) = try_custom_format_conversion(
384+
&input,
385+
&output,
386+
&options.input_format,
387+
options.output_format.as_ref(),
388+
options.output_lang.as_ref(),
389+
) {
261390
println!(
262391
"{}",
263392
ui::status_line_stdout(ui::Tone::Error, "Strict conversion failed")
@@ -326,7 +455,7 @@ pub fn run_unified_convert_command(
326455
ui::status_line_stdout(ui::Tone::Info, "Trying standard JSON format detection...",)
327456
);
328457
// Try to use the standard format detection which will show proper JSON parsing errors
329-
if let Err(e) = convert_auto(&input, &output) {
458+
if convert_auto(&input, &output).is_err() {
330459
println!(
331460
"{}",
332461
ui::status_line_stdout(
@@ -335,32 +464,48 @@ pub fn run_unified_convert_command(
335464
)
336465
);
337466
// If standard detection fails, try custom formats
338-
if let Ok(()) = try_custom_format_conversion(&input, &output, &options.input_format)
339-
{
340-
println!(
341-
"{}",
342-
ui::status_line_stdout(
343-
ui::Tone::Success,
344-
"Successfully converted using custom JSON format",
345-
)
346-
);
347-
return;
467+
match try_custom_format_conversion(
468+
&input,
469+
&output,
470+
&options.input_format,
471+
options.output_format.as_ref(),
472+
options.output_lang.as_ref(),
473+
) {
474+
Ok(()) => {
475+
println!(
476+
"{}",
477+
ui::status_line_stdout(
478+
ui::Tone::Success,
479+
"Successfully converted using custom JSON format",
480+
)
481+
);
482+
return;
483+
}
484+
Err(custom_error) => {
485+
// If both fail, show the custom conversion error because it is usually
486+
// more actionable than the initial extension-based failure.
487+
println!(
488+
"{}",
489+
ui::status_line_stdout(ui::Tone::Error, "Conversion failed")
490+
);
491+
eprintln!("Error: {}", custom_error);
492+
std::process::exit(1);
493+
}
348494
}
349-
// If both fail, show the standard error message
350-
println!(
351-
"{}",
352-
ui::status_line_stdout(ui::Tone::Error, "Conversion failed")
353-
);
354-
eprintln!("Error: {}", e);
355-
std::process::exit(1);
356495
}
357496
} else {
358497
// For YAML and langcodec files, try custom formats directly
359498
println!(
360499
"{}",
361500
ui::status_line_stdout(ui::Tone::Info, "Converting using custom format...")
362501
);
363-
if let Err(e) = try_custom_format_conversion(&input, &output, &options.input_format) {
502+
if let Err(e) = try_custom_format_conversion(
503+
&input,
504+
&output,
505+
&options.input_format,
506+
options.output_format.as_ref(),
507+
options.output_lang.as_ref(),
508+
) {
364509
println!(
365510
"{}",
366511
ui::status_line_stdout(ui::Tone::Error, "Custom format conversion failed",)
@@ -387,7 +532,13 @@ pub fn run_unified_convert_command(
387532
"{}",
388533
ui::status_line_stdout(ui::Tone::Info, "Converting with explicit format hints...")
389534
);
390-
if let Err(e) = try_explicit_format_conversion(&input, &output, &input_fmt, &output_fmt) {
535+
if let Err(e) = try_explicit_format_conversion(
536+
&input,
537+
&output,
538+
&input_fmt,
539+
&output_fmt,
540+
options.output_lang.as_ref(),
541+
) {
391542
println!(
392543
"{}",
393544
ui::status_line_stdout(ui::Tone::Error, "Explicit format conversion failed",)
@@ -418,6 +569,8 @@ fn try_custom_format_conversion(
418569
input: &str,
419570
output: &str,
420571
input_format: &Option<String>,
572+
output_format: Option<&String>,
573+
output_lang: Option<&String>,
421574
) -> Result<(), String> {
422575
// Validate custom format file
423576
validate_custom_format_file(input)?;
@@ -443,8 +596,7 @@ fn try_custom_format_conversion(
443596
}
444597

445598
// Get output format type
446-
let output_format_type = langcodec::infer_format_from_extension(output)
447-
.ok_or_else(|| format!("Cannot infer output format from extension: {}", output))?;
599+
let output_format_type = resolve_convert_output_format(output, output_format, output_lang)?;
448600

449601
// Convert to target format
450602
convert_resources_to_format(resources, output, output_format_type)
@@ -511,6 +663,7 @@ fn try_explicit_format_conversion(
511663
output: &str,
512664
input_format: &str,
513665
output_format: &str,
666+
output_lang: Option<&String>,
514667
) -> Result<(), String> {
515668
// Validate input file exists
516669
validation::validate_file_path(input)?;
@@ -543,19 +696,8 @@ fn try_explicit_format_conversion(
543696
write_resources_as_langcodec(&codec.resources, output)
544697
} else {
545698
// Parse output format
546-
let output_format_type = match output_format.to_lowercase().as_str() {
547-
"strings" => langcodec::formats::FormatType::Strings(None),
548-
"android" | "androidstrings" => langcodec::formats::FormatType::AndroidStrings(None),
549-
"xcstrings" => langcodec::formats::FormatType::Xcstrings,
550-
"csv" => langcodec::formats::FormatType::CSV,
551-
"tsv" => langcodec::formats::FormatType::TSV,
552-
_ => {
553-
return Err(format!(
554-
"Unsupported output format: '{}'. Supported formats: strings, android, xcstrings, csv, tsv",
555-
output_format
556-
));
557-
}
558-
};
699+
let output_format_type =
700+
resolve_convert_output_format(output, Some(&output_format.to_string()), output_lang)?;
559701

560702
// Use the lib crate's convert function
561703
langcodec::convert(input, input_format_type, output, output_format_type)
@@ -748,23 +890,25 @@ pub fn read_resources_from_any_input(
748890
let err_prefix = format!("Failed to read input with language '{}': ", lang);
749891

750892
let format_type = if input.ends_with(".strings") {
751-
langcodec::formats::FormatType::Strings(Some(lang))
893+
Some(langcodec::formats::FormatType::Strings(Some(lang)))
752894
} else if input.ends_with(".xml") {
753-
langcodec::formats::FormatType::AndroidStrings(Some(lang))
895+
Some(langcodec::formats::FormatType::AndroidStrings(Some(lang)))
754896
} else if input.ends_with(".xcstrings") {
755-
langcodec::formats::FormatType::Xcstrings
897+
Some(langcodec::formats::FormatType::Xcstrings)
756898
} else if input.ends_with(".csv") {
757-
langcodec::formats::FormatType::CSV
899+
Some(langcodec::formats::FormatType::CSV)
758900
} else if input.ends_with(".tsv") {
759-
langcodec::formats::FormatType::TSV
901+
Some(langcodec::formats::FormatType::TSV)
760902
} else {
761-
return Err(format!("Unsupported file extension for input: {}", input));
903+
None
762904
};
763905

764-
let mut codec = Codec::new();
765-
codec
766-
.read_file_by_type(input, format_type)
767-
.map_err(|e2| format!("{err_prefix}{e2}"))?;
906+
if let Some(format_type) = format_type {
907+
let mut codec = Codec::new();
908+
codec
909+
.read_file_by_type(input, format_type)
910+
.map_err(|e2| format!("{err_prefix}{e2}"))?;
911+
}
768912
} else {
769913
eprintln!("Standard format detection failed: {}", e);
770914
}

langcodec-cli/src/main.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ enum Commands {
7878
/// For xcstrings output: override version (default: 1.0)
7979
#[arg(long)]
8080
version: Option<String>,
81+
/// Select the output language for single-language outputs like `.strings` or `strings.xml`
82+
#[arg(long, value_name = "LANG")]
83+
output_lang: Option<String>,
8184
/// Language codes to exclude from output (e.g., "en", "fr"). Can be specified multiple times or as comma-separated values (e.g., "--exclude-lang en,fr,zh-hans"). Only affects .langcodec output format.
8285
#[arg(long, value_name = "LANG", value_delimiter = ',')]
8386
exclude_lang: Vec<String>,
@@ -545,6 +548,7 @@ fn main() {
545548
output,
546549
input_format,
547550
output_format,
551+
output_lang,
548552
exclude_lang,
549553
include_lang,
550554
source_language,
@@ -561,6 +565,9 @@ fn main() {
561565
if let Some(format) = &output_format {
562566
context = context.with_output_format(format.clone());
563567
}
568+
if let Some(lang) = &output_lang {
569+
context = context.with_language_code(lang.clone());
570+
}
564571

565572
// Validate all inputs
566573
if let Err(e) = validate_context(&context) {
@@ -579,6 +586,7 @@ fn main() {
579586
output_format,
580587
source_language,
581588
version,
589+
output_lang,
582590
exclude_lang,
583591
include_lang,
584592
},

0 commit comments

Comments
 (0)