feat(cli,aso,mcp): file-backed copy flags, metadata limit lint, read-back verify (#39)

app-store-version-localizations create/update gain --description-file, --keywords-file, --whats-new-file and --promotional-text-file, each conflicting with its inline flag. One trailing newline is dropped, as $(cat file) did. testflight build-localizations takes --whats-new-file too, with exactly one of the two required. AppStoreVersionLocalizationFields::validate() checks the 4000/100/170/4000 character limits and warns about spaces after keyword commas and empty entries. The CLI runs it before --dry-run and names the flag in the error; the MCP create/update tools return invalid_params. After a write, create and update GET the localization back and fail with the field names and first differing character if any sent field was not stored exactly. --no-verify skips it; --dry-run never reaches it. Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 17, 2026 at 08:52 UTC 66d50279c2567010c822a8cf2ee1daec2ff1a10e
3 files changed +481 -33
crates/aso/src/app_store_version_localization.rs
+145
@@ -9,6 +9,16 @@ use smbcloud_ascapi_core::Result;
9
10 pub const RESOURCE_TYPE: &str = "appStoreVersionLocalizations";
11
12 +/// Maximum `description` length App Store Connect accepts, in characters.
13 +pub const DESCRIPTION_MAX_LEN: usize = 4000;
14 +/// Maximum `keywords` length, in characters, commas included.
15 +pub const KEYWORDS_MAX_LEN: usize = 100;
16 +/// Maximum `promotionalText` length, in characters.
17 +pub const PROMOTIONAL_TEXT_MAX_LEN: usize = 170;
18 +/// Maximum `whatsNew` length, in characters. Beta "What to Test" notes
19 +/// share the same limit.
20 +pub const WHATS_NEW_MAX_LEN: usize = 4000;
21 +
22 #[derive(Debug, Clone, Serialize, Deserialize)]
23 #[serde(rename_all = "camelCase")]
24 pub struct AppStoreVersionLocalizationAttributes {
@@ -40,6 +50,51 @@ pub struct AppStoreVersionLocalizationFields {
50 pub whats_new: Option<String>,
51 }
52
53 +impl AppStoreVersionLocalizationFields {
54 + /// Check the fields against App Store Connect's length limits before
55 + /// anything is sent; Apple answers an over-long value with an opaque
56 + /// 400. Lengths are counted in characters, as Apple counts them.
57 + ///
58 + /// Returns `Err` naming the first field over its limit (with its length
59 + /// and the limit), or `Ok` with advisory warnings: a space after a
60 + /// keyword comma and empty keyword entries both waste the 100-character
61 + /// budget without changing what the store indexes.
62 + pub fn validate(&self) -> std::result::Result<Vec<String>, String> {
63 + let limits = [
64 + ("description", &self.description, DESCRIPTION_MAX_LEN),
65 + ("keywords", &self.keywords, KEYWORDS_MAX_LEN),
66 + (
67 + "promotionalText",
68 + &self.promotional_text,
69 + PROMOTIONAL_TEXT_MAX_LEN,
70 + ),
71 + ("whatsNew", &self.whats_new, WHATS_NEW_MAX_LEN),
72 + ];
73 + for (name, value, max) in limits {
74 + let len = value.as_deref().map_or(0, |v| v.chars().count());
75 + if len > max {
76 + return Err(format!(
77 + "{name} is {len} characters; App Store Connect accepts at most {max}"
78 + ));
79 + }
80 + }
81 + let mut warnings = Vec::new();
82 + if let Some(keywords) = &self.keywords {
83 + if keywords.contains(", ") {
84 + warnings.push(
85 + "keywords: a space after a comma wastes a character; use `a,b` not `a, b`"
86 + .to_string(),
87 + );
88 + }
89 + if keywords.split(',').any(|k| k.trim().is_empty()) && !keywords.is_empty() {
90 + warnings
91 + .push("keywords: contains an empty entry (`,,` or a stray comma)".to_string());
92 + }
93 + }
94 + Ok(warnings)
95 + }
96 +}
97 +
98 #[derive(Debug, Clone, Serialize)]
99 #[serde(rename_all = "camelCase")]
100 pub struct AppStoreVersionLocalizationCreateAttributes {
@@ -75,6 +130,13 @@ pub trait AppStoreVersionLocalizationsApi {
130 attributes: AppStoreVersionLocalizationCreateAttributes,
131 ) -> Result<AppStoreVersionLocalization>;
132
133 + /// `GET /v1/appStoreVersionLocalizations/{id}` — read one localization
134 + /// back, e.g. to verify a write stored what was sent.
135 + async fn get_app_store_version_localization(
136 + &self,
137 + id: &str,
138 + ) -> Result<AppStoreVersionLocalization>;
139 +
140 async fn update_app_store_version_localization(
141 &self,
142 id: &str,
@@ -126,6 +188,16 @@ impl AppStoreVersionLocalizationsApi for Client {
188 Ok(doc.data)
189 }
190
191 + async fn get_app_store_version_localization(
192 + &self,
193 + id: &str,
194 + ) -> Result<AppStoreVersionLocalization> {
195 + let path = format!("/v1/appStoreVersionLocalizations/{id}");
196 + let doc: Document<AppStoreVersionLocalizationAttributes> =
197 + self.request(Method::GET, &path, &[], None::<&()>).await?;
198 + Ok(doc.data)
199 + }
200 +
201 async fn update_app_store_version_localization(
202 &self,
203 id: &str,
@@ -150,3 +222,76 @@ impl AppStoreVersionLocalizationsApi for Client {
222 .await
223 }
224 }
225 +
226 +#[cfg(test)]
227 +mod tests {
228 + use super::*;
229 +
230 + fn only(
231 + f: impl FnOnce(&mut AppStoreVersionLocalizationFields),
232 + ) -> AppStoreVersionLocalizationFields {
233 + let mut fields = AppStoreVersionLocalizationFields::default();
234 + f(&mut fields);
235 + fields
236 + }
237 +
238 + #[test]
239 + fn each_field_over_its_limit_is_rejected_by_name() {
240 + let cases = [
241 + (
242 + "description",
243 + only(|f| f.description = Some("a".repeat(DESCRIPTION_MAX_LEN + 1))),
244 + ),
245 + (
246 + "keywords",
247 + only(|f| f.keywords = Some("a".repeat(KEYWORDS_MAX_LEN + 1))),
248 + ),
249 + (
250 + "promotionalText",
251 + only(|f| f.promotional_text = Some("a".repeat(PROMOTIONAL_TEXT_MAX_LEN + 1))),
252 + ),
253 + (
254 + "whatsNew",
255 + only(|f| f.whats_new = Some("a".repeat(WHATS_NEW_MAX_LEN + 1))),
256 + ),
257 + ];
258 + for (name, fields) in cases {
259 + let message = fields.validate().expect_err("over-limit must be rejected");
260 + assert!(message.starts_with(name), "{message}");
261 + assert!(message.contains("at most"), "{message}");
262 + }
263 + }
264 +
265 + #[test]
266 + fn fields_at_their_limit_pass() {
267 + let fields = AppStoreVersionLocalizationFields {
268 + description: Some("a".repeat(DESCRIPTION_MAX_LEN)),
269 + keywords: Some("a".repeat(KEYWORDS_MAX_LEN)),
270 + promotional_text: Some("a".repeat(PROMOTIONAL_TEXT_MAX_LEN)),
271 + whats_new: Some("a".repeat(WHATS_NEW_MAX_LEN)),
272 + ..Default::default()
273 + };
274 + assert_eq!(fields.validate(), Ok(vec![]));
275 + }
276 +
277 + #[test]
278 + fn length_counts_characters_not_bytes() {
279 + // `•` is 3 bytes in UTF-8 but one character.
280 + let fields = only(|f| f.promotional_text = Some("•".repeat(PROMOTIONAL_TEXT_MAX_LEN)));
281 + assert!(fields.validate().is_ok());
282 + let over = only(|f| f.promotional_text = Some("•".repeat(PROMOTIONAL_TEXT_MAX_LEN + 1)));
283 + assert!(over.validate().is_err());
284 + }
285 +
286 + #[test]
287 + fn keyword_spacing_and_empty_entries_warn() {
288 + let spaced = only(|f| f.keywords = Some("a, b".into()));
289 + assert_eq!(spaced.validate().unwrap().len(), 1);
290 + let empty = only(|f| f.keywords = Some("a,,b".into()));
291 + let warnings = empty.validate().unwrap();
292 + assert_eq!(warnings.len(), 1);
293 + assert!(warnings[0].contains("empty"));
294 + let clean = only(|f| f.keywords = Some("a,b".into()));
295 + assert!(clean.validate().unwrap().is_empty());
296 + }
297 +}
crates/cli/src/main.rs
+328 -31
@@ -11,7 +11,8 @@ use smbcloud_ascapi_aso::app_store_version::{
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;
@@ -205,6 +206,7 @@ enum BetaBuildLocalizationsCommand {
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,
@@ -213,16 +215,25 @@ enum BetaBuildLocalizationsCommand {
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
@@ -765,31 +776,63 @@ enum AppStoreVersionLocalizationsCommand {
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,
@@ -931,6 +974,123 @@ fn dry_run_guard(dry_run: bool, value: &impl serde::Serialize) -> Result<bool> {
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
@@ -951,11 +1111,7 @@ fn validate_version_string(version_string: &str) -> Result<()> {
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
@@ -1258,7 +1414,10 @@ async fn run_testflight(client: &Client, command: TestflightCommand, dry_run: bo
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!(
@@ -1271,7 +1430,13 @@ async fn run_testflight(client: &Client, command: TestflightCommand, dry_run: bo
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}:");
@@ -1622,57 +1787,78 @@ async fn run_app_store_version_localizations(
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 {
@@ -2106,10 +2292,121 @@ mod tests {
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();
crates/mcp/src/lib.rs
+8 -2
@@ -548,7 +548,7 @@ macro_rules! ascapi_mcp_tools {
548 name = $app_store_version_localization_create_name,
549 title = "Add a version localization",
550 annotations(title = "Add a version localization", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
551 - description = "Purpose: add the description, keywords, and release notes for a locale on a specific version. When to use vs siblings: use the update tool when the locale already exists on this version. Behavior: creates the localization with whichever fields are provided and returns it. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_<key-id>.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: App Store Connect enforces field limits, notably 4000 characters of description, 100 for keywords, and 170 for promotional text, and rejects longer values outright."
551 + description = "Purpose: add the description, keywords, and release notes for a locale on a specific version. When to use vs siblings: use the update tool when the locale already exists on this version. Behavior: creates the localization with whichever fields are provided and returns it. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_<key-id>.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: field limits are checked before sending (4000 characters for description and release notes, 100 for keywords, 170 for promotional text), and a value over its limit returns an invalid-params error naming the field."
552 )]
553 async fn app_store_version_localization_create(
554 &self,
@@ -568,6 +568,9 @@ macro_rules! ascapi_mcp_tools {
568 support_url: request.support_url,
569 whats_new: request.whats_new,
570 };
571 + fields
572 + .validate()
573 + .map_err(|message| ::rmcp::model::ErrorData::invalid_params(message, None))?;
574 let attributes = ::smbcloud_ascapi_aso::app_store_version_localization::AppStoreVersionLocalizationCreateAttributes {
575 locale: request.locale,
576 fields,
@@ -585,7 +588,7 @@ macro_rules! ascapi_mcp_tools {
588 name = $app_store_version_localization_update_name,
589 title = "Edit a version localization",
590 annotations(title = "Edit a version localization", read_only_hint = false, destructive_hint = false, idempotent_hint = true),
588 - description = "Purpose: change the description, keywords, promotional text, or release notes for an existing locale on a version. When to use vs siblings: promotional text is the one field editable without shipping a new build, which makes this the tool for timely copy changes. Behavior: sends only the fields provided and leaves the rest untouched. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_<key-id>.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: which fields are editable depends on the version's review state, and Apple rejects edits to a version under review."
591 + description = "Purpose: change the description, keywords, promotional text, or release notes for an existing locale on a version. When to use vs siblings: promotional text is the one field editable without shipping a new build, which makes this the tool for timely copy changes. Behavior: sends only the fields provided and leaves the rest untouched. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_<key-id>.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: field limits are checked before sending (4000 characters for description and release notes, 100 for keywords, 170 for promotional text) and a value over its limit returns an invalid-params error naming the field; which fields are editable depends on the version's review state, and Apple rejects edits to a version under review."
592 )]
593 async fn app_store_version_localization_update(
594 &self,
@@ -605,6 +608,9 @@ macro_rules! ascapi_mcp_tools {
608 support_url: request.support_url,
609 whats_new: request.whats_new,
610 };
611 + fields
612 + .validate()
613 + .map_err(|message| ::rmcp::model::ErrorData::invalid_params(message, None))?;
614 let value = client
615 .update_app_store_version_localization(&request.id, fields)
616 .await