11
AppStoreVersionCreateAttributes, AppStoreVersionUpdateFields, Platform,
12
};
13
use smbcloud_ascapi_aso::app_store_version_localization::{
14
- AppStoreVersionLocalizationCreateAttributes, AppStoreVersionLocalizationFields,
14
+ AppStoreVersionLocalizationAttributes, AppStoreVersionLocalizationCreateAttributes,
15
+ AppStoreVersionLocalizationFields,
16
};
17
use smbcloud_ascapi_aso::beta_app_review_detail::BetaAppReviewDetailUpdate;
18
use smbcloud_ascapi_aso::build::BuildFilter;
206
/// `POST /v1/betaBuildLocalizations` — add a locale's note to a build.
207
/// A build holds one note per locale; use `update` if the locale already
208
/// has one.
209
+ #[command(group(clap::ArgGroup::new("text").required(true).args(["whats_new", "whats_new_file"])))]
210
Create {
211
/// The ASC build ID.
212
build_id: String,
215
locale: String,
216
/// The "What to Test" text (up to 4000 characters).
217
#[arg(long)]
216
- whats_new: String,
218
+ whats_new: Option<String>,
219
+ /// Read the "What to Test" text from a UTF-8 file instead of
220
+ /// `--whats-new`; one trailing newline is dropped.
221
+ #[arg(long)]
222
+ whats_new_file: Option<PathBuf>,
223
},
224
/// `PATCH /v1/betaBuildLocalizations/{id}` — replace the text of an
225
/// existing note (the id comes from `list`).
226
+ #[command(group(clap::ArgGroup::new("text").required(true).args(["whats_new", "whats_new_file"])))]
227
Update {
228
/// The ASC beta build localization ID.
229
id: String,
230
/// The "What to Test" text (up to 4000 characters).
231
#[arg(long)]
225
- whats_new: String,
232
+ whats_new: Option<String>,
233
+ /// Read the "What to Test" text from a UTF-8 file instead of
234
+ /// `--whats-new`; one trailing newline is dropped.
235
+ #[arg(long)]
236
+ whats_new_file: Option<PathBuf>,
237
},
238
}
239
776
locale: String,
777
#[arg(long)]
778
description: Option<String>,
779
+ /// Read `description` from a UTF-8 file; one trailing newline is dropped.
780
+ #[arg(long, conflicts_with = "description")]
781
+ description_file: Option<PathBuf>,
782
#[arg(long)]
783
keywords: Option<String>,
784
+ /// Read `keywords` from a UTF-8 file; one trailing newline is dropped.
785
+ #[arg(long, conflicts_with = "keywords")]
786
+ keywords_file: Option<PathBuf>,
787
#[arg(long)]
788
whats_new: Option<String>,
789
+ /// Read `whatsNew` from a UTF-8 file; one trailing newline is dropped.
790
+ #[arg(long, conflicts_with = "whats_new")]
791
+ whats_new_file: Option<PathBuf>,
792
#[arg(long)]
793
promotional_text: Option<String>,
794
+ /// Read `promotionalText` from a UTF-8 file; one trailing newline is dropped.
795
+ #[arg(long, conflicts_with = "promotional_text")]
796
+ promotional_text_file: Option<PathBuf>,
797
#[arg(long)]
798
marketing_url: Option<String>,
799
#[arg(long)]
800
support_url: Option<String>,
801
+ /// Skip the read-back check that compares the stored values with
802
+ /// what was sent.
803
+ #[arg(long)]
804
+ no_verify: bool,
805
},
806
Update {
807
id: String,
808
#[arg(long)]
809
description: Option<String>,
810
+ /// Read `description` from a UTF-8 file; one trailing newline is dropped.
811
+ #[arg(long, conflicts_with = "description")]
812
+ description_file: Option<PathBuf>,
813
#[arg(long)]
814
keywords: Option<String>,
815
+ /// Read `keywords` from a UTF-8 file; one trailing newline is dropped.
816
+ #[arg(long, conflicts_with = "keywords")]
817
+ keywords_file: Option<PathBuf>,
818
#[arg(long)]
819
whats_new: Option<String>,
820
+ /// Read `whatsNew` from a UTF-8 file; one trailing newline is dropped.
821
+ #[arg(long, conflicts_with = "whats_new")]
822
+ whats_new_file: Option<PathBuf>,
823
#[arg(long)]
824
promotional_text: Option<String>,
825
+ /// Read `promotionalText` from a UTF-8 file; one trailing newline is dropped.
826
+ #[arg(long, conflicts_with = "promotional_text")]
827
+ promotional_text_file: Option<PathBuf>,
828
#[arg(long)]
829
marketing_url: Option<String>,
830
#[arg(long)]
831
support_url: Option<String>,
832
+ /// Skip the read-back check that compares the stored values with
833
+ /// what was sent.
834
+ #[arg(long)]
835
+ no_verify: bool,
836
},
837
Delete {
838
id: String,
974
Ok(dry_run)
975
}
976
977
+/// Resolve a text option given either inline or as a file. The file is read
978
+/// as UTF-8 and one trailing newline (`\n` or `\r\n`) is dropped, the way
979
+/// `$(cat file)` does, so file-backed and inline values behave the same.
980
+fn text_arg(inline: Option<String>, file: Option<PathBuf>, flag: &str) -> Result<Option<String>> {
981
+ let Some(path) = file else {
982
+ return Ok(inline);
983
+ };
984
+ let mut text = std::fs::read_to_string(&path)
985
+ .with_context(|| format!("{flag}: cannot read {} as UTF-8 text", path.display()))?;
986
+ if text.ends_with('\n') {
987
+ text.pop();
988
+ if text.ends_with('\r') {
989
+ text.pop();
990
+ }
991
+ }
992
+ Ok(Some(text))
993
+}
994
+
995
+/// Run the shared length lint: warnings go to stderr, a violation aborts
996
+/// with the field's message rewritten to name the CLI flag.
997
+fn lint_localization_fields(fields: &AppStoreVersionLocalizationFields) -> Result<()> {
998
+ match fields.validate() {
999
+ Ok(warnings) => {
1000
+ for warning in warnings {
1001
+ eprintln!("warning: {warning}");
1002
+ }
1003
+ Ok(())
1004
+ }
1005
+ Err(message) => {
1006
+ let (field, rest) = message.split_once(' ').unwrap_or((&message, ""));
1007
+ let mut flag = String::from("--");
1008
+ for c in field.chars() {
1009
+ if c.is_ascii_uppercase() {
1010
+ flag.push('-');
1011
+ flag.push(c.to_ascii_lowercase());
1012
+ } else {
1013
+ flag.push(c);
1014
+ }
1015
+ }
1016
+ anyhow::bail!("{flag} {rest}. Shorten the text and try again.")
1017
+ }
1018
+ }
1019
+}
1020
+
1021
+/// Names of the fields in `sent` whose stored value is not an exact match,
1022
+/// with the index of the first differing character (a missing value counts
1023
+/// as the empty string).
1024
+fn diff_fields(
1025
+ sent: &AppStoreVersionLocalizationFields,
1026
+ stored: &AppStoreVersionLocalizationAttributes,
1027
+) -> Vec<&'static str> {
1028
+ [
1029
+ ("description", &sent.description, &stored.description),
1030
+ ("keywords", &sent.keywords, &stored.keywords),
1031
+ ("marketingUrl", &sent.marketing_url, &stored.marketing_url),
1032
+ (
1033
+ "promotionalText",
1034
+ &sent.promotional_text,
1035
+ &stored.promotional_text,
1036
+ ),
1037
+ ("supportUrl", &sent.support_url, &stored.support_url),
1038
+ ("whatsNew", &sent.whats_new, &stored.whats_new),
1039
+ ]
1040
+ .into_iter()
1041
+ .filter(|(_, sent, stored)| match sent {
1042
+ Some(sent) => stored.as_deref() != Some(sent.as_str()),
1043
+ None => false,
1044
+ })
1045
+ .map(|(name, _, _)| name)
1046
+ .collect()
1047
+}
1048
+
1049
+fn first_diff_index(a: &str, b: &str) -> usize {
1050
+ a.chars()
1051
+ .zip(b.chars())
1052
+ .position(|(x, y)| x != y)
1053
+ .unwrap_or_else(|| a.chars().count().min(b.chars().count()))
1054
+}
1055
+
1056
+/// Read the localization back and fail if any field that was sent is not
1057
+/// stored byte for byte.
1058
+async fn verify_localization(
1059
+ client: &Client,
1060
+ id: &str,
1061
+ sent: &AppStoreVersionLocalizationFields,
1062
+ no_verify: bool,
1063
+) -> Result<()> {
1064
+ if no_verify {
1065
+ return Ok(());
1066
+ }
1067
+ let stored = client.get_app_store_version_localization(id).await?;
1068
+ let mismatched = diff_fields(sent, &stored.attributes);
1069
+ if mismatched.is_empty() {
1070
+ return Ok(());
1071
+ }
1072
+ let details = mismatched
1073
+ .iter()
1074
+ .map(|name| {
1075
+ let (sent, stored) = match *name {
1076
+ "description" => (&sent.description, &stored.attributes.description),
1077
+ "keywords" => (&sent.keywords, &stored.attributes.keywords),
1078
+ "marketingUrl" => (&sent.marketing_url, &stored.attributes.marketing_url),
1079
+ "promotionalText" => (&sent.promotional_text, &stored.attributes.promotional_text),
1080
+ "supportUrl" => (&sent.support_url, &stored.attributes.support_url),
1081
+ _ => (&sent.whats_new, &stored.attributes.whats_new),
1082
+ };
1083
+ let at = first_diff_index(
1084
+ sent.as_deref().unwrap_or(""),
1085
+ stored.as_deref().unwrap_or(""),
1086
+ );
1087
+ format!("{name} (first difference at char {at})")
1088
+ })
1089
+ .collect::<Vec<_>>()
1090
+ .join(", ");
1091
+ anyhow::bail!("read-back mismatch on {id}: {details}")
1092
+}
1093
+
1094
/// Validate a `--version-string` before sending it. Apple takes one to
1095
/// three period-separated integers (`1.1` and `1.1.0` are both fine) and
1096
/// answers anything else with an opaque error, so the shape is checked
1111
Ok(())
1112
}
1113
954
-/// Maximum length App Store Connect accepts for a "What to Test" note
955
-/// (`whatsNew` on `betaBuildLocalizations`). Apple's answer for anything
956
-/// longer is an opaque 400, so the CLI rejects locally and names the
957
-/// flag in the message.
958
-const WHATS_NEW_MAX_LEN: usize = 4000;
1114
+use smbcloud_ascapi_aso::app_store_version_localization::WHATS_NEW_MAX_LEN;
1115
1116
/// Validate a `--whats-new` text before sending it. App Store Connect caps
1117
/// the field at [`WHATS_NEW_MAX_LEN`] characters and answers with an opaque
1414
build_id,
1415
locale,
1416
whats_new,
1417
+ whats_new_file,
1418
} => {
1419
+ let whats_new = text_arg(whats_new, whats_new_file, "--whats-new-file")?
1420
+ .context("one of --whats-new or --whats-new-file is required")?;
1421
validate_whats_new(&whats_new)?;
1422
if dry_run {
1423
println!(
1430
.await?;
1431
print_json(&created)
1432
}
1274
- BetaBuildLocalizationsCommand::Update { id, whats_new } => {
1433
+ BetaBuildLocalizationsCommand::Update {
1434
+ id,
1435
+ whats_new,
1436
+ whats_new_file,
1437
+ } => {
1438
+ let whats_new = text_arg(whats_new, whats_new_file, "--whats-new-file")?
1439
+ .context("one of --whats-new or --whats-new-file is required")?;
1440
validate_whats_new(&whats_new)?;
1441
if dry_run {
1442
println!("[dry-run] would update \"What to Test\" note {id}:");
1787
app_store_version_id,
1788
locale,
1789
description,
1790
+ description_file,
1791
keywords,
1792
+ keywords_file,
1793
whats_new,
1794
+ whats_new_file,
1795
promotional_text,
1796
+ promotional_text_file,
1797
marketing_url,
1798
support_url,
1799
+ no_verify,
1800
} => {
1801
+ let fields = AppStoreVersionLocalizationFields {
1802
+ description: text_arg(description, description_file, "--description-file")?,
1803
+ keywords: text_arg(keywords, keywords_file, "--keywords-file")?,
1804
+ whats_new: text_arg(whats_new, whats_new_file, "--whats-new-file")?,
1805
+ promotional_text: text_arg(
1806
+ promotional_text,
1807
+ promotional_text_file,
1808
+ "--promotional-text-file",
1809
+ )?,
1810
+ marketing_url,
1811
+ support_url,
1812
+ };
1813
+ lint_localization_fields(&fields)?;
1814
let attributes = AppStoreVersionLocalizationCreateAttributes {
1815
locale,
1633
- fields: AppStoreVersionLocalizationFields {
1634
- description,
1635
- keywords,
1636
- whats_new,
1637
- promotional_text,
1638
- marketing_url,
1639
- support_url,
1640
- },
1816
+ fields: fields.clone(),
1817
};
1818
if dry_run_guard(dry_run, &attributes)? {
1819
return Ok(());
1820
}
1645
- print_json(
1646
- &client
1647
- .create_app_store_version_localization(&app_store_version_id, attributes)
1648
- .await?,
1649
- )
1821
+ let created = client
1822
+ .create_app_store_version_localization(&app_store_version_id, attributes)
1823
+ .await?;
1824
+ verify_localization(client, &created.id, &fields, no_verify).await?;
1825
+ print_json(&created)
1826
}
1827
AppStoreVersionLocalizationsCommand::Update {
1828
id,
1829
description,
1830
+ description_file,
1831
keywords,
1832
+ keywords_file,
1833
whats_new,
1834
+ whats_new_file,
1835
promotional_text,
1836
+ promotional_text_file,
1837
marketing_url,
1838
support_url,
1839
+ no_verify,
1840
} => {
1841
let fields = AppStoreVersionLocalizationFields {
1661
- description,
1662
- keywords,
1663
- whats_new,
1664
- promotional_text,
1842
+ description: text_arg(description, description_file, "--description-file")?,
1843
+ keywords: text_arg(keywords, keywords_file, "--keywords-file")?,
1844
+ whats_new: text_arg(whats_new, whats_new_file, "--whats-new-file")?,
1845
+ promotional_text: text_arg(
1846
+ promotional_text,
1847
+ promotional_text_file,
1848
+ "--promotional-text-file",
1849
+ )?,
1850
marketing_url,
1851
support_url,
1852
};
1853
+ lint_localization_fields(&fields)?;
1854
if dry_run_guard(dry_run, &fields)? {
1855
return Ok(());
1856
}
1671
- print_json(
1672
- &client
1673
- .update_app_store_version_localization(&id, fields)
1674
- .await?,
1675
- )
1857
+ let updated = client
1858
+ .update_app_store_version_localization(&id, fields.clone())
1859
+ .await?;
1860
+ verify_localization(client, &updated.id, &fields, no_verify).await?;
1861
+ print_json(&updated)
1862
}
1863
AppStoreVersionLocalizationsCommand::Delete { id } => {
1864
if dry_run {
2292
use super::validate_version_string;
2293
use super::validate_whats_new;
2294
use super::WHATS_NEW_MAX_LEN;
2295
+ use super::{diff_fields, lint_localization_fields, text_arg, Cli};
2296
+ use clap::Parser;
2297
use flate2::write::GzEncoder;
2298
use flate2::Compression;
2299
+ use smbcloud_ascapi_aso::app_store_version_localization::{
2300
+ AppStoreVersionLocalizationAttributes, AppStoreVersionLocalizationFields,
2301
+ };
2302
use std::io::Write;
2303
2304
+ fn temp_file(name: &str, contents: &str) -> std::path::PathBuf {
2305
+ let path = std::env::temp_dir().join(format!("asc-test-{}-{name}", std::process::id()));
2306
+ std::fs::write(&path, contents).unwrap();
2307
+ path
2308
+ }
2309
+
2310
+ #[test]
2311
+ fn text_arg_strips_one_trailing_newline_and_keeps_the_rest() {
2312
+ let path = temp_file("strip", "a & b\n\nline two\n");
2313
+ let text = text_arg(None, Some(path.clone()), "--description-file").unwrap();
2314
+ assert_eq!(text.as_deref(), Some("a & b\n\nline two"));
2315
+ std::fs::write(&path, "crlf\r\n").unwrap();
2316
+ let text = text_arg(None, Some(path.clone()), "--description-file").unwrap();
2317
+ assert_eq!(text.as_deref(), Some("crlf"));
2318
+ std::fs::remove_file(path).unwrap();
2319
+ }
2320
+
2321
+ #[test]
2322
+ fn text_arg_passes_inline_through_and_names_flag_on_error() {
2323
+ assert_eq!(
2324
+ text_arg(Some("x\n".into()), None, "--f")
2325
+ .unwrap()
2326
+ .as_deref(),
2327
+ Some("x\n")
2328
+ );
2329
+ let error = text_arg(None, Some("/nonexistent/asc.txt".into()), "--keywords-file")
2330
+ .unwrap_err()
2331
+ .to_string();
2332
+ assert!(error.contains("--keywords-file") && error.contains("/nonexistent/asc.txt"));
2333
+ }
2334
+
2335
+ #[test]
2336
+ fn file_and_inline_flags_conflict() {
2337
+ let result = Cli::try_parse_from([
2338
+ "asc",
2339
+ "app-store-version-localizations",
2340
+ "update",
2341
+ "id",
2342
+ "--description",
2343
+ "a",
2344
+ "--description-file",
2345
+ "b.txt",
2346
+ ]);
2347
+ assert!(result.is_err());
2348
+ let result =
2349
+ Cli::try_parse_from(["asc", "testflight", "build-localizations", "update", "id"]);
2350
+ assert!(
2351
+ result.is_err(),
2352
+ "one of the two whats-new flags is required"
2353
+ );
2354
+ let result = Cli::try_parse_from([
2355
+ "asc",
2356
+ "testflight",
2357
+ "build-localizations",
2358
+ "update",
2359
+ "id",
2360
+ "--whats-new",
2361
+ "a",
2362
+ "--whats-new-file",
2363
+ "b.txt",
2364
+ ]);
2365
+ assert!(result.is_err());
2366
+ }
2367
+
2368
+ #[test]
2369
+ fn lint_names_the_flag() {
2370
+ let fields = AppStoreVersionLocalizationFields {
2371
+ promotional_text: Some("a".repeat(171)),
2372
+ ..Default::default()
2373
+ };
2374
+ let error = lint_localization_fields(&fields).unwrap_err().to_string();
2375
+ assert!(error.starts_with("--promotional-text is 171"), "{error}");
2376
+ }
2377
+
2378
+ fn stored(description: Option<&str>) -> AppStoreVersionLocalizationAttributes {
2379
+ AppStoreVersionLocalizationAttributes {
2380
+ locale: "en-US".into(),
2381
+ description: description.map(Into::into),
2382
+ keywords: None,
2383
+ marketing_url: None,
2384
+ promotional_text: None,
2385
+ support_url: None,
2386
+ whats_new: None,
2387
+ }
2388
+ }
2389
+
2390
+ #[test]
2391
+ fn diff_fields_matches_and_mismatches() {
2392
+ let sent = AppStoreVersionLocalizationFields {
2393
+ description: Some("abc".into()),
2394
+ ..Default::default()
2395
+ };
2396
+ assert!(diff_fields(&sent, &stored(Some("abc"))).is_empty());
2397
+ assert_eq!(
2398
+ diff_fields(&sent, &stored(Some("abd"))),
2399
+ vec!["description"]
2400
+ );
2401
+ assert_eq!(diff_fields(&sent, &stored(None)), vec!["description"]);
2402
+ // Fields that were not sent are never compared.
2403
+ assert!(diff_fields(
2404
+ &AppStoreVersionLocalizationFields::default(),
2405
+ &stored(Some("x"))
2406
+ )
2407
+ .is_empty());
2408
+ }
2409
+
2410
fn gzip(tsv: &str) -> Vec<u8> {
2411
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
2412
encoder.write_all(tsv.as_bytes()).unwrap();