Skip to content

Commit a0513fa

Browse files
committed
fix(cli): expand globbed config inputs for annotate and translate
1 parent a241b9f commit a0513fa

4 files changed

Lines changed: 364 additions & 5 deletions

File tree

langcodec-cli/src/annotate.rs

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::{
22
ai::{ProviderKind, read_api_key, resolve_model, resolve_provider},
33
config::{LoadedConfig, load_config, resolve_config_relative_path},
4+
path_glob,
45
tui::{
56
DashboardEvent, DashboardInit, DashboardItem, DashboardItemStatus, DashboardKind,
67
DashboardLogTone, PlainReporter, ResolvedUiMode, RunReporter, SummaryRow, TuiReporter,
@@ -413,19 +414,33 @@ fn resolve_config_inputs(
413414
cfg: Option<&crate::config::AnnotateConfig>,
414415
config_dir: Option<&Path>,
415416
) -> Result<Vec<String>, String> {
417+
fn has_glob_meta(path: &str) -> bool {
418+
path.bytes().any(|b| matches!(b, b'*' | b'?' | b'[' | b'{'))
419+
}
420+
416421
if let Some(input) = &opts.input {
417422
return Ok(vec![input.clone()]);
418423
}
419424

420425
if let Some(input) = cfg.and_then(|item| item.input.as_ref()) {
421-
return Ok(vec![resolve_config_relative_path(config_dir, input)]);
426+
let resolved = vec![resolve_config_relative_path(config_dir, input)];
427+
return if resolved.iter().any(|path| has_glob_meta(path)) {
428+
path_glob::expand_input_globs(&resolved)
429+
} else {
430+
Ok(resolved)
431+
};
422432
}
423433

424434
if let Some(inputs) = cfg.and_then(|item| item.inputs.as_ref()) {
425-
return Ok(inputs
435+
let resolved = inputs
426436
.iter()
427437
.map(|input| resolve_config_relative_path(config_dir, input))
428-
.collect());
438+
.collect::<Vec<_>>();
439+
return if resolved.iter().any(|path| has_glob_meta(path)) {
440+
path_glob::expand_input_globs(&resolved)
441+
} else {
442+
Ok(resolved)
443+
};
429444
}
430445

431446
Ok(Vec::new())
@@ -2158,6 +2173,77 @@ concurrency = 2
21582173
);
21592174
}
21602175

2176+
#[test]
2177+
fn expand_annotate_invocations_expands_globbed_config_inputs() {
2178+
let temp_dir = TempDir::new().expect("temp dir");
2179+
let project_dir = temp_dir.path().join("project");
2180+
let sources_dir = project_dir.join("Sources");
2181+
let app_dir = project_dir.join("App").join("Resources");
2182+
let module_dir = project_dir.join("Modules").join("Feature");
2183+
fs::create_dir_all(&sources_dir).expect("create Sources");
2184+
fs::create_dir_all(&app_dir).expect("create app dir");
2185+
fs::create_dir_all(&module_dir).expect("create module dir");
2186+
2187+
let first = app_dir.join("Localizable.xcstrings");
2188+
let second = module_dir.join("Localizable.xcstrings");
2189+
fs::write(
2190+
&first,
2191+
r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
2192+
)
2193+
.expect("write first");
2194+
fs::write(
2195+
&second,
2196+
r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
2197+
)
2198+
.expect("write second");
2199+
2200+
let config_path = project_dir.join("langcodec.toml");
2201+
fs::write(
2202+
&config_path,
2203+
r#"[openai]
2204+
model = "gpt-5.4"
2205+
2206+
[annotate]
2207+
inputs = ["*/**/Localizable.xcstrings"]
2208+
source_roots = ["Sources"]
2209+
"#,
2210+
)
2211+
.expect("write config");
2212+
2213+
let loaded = load_config(Some(config_path.to_str().expect("config path")))
2214+
.expect("load config")
2215+
.expect("config present");
2216+
2217+
let runs = expand_annotate_invocations(
2218+
&AnnotateOptions {
2219+
input: None,
2220+
source_roots: Vec::new(),
2221+
output: None,
2222+
source_lang: None,
2223+
provider: None,
2224+
model: None,
2225+
concurrency: None,
2226+
config: Some(config_path.to_string_lossy().to_string()),
2227+
dry_run: false,
2228+
check: false,
2229+
ui_mode: UiMode::Plain,
2230+
},
2231+
Some(&loaded),
2232+
)
2233+
.expect("expand annotate invocations");
2234+
2235+
let mut inputs = runs.into_iter().map(|run| run.input).collect::<Vec<_>>();
2236+
inputs.sort();
2237+
2238+
let mut expected = vec![
2239+
first.to_string_lossy().to_string(),
2240+
second.to_string_lossy().to_string(),
2241+
];
2242+
expected.sort();
2243+
2244+
assert_eq!(inputs, expected);
2245+
}
2246+
21612247
#[test]
21622248
fn expand_annotate_invocations_rejects_input_and_inputs_together() {
21632249
let temp_dir = TempDir::new().expect("temp dir");

langcodec-cli/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub mod annotate;
55
pub mod config;
66
pub mod formats;
77
pub mod merge;
8+
pub mod path_glob;
89
pub mod tolgee;
910
pub mod transformers;
1011
pub mod translate;

langcodec-cli/src/translate.rs

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::validation::{validate_language_code, validate_output_path};
22
use crate::{
33
ai::{ProviderKind, build_provider, resolve_model, resolve_provider},
44
config::{LoadedConfig, load_config, resolve_config_relative_path},
5+
path_glob,
56
tolgee::{
67
TranslateTolgeeContext, TranslateTolgeeSettings, prefill_translate_from_tolgee,
78
push_translate_results_to_tolgee,
@@ -358,20 +359,33 @@ fn resolve_config_sources(
358359
cfg: Option<&crate::config::TranslateConfig>,
359360
config_dir: Option<&Path>,
360361
) -> Result<Vec<String>, String> {
362+
fn has_glob_meta(path: &str) -> bool {
363+
path.bytes().any(|b| matches!(b, b'*' | b'?' | b'[' | b'{'))
364+
}
365+
361366
if let Some(source) = &opts.source {
362367
return Ok(vec![source.clone()]);
363368
}
364369

365370
if let Some(source) = cfg.and_then(|item| item.resolved_source()) {
366-
return Ok(vec![resolve_config_relative_path(config_dir, source)]);
371+
let resolved = vec![resolve_config_relative_path(config_dir, source)];
372+
return if resolved.iter().any(|path| has_glob_meta(path)) {
373+
path_glob::expand_input_globs(&resolved)
374+
} else {
375+
Ok(resolved)
376+
};
367377
}
368378

369379
if let Some(sources) = cfg.and_then(|item| item.resolved_sources()) {
370380
let resolved = sources
371381
.iter()
372382
.map(|source| resolve_config_relative_path(config_dir, source))
373383
.collect::<Vec<_>>();
374-
return Ok(resolved);
384+
return if resolved.iter().any(|path| has_glob_meta(path)) {
385+
path_glob::expand_input_globs(&resolved)
386+
} else {
387+
Ok(resolved)
388+
};
375389
}
376390

377391
Ok(Vec::new())
@@ -2316,6 +2330,72 @@ sources = ["one.xcstrings", "two.xcstrings"]
23162330
);
23172331
}
23182332

2333+
#[test]
2334+
fn expands_globbed_sources_from_config() {
2335+
let temp_dir = TempDir::new().unwrap();
2336+
let config_dir = temp_dir.path().join("project");
2337+
let feature_a = config_dir.join("Modules").join("FeatureA");
2338+
let feature_b = config_dir.join("Modules").join("FeatureB");
2339+
fs::create_dir_all(&feature_a).unwrap();
2340+
fs::create_dir_all(&feature_b).unwrap();
2341+
2342+
let first = feature_a.join("Localizable.xcstrings");
2343+
let second = feature_b.join("Localizable.xcstrings");
2344+
fs::write(
2345+
&first,
2346+
r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
2347+
)
2348+
.unwrap();
2349+
fs::write(
2350+
&second,
2351+
r#"{"sourceLanguage":"en","version":"1.0","strings":{}}"#,
2352+
)
2353+
.unwrap();
2354+
2355+
let config = config_dir.join("langcodec.toml");
2356+
fs::write(
2357+
&config,
2358+
r#"[translate.input]
2359+
sources = ["Modules/*/Localizable.xcstrings"]
2360+
"#,
2361+
)
2362+
.unwrap();
2363+
2364+
let runs = expand_translate_invocations(&TranslateOptions {
2365+
source: None,
2366+
target: None,
2367+
output: None,
2368+
source_lang: None,
2369+
target_langs: Vec::new(),
2370+
status: None,
2371+
provider: None,
2372+
model: None,
2373+
concurrency: None,
2374+
config: Some(config.to_string_lossy().to_string()),
2375+
use_tolgee: false,
2376+
tolgee_config: None,
2377+
tolgee_namespaces: Vec::new(),
2378+
dry_run: true,
2379+
strict: false,
2380+
ui_mode: UiMode::Plain,
2381+
})
2382+
.unwrap();
2383+
2384+
let mut sources = runs
2385+
.into_iter()
2386+
.map(|run| run.source.expect("source"))
2387+
.collect::<Vec<_>>();
2388+
sources.sort();
2389+
2390+
let mut expected = vec![
2391+
first.to_string_lossy().to_string(),
2392+
second.to_string_lossy().to_string(),
2393+
];
2394+
expected.sort();
2395+
2396+
assert_eq!(sources, expected);
2397+
}
2398+
23192399
#[test]
23202400
fn rejects_target_with_multiple_sources_from_config() {
23212401
let temp_dir = TempDir::new().unwrap();

0 commit comments

Comments
 (0)