Skip to content

Commit e806218

Browse files
committed
feat(cli): implement normalize check and dry-run semantics
1 parent 1d070ff commit e806218

3 files changed

Lines changed: 212 additions & 8 deletions

File tree

langcodec-cli/src/main.rs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use crate::debug::run_debug_command;
1717
use crate::diff::{DiffOptions, run_diff_command};
1818
use crate::edit::{EditSetOptions, run_edit_set_command};
1919
use crate::merge::{ConflictStrategy, run_merge_command};
20-
use crate::normalize::run_normalize_command;
20+
use crate::normalize::{NormalizeCliOptions, run_normalize_command};
2121
use crate::sync::{SyncOptions, run_sync_command};
2222
use crate::validation::{ValidationContext, validate_context, validate_language_code};
2323
use crate::view::print_view;
@@ -199,7 +199,31 @@ enum Commands {
199199
},
200200

201201
/// Normalize localization files.
202-
Normalize,
202+
Normalize {
203+
/// The input files to normalize (supports glob patterns). Quote patterns to avoid shell expansion.
204+
#[arg(short, long, num_args = 1.., help = "Input files. Supports glob patterns. Quote patterns to avoid slow shell-side expansion (e.g., '/path/**/*/Localizable.strings').")]
205+
inputs: Vec<String>,
206+
207+
/// Optional output file (single-file mode only). If omitted, writes in-place.
208+
#[arg(short, long)]
209+
output: Option<String>,
210+
211+
/// Preview changes without writing
212+
#[arg(long, default_value_t = false)]
213+
dry_run: bool,
214+
215+
/// Exit non-zero if normalization would change the file
216+
#[arg(long, default_value_t = false)]
217+
check: bool,
218+
219+
/// Disable placeholder normalization
220+
#[arg(long, default_value_t = false)]
221+
no_placeholders: bool,
222+
223+
/// Key renaming style: none|snake|kebab|camel
224+
#[arg(long, default_value = "none")]
225+
key_style: String,
226+
},
203227

204228
/// Show translation coverage and per-status counts.
205229
Stats {
@@ -600,8 +624,23 @@ fn main() {
600624
cmd = cmd.bin_name("langcodec");
601625
generate(shell, &mut cmd, "langcodec", &mut std::io::stdout());
602626
}
603-
Commands::Normalize => {
604-
if let Err(e) = run_normalize_command() {
627+
Commands::Normalize {
628+
inputs,
629+
output,
630+
dry_run,
631+
check,
632+
no_placeholders,
633+
key_style,
634+
} => {
635+
let opts = NormalizeCliOptions {
636+
inputs,
637+
output,
638+
dry_run,
639+
check,
640+
no_placeholders,
641+
key_style,
642+
};
643+
if let Err(e) = run_normalize_command(opts) {
605644
eprintln!("❌ Normalize failed: {}", e);
606645
std::process::exit(1);
607646
}

langcodec-cli/src/normalize.rs

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,113 @@
1-
/// Temporary scaffold for Task 1; normalize behavior will be implemented in later tasks.
2-
pub fn run_normalize_command() -> Result<(), String> {
1+
use crate::path_glob;
2+
use crate::validation::{validate_file_path, validate_output_path};
3+
use langcodec::{
4+
Codec, FormatType, KeyStyle, NormalizeOptions as EngineNormalizeOptions, normalize_codec,
5+
};
6+
7+
#[derive(Debug, Clone)]
8+
pub struct NormalizeCliOptions {
9+
pub inputs: Vec<String>,
10+
pub output: Option<String>,
11+
pub dry_run: bool,
12+
pub check: bool,
13+
pub no_placeholders: bool,
14+
pub key_style: String,
15+
}
16+
17+
fn parse_key_style(input: &str) -> Result<KeyStyle, String> {
18+
match input.trim().to_ascii_lowercase().as_str() {
19+
"none" => Ok(KeyStyle::None),
20+
"snake" => Ok(KeyStyle::Snake),
21+
"kebab" => Ok(KeyStyle::Kebab),
22+
"camel" => Ok(KeyStyle::Camel),
23+
other => Err(format!(
24+
"Invalid --key-style '{}'. Expected one of: none, snake, kebab, camel",
25+
other
26+
)),
27+
}
28+
}
29+
30+
fn infer_output_format_from_path(path: &str) -> Result<FormatType, String> {
31+
langcodec::infer_format_from_extension(path)
32+
.ok_or_else(|| format!("Cannot infer format from path: {}", path))
33+
}
34+
35+
fn pick_single_resource(codec: &Codec) -> Result<&langcodec::Resource, String> {
36+
if codec.resources.len() == 1 {
37+
Ok(&codec.resources[0])
38+
} else {
39+
Err(
40+
"Multiple languages present; single-language output requires exactly one resource"
41+
.to_string(),
42+
)
43+
}
44+
}
45+
46+
fn write_back(codec: &Codec, input_path: &str, output_path: &Option<String>) -> Result<(), String> {
47+
let input_owned = input_path.to_string();
48+
let out = output_path.as_ref().unwrap_or(&input_owned);
49+
let fmt = infer_output_format_from_path(out)?;
50+
51+
match fmt {
52+
FormatType::Strings(_) | FormatType::AndroidStrings(_) => {
53+
let resource = pick_single_resource(codec)?;
54+
Codec::write_resource_to_file(resource, out)
55+
.map_err(|e| format!("Error writing output: {}", e))
56+
}
57+
FormatType::Xcstrings | FormatType::CSV | FormatType::TSV => {
58+
langcodec::converter::convert_resources_to_format(codec.resources.clone(), out, fmt)
59+
.map_err(|e| format!("Error writing output: {}", e))
60+
}
61+
}
62+
}
63+
64+
pub fn run_normalize_command(opts: NormalizeCliOptions) -> Result<(), String> {
65+
let expanded = path_glob::expand_input_globs(&opts.inputs)
66+
.map_err(|e| format!("Failed to expand input patterns: {}", e))?;
67+
if expanded.is_empty() {
68+
return Err("No input files matched the provided patterns".to_string());
69+
}
70+
if expanded.len() > 1 {
71+
return Err("Normalize currently supports exactly one input file".to_string());
72+
}
73+
74+
let input = &expanded[0];
75+
validate_file_path(input)?;
76+
if let Some(output) = &opts.output {
77+
validate_output_path(output)?;
78+
}
79+
80+
let mut codec = Codec::new();
81+
codec
82+
.read_file_by_extension(input, None)
83+
.map_err(|e| format!("Failed to read input '{}': {}", input, e))?;
84+
85+
let key_style = parse_key_style(&opts.key_style)?;
86+
let report = normalize_codec(
87+
&mut codec,
88+
&EngineNormalizeOptions {
89+
normalize_placeholders: !opts.no_placeholders,
90+
key_style,
91+
},
92+
)
93+
.map_err(|e| e.to_string())?;
94+
95+
if !report.changed {
96+
println!("No changes needed: {}", input);
97+
return Ok(());
98+
}
99+
100+
if opts.check {
101+
println!("would change: {}", input);
102+
return Err(format!("would change: {}", input));
103+
}
104+
105+
if opts.dry_run {
106+
println!("DRY-RUN: would change {}", input);
107+
return Ok(());
108+
}
109+
110+
write_back(&codec, input, &opts.output)?;
111+
println!("✅ Normalized: {}", opts.output.as_deref().unwrap_or(input));
3112
Ok(())
4113
}

langcodec-cli/tests/normalize_cli_tests.rs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
use std::fs;
12
use std::process::Command;
3+
use tempfile::TempDir;
24

35
fn langcodec_cmd() -> Command {
46
Command::new(assert_cmd::cargo::cargo_bin!("langcodec"))
@@ -14,12 +16,66 @@ fn test_main_help_lists_normalize() {
1416

1517
#[test]
1618
fn test_normalize_command_executes_successfully() {
17-
let output = langcodec_cmd().args(["normalize"]).output().unwrap();
19+
let temp_dir = TempDir::new().unwrap();
20+
let input = temp_dir.path().join("en.strings");
21+
fs::write(&input, "\"a\" = \"A\";\n\"b\" = \"B\";\n").unwrap();
22+
23+
let output = langcodec_cmd()
24+
.args(["normalize", "-i", input.to_str().unwrap()])
25+
.output()
26+
.unwrap();
1827
let stderr = String::from_utf8_lossy(&output.stderr);
1928

2029
assert!(
2130
output.status.success(),
2231
"normalize command failed with stderr: {stderr}"
2332
);
24-
assert!(!stderr.contains("panicked at"), "normalize command panicked");
33+
assert!(
34+
!stderr.contains("panicked at"),
35+
"normalize command panicked"
36+
);
37+
}
38+
39+
#[test]
40+
fn test_normalize_check_fails_on_drift() {
41+
let temp_dir = TempDir::new().unwrap();
42+
let input = temp_dir.path().join("en.strings");
43+
fs::write(&input, "\"z\" = \"%@\";\n\"a\" = \"A\";\n").unwrap();
44+
45+
let output = langcodec_cmd()
46+
.args(["normalize", "-i", input.to_str().unwrap(), "--check"])
47+
.output()
48+
.unwrap();
49+
50+
assert!(!output.status.success());
51+
let combined = format!(
52+
"{}{}",
53+
String::from_utf8_lossy(&output.stdout),
54+
String::from_utf8_lossy(&output.stderr)
55+
);
56+
assert!(
57+
combined.contains("would change"),
58+
"expected output to mention drift; got: {combined}"
59+
);
60+
}
61+
62+
#[test]
63+
fn test_normalize_dry_run_does_not_write() {
64+
let temp_dir = TempDir::new().unwrap();
65+
let input = temp_dir.path().join("en.strings");
66+
let before = "\"z\" = \"%@\";\n\"a\" = \"A\";\n";
67+
fs::write(&input, before).unwrap();
68+
69+
let output = langcodec_cmd()
70+
.args(["normalize", "-i", input.to_str().unwrap(), "--dry-run"])
71+
.output()
72+
.unwrap();
73+
74+
assert!(
75+
output.status.success(),
76+
"stderr: {}",
77+
String::from_utf8_lossy(&output.stderr)
78+
);
79+
let after = fs::read_to_string(&input).unwrap();
80+
assert_eq!(after, before);
2581
}

0 commit comments

Comments
 (0)