Integration: merge main, resolve conflicts (#8658)

docs/file-format-parity.md: main's PSD row with the round-trip count
258/258. docs/scorecard.md: regenerated with `cargo xtask scorecard`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-25 12:46:18 +01:00
484 changed files with 36221 additions and 6390 deletions
+42 -33
View File
@@ -72,9 +72,33 @@ fn format_report(languages: &[Language], english_keys: &BTreeSet<Key>, translati
let coverage = 100.0 * translated as f64 / english_keys.len() as f64;
report.push_str(&format!("{:<12} {:>5}/{:<6} {:>8.2}%\n", language.code, translated, english_keys.len(), coverage));
}
report.push_str("\nMissing translations (shown in English until translated)\n");
for language in languages.iter().filter(|language| language.code != "en") {
let empty = BTreeSet::new();
let translated = translations.get(&language.code).unwrap_or(&empty);
let missing: Vec<&Key> = english_keys.difference(translated).collect();
if missing.is_empty() {
report.push_str(&format!("{}: none missing\n", language.code));
continue;
}
report.push_str(&format!("{}: {} missing\n", language.code, missing.len()));
for key in missing {
let source = escape_line(&key.source);
if key.context.is_empty() {
report.push_str(&format!(" {source}\n"));
} else {
report.push_str(&format!(" [{}] {source}\n", escape_line(&key.context)));
}
}
}
report
}
/// Keeps one key per log line: a source string with a newline or tab prints them escaped.
fn escape_line(text: &str) -> String {
text.replace('\\', "\\\\").replace('\n', "\\n").replace('\t', "\\t")
}
fn registered_languages(source: &str) -> Result<Vec<Language>, String> {
let registry = source
.split("pub static LANGUAGES")
@@ -494,15 +518,28 @@ mod tests {
let languages = registered_languages(registry);
assert!(languages.is_ok());
let languages = languages.unwrap_or_default();
let english_keys = BTreeSet::from([Key { context: String::new(), source: "Open".into() }, Key { context: String::new(), source: "Save".into() }]);
let translations = BTreeMap::from([("fr".to_string(), BTreeSet::from([Key { context: String::new(), source: "Open".into() }]))]);
let english_keys = BTreeSet::from([
Key { context: String::new(), source: "Open".into() },
Key { context: String::new(), source: "Save".into() },
Key { context: "@plural".into(), source: "{n} layer|{n} layers".into() },
]);
let translations = BTreeMap::from([
("fr".to_string(), BTreeSet::from([Key { context: String::new(), source: "Open".into() }])),
("zh".to_string(), english_keys.clone()),
]);
assert_eq!(
format_report(&languages, &english_keys, &translations),
"UI translation coverage (translated source keys / source keys)\n\
language translated coverage\n\
en 2/2 100.00%\n\
fr 1/2 50.00%\n\
zh 0/2 0.00%\n"
en 3/3 100.00%\n\
fr 1/3 33.33%\n\
zh 3/3 100.00%\n\
\n\
Missing translations (shown in English until translated)\n\
fr: 2 missing\n\
\x20 Save\n\
\x20 [@plural] {n} layer|{n} layers\n\
zh: none missing\n"
);
}
@@ -563,34 +600,6 @@ mod tests {
}
}
#[test]
fn source_key_set_is_covered_by_complete_catalogs() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap_or(Path::new("."));
let keys = source_keys(root).unwrap_or_default();
for (code, path) in [
("ja", "ja.tsv"),
("zh-hant", "zh-hant.tsv"),
("es", "es.tsv"),
("ru", "ru.tsv"),
("cs", "cs.tsv"),
("de", "de.tsv"),
("pt-br", "pt-br.tsv"),
("it", "it.tsv"),
("el", "el.tsv"),
("uk", "uk.tsv"),
("nl", "nl.tsv"),
("hu", "hu.tsv"),
("tr", "tr.tsv"),
("vi", "vi.tsv"),
("sr", "sr.tsv"),
] {
let catalog_path = root.join("crates/ui-egui/src/i18n").join(path);
let text = fs::read_to_string(&catalog_path).unwrap_or_default();
let translations = parse_catalog(&catalog_path, &text).unwrap_or_default();
let missing: Vec<_> = keys.difference(&translations).collect();
assert!(missing.is_empty(), "{code}: missing source keys: {missing:#?}");
}
}
#[test]
fn plural_forms_follow_count_expressions_and_dynamic_forms_are_not_literal_keys() {
let sources = vec![(
+103 -21
View File
@@ -16,15 +16,19 @@ mod stats;
mod version;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::process::{Command, ExitCode, Stdio};
const USAGE: &str = "\
usage: cargo xtask <command>
commands:
layers enforce the crate dependency layering (docs/architecture.md §3)
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure)
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call, plus
one per optional web feature)
test workspace tests (cargo nextest when installed, else cargo test), then doctests
ci the CI gate: fmt --check, clippy -D warnings, layers, scorecard --check, tests
(nextest --profile ci), panic_hunt, doctests and wasm, with --locked --features heif
(stops at first failure), then the i18n-coverage report
corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --atn | --photoshop | --gimp] [--local] [--update-manifest]
show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch
them into corpus/ (pinned commits, sha256-verified; --all = every corpus but pixls,
@@ -36,7 +40,8 @@ commands:
--local takes corpus/photoshop and corpus/gimp from the photocraft-corpus authoring clone
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
parity Photoshop menu parity; rewrites docs/parity-checklist.md
i18n-coverage report stable UI translation coverage for registered languages
i18n-coverage report UI translation coverage and the missing strings per registered language
(the last `ci` step; it never fails)
perf [--quick] [--update-baseline] [--threshold PCT] [--bench NAME]... [--skip-build] [--reuse]
run the release benches, merge them by scenario id into target/perf/results.json,
check perf/budgets.toml and perf/baseline.json (non-zero on a broken budget or regression)
@@ -54,6 +59,7 @@ fn main() -> ExitCode {
let result = match args.first().map(String::as_str) {
Some("layers") => cmd_layers(),
Some("wasm") => cmd_wasm(),
Some("test") => cmd_test().and_then(|()| cmd_doctest()),
Some("ci") => cmd_ci(),
Some("corpus") => corpus::cmd(&rest),
Some("test-corpus") => corpus::test_cmd(&rest),
@@ -157,15 +163,23 @@ fn wasm_set() -> Result<Vec<String>, String> {
/// (package, feature).
const WASM_FEATURES: &[(&str, &str)] = &[("photocraft-codecs", "heif")];
/// One `cargo check` for the whole set: each crate is checked once, and `--keep-going` still
/// reports every failing crate. Features unify across the set, so each crate is checked with the
/// union of the features the set enables. The optional features get a call each, so the crates
/// are also checked without them.
fn cmd_wasm() -> Result<(), String> {
let set = wasm_set()?;
let mut results = Vec::new();
for pkg in &set {
let mut c = cargo();
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok();
results.push((pkg.clone(), ok));
if set.is_empty() {
return Err("no wasm-safe crates found in the workspace metadata".into());
}
let mut results = Vec::new();
let mut c = cargo();
c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]);
for pkg in &set {
c.args(["-p", pkg]);
}
let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p "))).is_ok();
results.push((format!("{} crates", set.len()), ok));
for (pkg, feature) in WASM_FEATURES {
let mut c = cargo();
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg, "--features", feature]);
@@ -177,7 +191,62 @@ fn cmd_wasm() -> Result<(), String> {
println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
}
let failed = results.iter().filter(|r| !r.1).count();
if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) }
if failed == 0 { Ok(()) } else { Err(format!("{failed} wasm check(s) failed")) }
}
/// Test targets for the test build. Examples are left out so they aren't linked; clippy
/// `--all-targets` still compiles them.
const TEST_TARGETS: [&str; 4] = ["--workspace", "--lib", "--bins", "--tests"];
fn nextest_available() -> bool {
cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success())
}
fn cmd_test() -> Result<(), String> {
let mut c = cargo();
let tool = if nextest_available() {
c.args(["nextest", "run"]);
"nextest run"
} else {
eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)");
c.arg("test");
"test"
};
c.args(TEST_TARGETS);
run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" ")))
}
/// What the CI gate adds to every build: `--locked`, and the optional photocraft-heif crate that
/// official builds open HEIC photos with.
const GATE_ARGS: [&str; 4] = ["--workspace", "--locked", "--features", "heif"];
/// The gate's tests. nextest's `ci` profile stops at the first failure and terminates hung tests
/// (.config/nextest.toml). `panic_hunt` runs only the engine's `#[ignore]`d panic hunt (AGENTS.md
/// Rule 9) with the same packages, features and targets as the tests, so nothing rebuilds.
fn cmd_gate_tests(panic_hunt: bool) -> Result<(), String> {
let mut c = cargo();
if nextest_available() {
c.args(["nextest", "run", "--profile", "ci"]).args(GATE_ARGS).args(&TEST_TARGETS[1..]);
if panic_hunt {
c.args(["--run-ignored", "only", "-E", "binary_id(photocraft-engine::panic_hunt)"]);
}
} else if panic_hunt {
// `-- --ignored` across the workspace would run every ignored test, not only the hunt. The
// engine has no `heif` feature of its own; it reaches the decoder through photocraft-io.
c.args(["test", "-p", "photocraft-engine", "--locked", "--features", "photocraft-io/heif", "--test", "panic_hunt", "--", "--ignored"]);
} else {
eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)");
c.arg("test").args(GATE_ARGS).args(&TEST_TARGETS[1..]);
}
let what = format!("cargo {}", c.get_args().map(|a| a.to_string_lossy()).collect::<Vec<_>>().join(" "));
run(c, &what)
}
/// nextest does not run doctests.
fn cmd_doctest() -> Result<(), String> {
let mut c = cargo();
c.args(["test", "--workspace", "--doc"]);
run(c, "cargo test --workspace --doc")
}
fn cmd_parity() -> Result<(), String> {
@@ -201,20 +270,33 @@ fn cmd_ci() -> Result<(), String> {
"clippy",
Box::new(|| {
let mut c = cargo();
c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]);
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
}),
),
(
"test",
Box::new(|| {
let mut c = cargo();
c.args(["test", "--workspace"]);
run(c, "cargo test --workspace")
c.arg("clippy").args(GATE_ARGS).args(["--all-targets", "--keep-going", "--", "-D", "warnings"]);
run(c, "cargo clippy --workspace --locked --features heif --all-targets --keep-going -- -D warnings")
}),
),
("layers", Box::new(cmd_layers)),
("scorecard", Box::new(|| scorecard::run(&root(), &["--check"]))),
("test", Box::new(|| cmd_gate_tests(false))),
("panic_hunt", Box::new(|| cmd_gate_tests(true))),
(
"doctest",
Box::new(|| {
let mut c = cargo();
c.arg("test").args(GATE_ARGS).arg("--doc");
run(c, "cargo test --workspace --locked --features heif --doc")
}),
),
("wasm", Box::new(cmd_wasm)),
(
"i18n",
Box::new(|| {
// Lists missing translations for the log; a missing string shows in English, so it never fails CI.
if let Err(e) = i18n_coverage::run(&root()) {
eprintln!("warning: i18n coverage report failed: {e}");
}
Ok(())
}),
),
];
let mut done = Vec::new();
for (name, f) in &steps {