Skip to content

Commit 7cd0e76

Browse files
committed
feat(cli): add normalize multi-file safety and error aggregation
1 parent 7bb9787 commit 7cd0e76

3 files changed

Lines changed: 195 additions & 27 deletions

File tree

langcodec-cli/src/main.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ enum Commands {
223223
/// Key renaming style: none|snake|kebab|camel
224224
#[arg(long, default_value = "none")]
225225
key_style: String,
226+
227+
/// Continue processing remaining files when a file fails
228+
#[arg(long, default_value_t = false)]
229+
continue_on_error: bool,
226230
},
227231

228232
/// Show translation coverage and per-status counts.
@@ -631,6 +635,7 @@ fn main() {
631635
check,
632636
no_placeholders,
633637
key_style,
638+
continue_on_error,
634639
} => {
635640
let opts = NormalizeCliOptions {
636641
inputs,
@@ -639,6 +644,7 @@ fn main() {
639644
check,
640645
no_placeholders,
641646
key_style,
647+
continue_on_error,
642648
};
643649
if let Err(e) = run_normalize_command(opts) {
644650
eprintln!("❌ Normalize failed: {}", e);

langcodec-cli/src/normalize.rs

Lines changed: 119 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use crate::validation::{validate_file_path, validate_output_path};
33
use langcodec::{
44
Codec, FormatType, KeyStyle, NormalizeOptions as EngineNormalizeOptions, normalize_codec,
55
};
6+
use std::collections::HashSet;
67
use std::path::Path;
78

89
#[derive(Debug, Clone)]
@@ -13,6 +14,7 @@ pub struct NormalizeCliOptions {
1314
pub check: bool,
1415
pub no_placeholders: bool,
1516
pub key_style: String,
17+
pub continue_on_error: bool,
1618
}
1719

1820
fn parse_key_style(input: &str) -> Result<KeyStyle, String> {
@@ -68,73 +70,163 @@ fn has_distinct_output_path(input_path: &str, output_path: &Option<String>) -> b
6870
.is_some_and(|output| Path::new(output) != Path::new(input_path))
6971
}
7072

71-
pub fn run_normalize_command(opts: NormalizeCliOptions) -> Result<(), String> {
72-
let expanded = path_glob::expand_input_globs(&opts.inputs)
73-
.map_err(|e| format!("Failed to expand input patterns: {}", e))?;
74-
if expanded.is_empty() {
75-
return Err("No input files matched the provided patterns".to_string());
76-
}
77-
if expanded.len() > 1 {
78-
return Err("Normalize currently supports exactly one input file".to_string());
79-
}
73+
fn has_glob_meta(input: &str) -> bool {
74+
input
75+
.bytes()
76+
.any(|b| matches!(b, b'*' | b'?' | b'[' | b'{'))
77+
}
8078

81-
let input = &expanded[0];
79+
fn run_normalize_for_file(
80+
input: &str,
81+
output: &Option<String>,
82+
dry_run: bool,
83+
check: bool,
84+
no_placeholders: bool,
85+
key_style: &KeyStyle,
86+
) -> Result<bool, String> {
8287
validate_file_path(input)?;
8388

8489
let mut codec = Codec::new();
8590
codec
8691
.read_file_by_extension(input, None)
8792
.map_err(|e| format!("Failed to read input '{}': {}", input, e))?;
8893

89-
let key_style = parse_key_style(&opts.key_style)?;
9094
let report = normalize_codec(
9195
&mut codec,
9296
&EngineNormalizeOptions {
93-
normalize_placeholders: !opts.no_placeholders,
94-
key_style,
97+
normalize_placeholders: !no_placeholders,
98+
key_style: key_style.clone(),
9599
},
96100
)
97101
.map_err(|e| e.to_string())?;
98102

99-
if opts.check {
103+
if check {
100104
if report.changed {
101105
println!("would change: {}", input);
102106
return Err(format!("would change: {}", input));
103107
}
104108

105109
println!("No changes needed: {}", input);
106-
return Ok(());
110+
return Ok(false);
107111
}
108112

109-
if opts.dry_run {
113+
if dry_run {
110114
if report.changed {
111115
println!("DRY-RUN: would change {}", input);
116+
return Ok(true);
112117
} else {
113118
println!("No changes needed: {}", input);
114119
}
115-
return Ok(());
120+
return Ok(false);
116121
}
117122

118123
if !report.changed {
119-
if has_distinct_output_path(input, &opts.output) {
120-
if let Some(output) = &opts.output {
124+
if has_distinct_output_path(input, output) {
125+
if let Some(output) = output {
121126
validate_output_path(output)?;
122127
}
123-
write_back(&codec, input, &opts.output)?;
128+
write_back(&codec, input, output)?;
124129
println!("No changes needed: {}", input);
125-
println!("✅ Wrote output: {}", opts.output.as_deref().unwrap_or(input));
126-
return Ok(());
130+
println!("✅ Wrote output: {}", output.as_deref().unwrap_or(input));
131+
return Ok(false);
127132
}
128133

129134
println!("No changes needed: {}", input);
130-
return Ok(());
135+
return Ok(false);
131136
}
132137

133-
if let Some(output) = &opts.output {
138+
if let Some(output) = output {
134139
validate_output_path(output)?;
135140
}
136141

137-
write_back(&codec, input, &opts.output)?;
138-
println!("✅ Normalized: {}", opts.output.as_deref().unwrap_or(input));
139-
Ok(())
142+
write_back(&codec, input, output)?;
143+
println!("✅ Normalized: {}", output.as_deref().unwrap_or(input));
144+
Ok(true)
145+
}
146+
147+
pub fn run_normalize_command(opts: NormalizeCliOptions) -> Result<(), String> {
148+
let expanded = path_glob::expand_input_globs(&opts.inputs)
149+
.map_err(|e| format!("Failed to expand input patterns: {}", e))?;
150+
if expanded.is_empty() {
151+
return Err("No input files matched the provided patterns".to_string());
152+
}
153+
154+
if expanded.len() > 1 && opts.output.is_some() {
155+
return Err("--output cannot be used with multiple input files".to_string());
156+
}
157+
158+
let key_style = parse_key_style(&opts.key_style)?;
159+
160+
let mut skip_missing: HashSet<String> = HashSet::new();
161+
let mut failures: Vec<String> = Vec::new();
162+
let mut processed_count: usize = 0;
163+
let mut success_count: usize = 0;
164+
let mut failed_count: usize = 0;
165+
let mut changed_count: usize = 0;
166+
167+
for original in &opts.inputs {
168+
if !has_glob_meta(original) && !Path::new(original).is_file() {
169+
let msg = format!("Input file does not exist: {}", original);
170+
if opts.continue_on_error {
171+
eprintln!("❌ {}", msg);
172+
failures.push(msg);
173+
failed_count += 1;
174+
skip_missing.insert(original.clone());
175+
continue;
176+
}
177+
return Err(msg);
178+
}
179+
}
180+
181+
for input in expanded {
182+
if skip_missing.contains(&input) {
183+
continue;
184+
}
185+
186+
processed_count += 1;
187+
188+
match run_normalize_for_file(
189+
&input,
190+
&opts.output,
191+
opts.dry_run,
192+
opts.check,
193+
opts.no_placeholders,
194+
&key_style,
195+
) {
196+
Ok(changed) => {
197+
success_count += 1;
198+
if changed {
199+
changed_count += 1;
200+
}
201+
}
202+
Err(err) => {
203+
failed_count += 1;
204+
if opts.continue_on_error {
205+
eprintln!("❌ {}", err);
206+
failures.push(err);
207+
continue;
208+
}
209+
210+
println!(
211+
"Summary: processed {}; success: {}; failed: {}; changed: {}",
212+
processed_count, success_count, failed_count, changed_count
213+
);
214+
return Err(err);
215+
}
216+
}
217+
}
218+
219+
println!(
220+
"Summary: processed {}; success: {}; failed: {}; changed: {}",
221+
processed_count, success_count, failed_count, changed_count
222+
);
223+
224+
if failures.is_empty() {
225+
return Ok(());
226+
}
227+
228+
Err(format!(
229+
"{} file(s) failed. See errors above.",
230+
failures.len()
231+
))
140232
}

langcodec-cli/tests/normalize_cli_tests.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,73 @@ fn test_normalize_check_and_dry_run_with_output_do_not_create_directories() {
162162
);
163163
}
164164
}
165+
166+
#[test]
167+
fn test_normalize_rejects_output_with_multiple_inputs() {
168+
let temp_dir = TempDir::new().unwrap();
169+
let input_a = temp_dir.path().join("a.strings");
170+
let input_b = temp_dir.path().join("b.strings");
171+
let output_path = temp_dir.path().join("out.strings");
172+
fs::write(&input_a, "\"a\" = \"A\";\n").unwrap();
173+
fs::write(&input_b, "\"b\" = \"B\";\n").unwrap();
174+
175+
let output = langcodec_cmd()
176+
.args([
177+
"normalize",
178+
"-i",
179+
input_a.to_str().unwrap(),
180+
input_b.to_str().unwrap(),
181+
"-o",
182+
output_path.to_str().unwrap(),
183+
])
184+
.output()
185+
.unwrap();
186+
187+
assert!(!output.status.success());
188+
let combined = format!(
189+
"{}{}",
190+
String::from_utf8_lossy(&output.stdout),
191+
String::from_utf8_lossy(&output.stderr)
192+
);
193+
assert!(
194+
combined.contains("--output cannot be used with multiple input files"),
195+
"expected --output multi-input rejection; got: {combined}"
196+
);
197+
}
198+
199+
#[test]
200+
fn test_normalize_continue_on_error_aggregates_and_returns_non_zero() {
201+
let temp_dir = TempDir::new().unwrap();
202+
let good = temp_dir.path().join("good.strings");
203+
let bad = temp_dir.path().join("bad.txt");
204+
fs::write(&good, "\"z\" = \"%@\";\n\"a\" = \"A\";\n").unwrap();
205+
fs::write(&bad, "not a supported localization format").unwrap();
206+
207+
let output = langcodec_cmd()
208+
.args([
209+
"normalize",
210+
"-i",
211+
bad.to_str().unwrap(),
212+
good.to_str().unwrap(),
213+
"--continue-on-error",
214+
])
215+
.output()
216+
.unwrap();
217+
218+
assert!(
219+
!output.status.success(),
220+
"expected non-zero when at least one file fails"
221+
);
222+
let stdout = String::from_utf8_lossy(&output.stdout);
223+
let stderr = String::from_utf8_lossy(&output.stderr);
224+
let combined = format!("{stdout}{stderr}");
225+
226+
assert!(
227+
combined.contains("Summary: processed 2; success: 1; failed: 1"),
228+
"expected summary counts, got: {combined}"
229+
);
230+
assert!(
231+
combined.contains("✅ Normalized:"),
232+
"expected successful file processing to continue, got: {combined}"
233+
);
234+
}

0 commit comments

Comments
 (0)