185
#[command(subcommand)]
186
command: BetaAppReviewSubmissionsCommand,
187
},
188
+ /// Manage the per-locale "What to Test" notes testers see on a build.
189
+ BuildLocalizations {
190
+ #[command(subcommand)]
191
+ command: BetaBuildLocalizationsCommand,
192
+ },
193
+}
194
+
195
+#[derive(Subcommand)]
196
+enum BetaBuildLocalizationsCommand {
197
+ /// `GET /v1/betaBuildLocalizations?filter[build]={build_id}` — every
198
+ /// locale's note for a build (find the build with `apps builds`).
199
+ List {
200
+ /// The ASC build ID.
201
+ build_id: String,
202
+ },
203
+ /// `POST /v1/betaBuildLocalizations` — add a locale's note to a build.
204
+ /// A build holds one note per locale; use `update` if the locale already
205
+ /// has one.
206
+ Create {
207
+ /// The ASC build ID.
208
+ build_id: String,
209
+ /// BCP 47 locale tag, e.g. `en-US` or `id`.
210
+ #[arg(long)]
211
+ locale: String,
212
+ /// The "What to Test" text (up to 4000 characters).
213
+ #[arg(long)]
214
+ whats_new: String,
215
+ },
216
+ /// `PATCH /v1/betaBuildLocalizations/{id}` — replace the text of an
217
+ /// existing note (the id comes from `list`).
218
+ Update {
219
+ /// The ASC beta build localization ID.
220
+ id: String,
221
+ /// The "What to Test" text (up to 4000 characters).
222
+ #[arg(long)]
223
+ whats_new: String,
224
+ },
225
}
226
227
#[derive(Subcommand)]
917
Ok(dry_run)
918
}
919
920
+/// Maximum length App Store Connect accepts for a "What to Test" note
921
+/// (`whatsNew` on `betaBuildLocalizations`). Apple's answer for anything
922
+/// longer is an opaque 400, so the CLI rejects locally and names the
923
+/// flag in the message.
924
+const WHATS_NEW_MAX_LEN: usize = 4000;
925
+
926
+/// Validate a `--whats-new` text before sending it. App Store Connect caps
927
+/// the field at [`WHATS_NEW_MAX_LEN`] characters and answers with an opaque
928
+/// error otherwise, so the length is checked here where the message can
929
+/// name the offending flag. Shared by `build-localizations create` and
930
+/// `update` because both write the same attribute, and called before the
931
+/// dry-run print so `--dry-run` catches the limit too.
932
+fn validate_whats_new(whats_new: &str) -> Result<()> {
933
+ let len = whats_new.chars().count();
934
+ if len > WHATS_NEW_MAX_LEN {
935
+ anyhow::bail!(
936
+ "--whats-new is {len} characters; App Store Connect accepts at most \
937
+ {WHATS_NEW_MAX_LEN}. Shorten the note and try again."
938
+ );
939
+ }
940
+ Ok(())
941
+}
942
+
943
/// Convert Apple's gzip-compressed TSV report to JSON without normalizing
944
/// columns: report layouts vary by report type and Apple can add columns.
945
fn sales_report_as_json(gzip: &[u8]) -> Result<Vec<serde_json::Value>> {
1216
print_json(&submission)
1217
}
1218
},
1219
+ TestflightCommand::BuildLocalizations { command } => match command {
1220
+ BetaBuildLocalizationsCommand::List { build_id } => {
1221
+ print_json(&client.list_beta_build_localizations(&build_id).await?)
1222
+ }
1223
+ BetaBuildLocalizationsCommand::Create {
1224
+ build_id,
1225
+ locale,
1226
+ whats_new,
1227
+ } => {
1228
+ validate_whats_new(&whats_new)?;
1229
+ if dry_run {
1230
+ println!(
1231
+ "[dry-run] would create the {locale} \"What to Test\" note on build {build_id}:"
1232
+ );
1233
+ return print_json(&serde_json::json!({ "whatsNew": whats_new }));
1234
+ }
1235
+ let created = client
1236
+ .create_beta_build_localization(&build_id, &locale, Some(&whats_new))
1237
+ .await?;
1238
+ print_json(&created)
1239
+ }
1240
+ BetaBuildLocalizationsCommand::Update { id, whats_new } => {
1241
+ validate_whats_new(&whats_new)?;
1242
+ if dry_run {
1243
+ println!("[dry-run] would update \"What to Test\" note {id}:");
1244
+ return print_json(&serde_json::json!({ "whatsNew": whats_new }));
1245
+ }
1246
+ print_json(
1247
+ &client
1248
+ .update_beta_build_localization(&id, &whats_new)
1249
+ .await?,
1250
+ )
1251
+ }
1252
+ },
1253
}
1254
}
1255
2048
#[cfg(test)]
2049
mod tests {
2050
use super::sales_report_as_json;
2051
+ use super::validate_whats_new;
2052
+ use super::WHATS_NEW_MAX_LEN;
2053
use flate2::write::GzEncoder;
2054
use flate2::Compression;
2055
use std::io::Write;
2115
})]
2116
);
2117
}
2118
+
2119
+ #[test]
2120
+ fn whats_new_accepts_empty_and_up_to_the_limit() {
2121
+ // Apple accepts the field at create time as null/empty, and a
2122
+ // note exactly at the cap is the boundary that must pass.
2123
+ assert!(validate_whats_new("").is_ok());
2124
+ let at_limit = "a".repeat(WHATS_NEW_MAX_LEN);
2125
+ assert!(validate_whats_new(&at_limit).is_ok(), "at-limit must pass");
2126
+ }
2127
+
2128
+ #[test]
2129
+ fn whats_new_rejects_one_char_over_the_limit() {
2130
+ let too_long = "a".repeat(WHATS_NEW_MAX_LEN + 1);
2131
+ let error = validate_whats_new(&too_long).expect_err("over-limit must be rejected");
2132
+ let message = error.to_string();
2133
+ assert!(
2134
+ message.contains("--whats-new"),
2135
+ "should name the flag: {message}"
2136
+ );
2137
+ assert!(
2138
+ message.contains(&WHATS_NEW_MAX_LEN.to_string()),
2139
+ "should quote the limit: {message}"
2140
+ );
2141
+ }
2142
+
2143
+ #[test]
2144
+ fn whats_new_counts_characters_not_bytes() {
2145
+ // "あ" is 3 bytes in UTF-8 but 1 character, so 4000 of them fits
2146
+ // the character-based limit even though it would blow a byte
2147
+ // budget. Make sure the validator agrees with ASC's character
2148
+ // semantics.
2149
+ let multibyte = "あ".repeat(WHATS_NEW_MAX_LEN);
2150
+ assert!(
2151
+ validate_whats_new(&multibyte).is_ok(),
2152
+ "char count, not byte count"
2153
+ );
2154
+ }
2155
}