use super::*;
use anyhow::{bail, Context, Result};
use reqwest::blocking::{Client, Response};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::path::Path;
use std::time::Duration;
const MICROSOFT_STORE_API: &str = "https://api.store.microsoft.com";
const MICROSOFT_STORE_SCOPE: &str = "https://api.store.microsoft.com/.default";
#[derive(Debug, Deserialize, Default)]
struct ReleaseProviderToml {
distribution: Option<DistributionToml>,
release: Option<ReleaseRootToml>,
#[serde(default)]
releases: Vec<ReleaseEntryToml>,
}
#[derive(Debug, Deserialize, Default)]
struct DistributionToml {
microsoft_store: Option<MicrosoftStoreConfig>,
}
#[derive(Debug, Deserialize, Default)]
struct ReleaseRootToml {
active_release: Option<String>,
#[serde(default)]
default_locales: Vec<String>,
#[serde(default)]
store_listing: BTreeMap<String, BTreeMap<String, StoreListingToml>>,
}
#[derive(Debug, Deserialize, Default)]
struct ReleaseEntryToml {
id: Option<String>,
#[serde(default)]
locales: Vec<String>,
metadata: Option<String>,
release_notes: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Default, Serialize, PartialEq, Eq)]
struct StoreListingToml {
title: Option<String>,
name: Option<String>,
short_description: Option<String>,
subtitle: Option<String>,
#[serde(default)]
keywords: Vec<String>,
privacy_url: Option<String>,
support_url: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Default)]
struct ReleaseMetadataToml {
#[serde(default)]
microsoft_store: BTreeMap<String, MicrosoftReleaseMetadataToml>,
}
#[derive(Clone, Debug, Deserialize, Default)]
struct MicrosoftReleaseMetadataToml {
description: Option<String>,
#[serde(default)]
features: Vec<String>,
#[serde(default)]
search_terms: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Default)]
struct MicrosoftStoreConfig {
product_id: Option<String>,
tenant_id: Option<String>,
client_id: Option<String>,
seller_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
struct MicrosoftListing {
language: String,
title: Option<String>,
short_description: Option<String>,
description: String,
keywords: Vec<String>,
privacy_url: Option<String>,
support_url: Option<String>,
release_notes: Option<String>,
features: Vec<String>,
search_terms: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct OAuthTokenResponse {
access_token: String,
}
pub(super) fn release_config_import(
project_dir: &Path,
locales: Option<&str>,
yes: bool,
json_output: bool,
) -> Result<()> {
if !yes {
bail!("release-config import mutates fission.toml/release metadata; pass --yes after reviewing the provider and locales");
}
let root = read_release_provider_toml(project_dir)?;
let cfg = microsoft_store_config(project_dir)?;
let product_id = product_id(&cfg)?;
let seller_id = seller_id(&cfg)?;
let client = http_client()?;
let token = microsoft_store_access_token(&cfg, &client)?;
let remote = fetch_microsoft_listings(&client, &token, &seller_id, product_id, locales)?;
write_imported_microsoft_listings(project_dir, &root, &remote)?;
let summary = json!({
"provider": "microsoft-store",
"product_id": product_id,
"imported_locales": remote.iter().map(|item| item.language.as_str()).collect::<Vec<_>>(),
"status": "imported"
});
if json_output {
println!("{}", serde_json::to_string_pretty(&summary)?);
} else {
println!("Imported {} Microsoft Store listing(s)", remote.len());
}
Ok(())
}
pub(super) fn release_config_diff(project_dir: &Path, json_output: bool) -> Result<()> {
let root = read_release_provider_toml(project_dir)?;
let cfg = microsoft_store_config(project_dir)?;
let product_id = product_id(&cfg)?;
let seller_id = seller_id(&cfg)?;
let locales = resolve_release_locales(&root, None)?;
let local = resolve_microsoft_listings(project_dir, &root, &locales)?;
let client = http_client()?;
let token = microsoft_store_access_token(&cfg, &client)?;
let remote = fetch_microsoft_listings(&client, &token, &seller_id, product_id, None)?;
let diff = microsoft_listing_diff(&local, &remote);
if json_output {
println!("{}", serde_json::to_string_pretty(&diff)?);
} else if diff.as_array().is_some_and(Vec::is_empty) {
println!(
"Microsoft Store metadata is in sync for {} locale(s)",
locales.len()
);
} else {
println!("Microsoft Store metadata differences:");
for item in diff.as_array().into_iter().flatten() {
println!(
"{} {}: local={:?} remote={:?}",
item.get("locale")
.and_then(Value::as_str)
.unwrap_or("<locale>"),
item.get("field")
.and_then(Value::as_str)
.unwrap_or("<field>"),
item.get("local"),
item.get("remote")
);
}
}
Ok(())
}
pub(super) fn release_config_push(
project_dir: &Path,
locales_arg: Option<&str>,
dry_run: bool,
yes: bool,
json_output: bool,
) -> Result<()> {
if !dry_run && !yes {
bail!("release-config push mutates provider metadata; pass --yes after reviewing `release-config diff`");
}
let root = read_release_provider_toml(project_dir)?;
let cfg = microsoft_store_config(project_dir)?;
let product_id = product_id(&cfg)?;
let seller_id = seller_id(&cfg)?;
let locales = resolve_release_locales(&root, locales_arg)?;
let local = resolve_microsoft_listings(project_dir, &root, &locales)?;
if dry_run {
let value = json!({
"provider": "microsoft-store",
"product_id": product_id,
"locales": locales,
"listings": local,
"status": "dry-run"
});
if json_output {
println!("{}", serde_json::to_string_pretty(&value)?);
} else {
println!("Would push {} Microsoft Store listing(s)", local.len());
}
return Ok(());
}
let client = http_client()?;
let token = microsoft_store_access_token(&cfg, &client)?;
let remote = fetch_microsoft_raw_listings(&client, &token, &seller_id, product_id, None)?;
let mut responses = Vec::new();
for listing in &local {
let existing = find_microsoft_listing_value(&remote, &listing.language);
let payload = microsoft_listing_payload(existing, listing);
let response = client
.put(format!(
"{MICROSOFT_STORE_API}/submission/v1/product/{product_id}/metadata"
))
.bearer_auth(&token)
.header("X-Seller-Account-Id", &seller_id)
.json(&payload)
.send()
.with_context(|| {
format!(
"failed to push Microsoft Store metadata for {}",
listing.language
)
})?;
let value = json_response(response, "Microsoft Store metadata push")?;
microsoft_store_success(&value, "Microsoft Store metadata push")?;
responses.push(value);
}
let summary = json!({
"provider": "microsoft-store",
"product_id": product_id,
"pushed_locales": local.iter().map(|item| item.language.as_str()).collect::<Vec<_>>(),
"responses": responses,
"status": "pushed"
});
if json_output {
println!("{}", serde_json::to_string_pretty(&summary)?);
} else {
println!("Pushed {} Microsoft Store listing(s)", local.len());
}
Ok(())
}
fn fetch_microsoft_listings(
client: &Client,
token: &str,
seller_id: &str,
product_id: &str,
locales: Option<&str>,
) -> Result<Vec<MicrosoftListing>> {
let value = fetch_microsoft_raw_listings(client, token, seller_id, product_id, locales)?;
microsoft_listings_from_value(&value)
}
fn fetch_microsoft_raw_listings(
client: &Client,
token: &str,
seller_id: &str,
product_id: &str,
locales: Option<&str>,
) -> Result<Value> {
let mut url =
format!("{MICROSOFT_STORE_API}/submission/v1/product/{product_id}/metadata/listings");
if let Some(locales) = locales.filter(|value| !value.trim().is_empty()) {
url.push_str("?languages=");
url.push_str(&encode_query(locales));
}
let response = client
.get(url)
.bearer_auth(token)
.header("X-Seller-Account-Id", seller_id)
.send()
.context("failed to fetch Microsoft Store listing metadata")?;
json_response(response, "Microsoft Store metadata listing")
}
fn microsoft_listings_from_value(value: &Value) -> Result<Vec<MicrosoftListing>> {
let listings = value
.pointer("/responseData/listings")
.or_else(|| value.get("listings"))
.and_then(Value::as_array)
.context("Microsoft Store metadata response did not contain responseData.listings")?;
listings
.iter()
.map(microsoft_listing_from_value)
.collect::<Result<Vec<_>>>()
}
fn microsoft_listing_from_value(value: &Value) -> Result<MicrosoftListing> {
let language = field_string(value, &["language", "locale"])
.context("Microsoft Store listing did not contain language")?;
let keywords = field_array(value, &["keywords", "keywordTerms"]);
let search_terms = field_array(value, &["searchTerms", "search_terms"]);
let description = field_string(value, &["description"]).unwrap_or_default();
Ok(MicrosoftListing {
language,
title: field_string(value, &["title", "name", "sortTitle"]),
short_description: field_string(
value,
&["shortDescription", "short_description", "subtitle"],
),
description,
keywords,
privacy_url: field_string(value, &["privacyUrl", "privacyPolicyUrl", "privacy_url"]),
support_url: field_string(value, &["supportUrl", "support_url"]),
release_notes: field_string(value, &["whatsNew", "releaseNotes", "release_notes"]),
features: field_array(value, &["features", "productFeatures"]),
search_terms,
})
}
fn find_microsoft_listing_value<'a>(value: &'a Value, language: &str) -> Option<&'a Value> {
value
.pointer("/responseData/listings")
.or_else(|| value.get("listings"))
.and_then(Value::as_array)?
.iter()
.find(|item| {
field_string(item, &["language", "locale"])
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(language))
})
}
fn resolve_microsoft_listings(
project_dir: &Path,
root: &ReleaseProviderToml,
locales: &[String],
) -> Result<Vec<MicrosoftListing>> {
locales
.iter()
.map(|locale| resolve_microsoft_listing(project_dir, root, locale))
.collect()
}
fn resolve_microsoft_listing(
project_dir: &Path,
root: &ReleaseProviderToml,
locale: &str,
) -> Result<MicrosoftListing> {
let release = active_release(root).context("release.active_release must point to a release")?;
let metadata_path = release
.metadata
.as_deref()
.context("active release metadata path is required for Microsoft Store metadata sync")?;
let metadata = read_release_metadata(project_dir, metadata_path)?;
let release_metadata = metadata
.microsoft_store
.get(locale)
.or_else(|| metadata.microsoft_store.get(&locale.to_ascii_lowercase()))
.with_context(|| {
format!("active release metadata [microsoft_store.{locale}].description is required")
})?;
let listing = root
.release
.as_ref()
.and_then(|release| release.store_listing.get("microsoft_store"))
.and_then(|store| {
store
.get(locale)
.or_else(|| store.get(&locale.to_ascii_lowercase()))
})
.cloned()
.unwrap_or_default();
let description = release_metadata
.description
.clone()
.filter(|value| !value.trim().is_empty())
.with_context(|| {
format!("active release metadata [microsoft_store.{locale}].description is required")
})?;
let release_notes = release
.release_notes
.as_deref()
.map(|notes_dir| project_dir.join(notes_dir).join(format!("{locale}.md")))
.filter(|path| path.exists())
.map(fs::read_to_string)
.transpose()?;
Ok(MicrosoftListing {
language: locale.to_string(),
title: listing.title.or(listing.name),
short_description: listing.short_description.or(listing.subtitle),
description,
keywords: listing.keywords,
privacy_url: listing.privacy_url,
support_url: listing.support_url,
release_notes,
features: release_metadata.features.clone(),
search_terms: release_metadata.search_terms.clone(),
})
}
fn microsoft_listing_payload(existing: Option<&Value>, listing: &MicrosoftListing) -> Value {
let mut listing_value = existing.cloned().unwrap_or_else(|| json!({}));
set_json_field(
&mut listing_value,
"language",
Some(listing.language.clone()),
);
set_json_field(
&mut listing_value,
"description",
Some(listing.description.clone()),
);
set_json_field(
&mut listing_value,
"shortDescription",
listing.short_description.clone(),
);
set_json_field(&mut listing_value, "sortTitle", listing.title.clone());
set_json_field(
&mut listing_value,
"privacyPolicyUrl",
listing.privacy_url.clone(),
);
set_json_field(
&mut listing_value,
"supportUrl",
listing.support_url.clone(),
);
set_json_field(
&mut listing_value,
"whatsNew",
listing.release_notes.clone(),
);
set_json_array(&mut listing_value, "keywords", &listing.keywords);
set_json_array(&mut listing_value, "searchTerms", &listing.search_terms);
set_json_array(&mut listing_value, "features", &listing.features);
json!({ "listings": listing_value })
}
fn write_imported_microsoft_listings(
project_dir: &Path,
root: &ReleaseProviderToml,
remote: &[MicrosoftListing],
) -> Result<()> {
let release = active_release(root).context("release.active_release must point to a release")?;
let metadata_path = release
.metadata
.as_deref()
.context("active release metadata path is required for Microsoft Store metadata import")?;
let toml_path = project_dir.join("fission.toml");
let mut fission_doc = parse_toml_edit_document(&fs::read_to_string(&toml_path)?, &toml_path)?;
for listing in remote {
if let Some(title) = listing.title.clone() {
set_toml_edit_path(
&mut fission_doc,
&format!(
"release.store_listing.microsoft_store.{}.title",
listing.language
),
toml_edit::value(title),
)?;
}
if let Some(short_description) = listing.short_description.clone() {
set_toml_edit_path(
&mut fission_doc,
&format!(
"release.store_listing.microsoft_store.{}.short_description",
listing.language
),
toml_edit::value(short_description),
)?;
}
if let Some(privacy_url) = listing.privacy_url.clone() {
set_toml_edit_path(
&mut fission_doc,
&format!(
"release.store_listing.microsoft_store.{}.privacy_url",
listing.language
),
toml_edit::value(privacy_url),
)?;
}
}
write_toml_edit_document(&toml_path, &fission_doc)?;
let metadata_abs = project_dir.join(metadata_path);
let mut metadata_doc: toml::Value = if metadata_abs.exists() {
toml::from_str(&fs::read_to_string(&metadata_abs)?)?
} else {
toml::Value::Table(Default::default())
};
for listing in remote {
set_toml_path(
&mut metadata_doc,
&format!("microsoft_store.{}.description", listing.language),
toml::Value::String(listing.description.clone()),
)?;
if !listing.features.is_empty() {
set_toml_path(
&mut metadata_doc,
&format!("microsoft_store.{}.features", listing.language),
toml::Value::Array(
listing
.features
.iter()
.cloned()
.map(toml::Value::String)
.collect(),
),
)?;
}
if !listing.search_terms.is_empty() {
set_toml_path(
&mut metadata_doc,
&format!("microsoft_store.{}.search_terms", listing.language),
toml::Value::Array(
listing
.search_terms
.iter()
.cloned()
.map(toml::Value::String)
.collect(),
),
)?;
}
}
if let Some(parent) = metadata_abs.parent() {
fs::create_dir_all(parent)?;
}
fs::write(metadata_abs, toml::to_string_pretty(&metadata_doc)? + "\n")?;
Ok(())
}
fn microsoft_listing_diff(local: &[MicrosoftListing], remote: &[MicrosoftListing]) -> Value {
let mut diffs = Vec::new();
for local_listing in local {
let remote_listing = remote
.iter()
.find(|item| item.language.eq_ignore_ascii_case(&local_listing.language));
let Some(remote_listing) = remote_listing else {
diffs.push(json!({
"locale": local_listing.language,
"field": "listing",
"local": "present",
"remote": null
}));
continue;
};
push_option_diff(
&mut diffs,
&local_listing.language,
"title",
local_listing.title.as_deref(),
remote_listing.title.as_deref(),
);
push_option_diff(
&mut diffs,
&local_listing.language,
"short_description",
local_listing.short_description.as_deref(),
remote_listing.short_description.as_deref(),
);
push_option_diff(
&mut diffs,
&local_listing.language,
"description",
Some(local_listing.description.as_str()),
Some(remote_listing.description.as_str()),
);
push_option_diff(
&mut diffs,
&local_listing.language,
"privacy_url",
local_listing.privacy_url.as_deref(),
remote_listing.privacy_url.as_deref(),
);
push_option_diff(
&mut diffs,
&local_listing.language,
"release_notes",
local_listing.release_notes.as_deref(),
remote_listing.release_notes.as_deref(),
);
if local_listing.keywords != remote_listing.keywords {
diffs.push(json!({
"locale": local_listing.language,
"field": "keywords",
"local": local_listing.keywords,
"remote": remote_listing.keywords
}));
}
if local_listing.features != remote_listing.features {
diffs.push(json!({
"locale": local_listing.language,
"field": "features",
"local": local_listing.features,
"remote": remote_listing.features
}));
}
if local_listing.search_terms != remote_listing.search_terms {
diffs.push(json!({
"locale": local_listing.language,
"field": "search_terms",
"local": local_listing.search_terms,
"remote": remote_listing.search_terms
}));
}
}
Value::Array(diffs)
}
fn resolve_release_locales(
root: &ReleaseProviderToml,
explicit: Option<&str>,
) -> Result<Vec<String>> {
if let Some(explicit) = explicit {
return parse_locale_list(explicit);
}
let release = active_release(root).context("release.active_release must point to a release")?;
let locales = if release.locales.is_empty() {
root.release
.as_ref()
.map(|release| release.default_locales.clone())
.unwrap_or_default()
} else {
release.locales.clone()
};
if locales.is_empty() {
bail!("active release must declare locales or release.default_locales")
}
Ok(locales)
}
fn active_release(root: &ReleaseProviderToml) -> Option<&ReleaseEntryToml> {
let active = root.release.as_ref()?.active_release.as_deref()?;
root.releases
.iter()
.find(|release| release.id.as_deref() == Some(active))
}
fn parse_locale_list(locales: &str) -> Result<Vec<String>> {
let mut values = locales
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
if values.is_empty() {
bail!("locale list is empty")
}
values.sort();
Ok(values)
}
fn read_release_metadata(project_dir: &Path, relative: &str) -> Result<ReleaseMetadataToml> {
let path = project_dir.join(relative);
let data =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
}
fn read_release_provider_toml(project_dir: &Path) -> Result<ReleaseProviderToml> {
let path = project_dir.join("fission.toml");
let data =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
}
fn microsoft_store_config(project_dir: &Path) -> Result<MicrosoftStoreConfig> {
Ok(read_release_provider_toml(project_dir)?
.distribution
.and_then(|distribution| distribution.microsoft_store)
.unwrap_or_default())
}
fn product_id(cfg: &MicrosoftStoreConfig) -> Result<&str> {
cfg.product_id
.as_deref()
.filter(|value| !value.trim().is_empty())
.context("distribution.microsoft_store.product_id is required")
}
fn seller_id(cfg: &MicrosoftStoreConfig) -> Result<String> {
env_value("MICROSOFT_STORE_SELLER_ID")
.or(cfg.seller_id.clone())
.context("distribution.microsoft_store.seller_id or MICROSOFT_STORE_SELLER_ID is required")
}
fn microsoft_store_access_token(cfg: &MicrosoftStoreConfig, client: &Client) -> Result<String> {
if let Some(token) = env_value("MICROSOFT_STORE_TOKEN") {
return Ok(token);
}
let tenant_id = env_value("AZURE_TENANT_ID")
.or(cfg.tenant_id.clone())
.context("distribution.microsoft_store.tenant_id or AZURE_TENANT_ID is required")?;
let client_id = env_value("AZURE_CLIENT_ID")
.or(cfg.client_id.clone())
.context("distribution.microsoft_store.client_id or AZURE_CLIENT_ID is required")?;
let client_secret = env_value("MICROSOFT_STORE_CLIENT_SECRET")
.or_else(|| {
provider_secret(DistributionProvider::MicrosoftStore, &[])
.ok()
.flatten()
})
.context("MICROSOFT_STORE_CLIENT_SECRET or vault credentials are required")?;
let url = format!("https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token");
let response = client
.post(url)
.form(&[
("grant_type", "client_credentials"),
("client_id", client_id.as_str()),
("client_secret", client_secret.as_str()),
("scope", MICROSOFT_STORE_SCOPE),
])
.send()
.context("failed to request Microsoft Store access token")?;
let token: OAuthTokenResponse = response
.error_for_status()
.context("Microsoft Store access token request failed")?
.json()
.context("failed to parse Microsoft Store access token response")?;
Ok(token.access_token)
}
fn json_response(response: Response, operation: &str) -> Result<Value> {
let status = response.status();
let text = response
.text()
.with_context(|| format!("failed to read {operation} response"))?;
if !status.is_success() {
bail!("{operation} failed with {status}: {text}");
}
if text.trim().is_empty() {
Ok(Value::Null)
} else {
serde_json::from_str(&text)
.with_context(|| format!("failed to parse {operation} JSON response: {text}"))
}
}
fn microsoft_store_success(value: &Value, operation: &str) -> Result<()> {
if value
.get("isSuccess")
.and_then(Value::as_bool)
.unwrap_or(true)
{
Ok(())
} else {
bail!("{operation} returned an unsuccessful response: {value}")
}
}
fn http_client() -> Result<Client> {
Client::builder()
.timeout(Duration::from_secs(300))
.user_agent("cargo-fission-release/0.1")
.build()
.context("failed to build release HTTP client")
}
fn field_string(value: &Value, fields: &[&str]) -> Option<String> {
fields.iter().find_map(|field| {
value
.get(*field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
}
fn field_array(value: &Value, fields: &[&str]) -> Vec<String> {
fields
.iter()
.find_map(|field| value.get(*field).and_then(Value::as_array))
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn set_json_field(value: &mut Value, field: &str, data: Option<String>) {
let Some(object) = value.as_object_mut() else {
return;
};
if let Some(data) = data.filter(|value| !value.trim().is_empty()) {
object.insert(field.to_string(), Value::String(data));
} else {
object.remove(field);
}
}
fn set_json_array(value: &mut Value, field: &str, data: &[String]) {
let Some(object) = value.as_object_mut() else {
return;
};
if data.is_empty() {
object.remove(field);
} else {
object.insert(
field.to_string(),
Value::Array(data.iter().cloned().map(Value::String).collect()),
);
}
}
fn push_option_diff(
diffs: &mut Vec<Value>,
locale: &str,
field: &str,
local: Option<&str>,
remote: Option<&str>,
) {
if local != remote {
diffs.push(json!({
"locale": locale,
"field": field,
"local": local,
"remote": remote
}));
}
}
fn encode_query(value: &str) -> String {
let mut out = String::new();
for byte in value.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b',' => {
out.push(*byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
fn env_value(name: &str) -> Option<String> {
env::var(name).ok().filter(|value| !value.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn microsoft_listing_payload_updates_existing_listing() {
let existing = json!({"language":"en-us","description":"old","keep":"yes"});
let payload = microsoft_listing_payload(
Some(&existing),
&MicrosoftListing {
language: "en-us".to_string(),
title: Some("Todo".to_string()),
short_description: Some("Plan work".to_string()),
description: "A production task app.".to_string(),
keywords: vec!["todo".to_string(), "tasks".to_string()],
privacy_url: Some("https://example.com/privacy".to_string()),
support_url: None,
release_notes: Some("First release".to_string()),
features: vec!["Fast lists".to_string()],
search_terms: vec!["productivity".to_string()],
},
);
assert_eq!(payload["listings"]["language"], "en-us");
assert_eq!(payload["listings"]["description"], "A production task app.");
assert_eq!(payload["listings"]["sortTitle"], "Todo");
assert_eq!(payload["listings"]["keep"], "yes");
assert_eq!(payload["listings"]["keywords"][0], "todo");
}
#[test]
fn microsoft_listing_parser_accepts_response_data_shape() {
let value = json!({
"responseData": {
"listings": [{
"language": "en-us",
"description": "A production app.",
"shortDescription": "Short",
"sortTitle": "Todo",
"privacyPolicyUrl": "https://example.com/privacy",
"whatsNew": "Changed",
"features": ["Fast"],
"searchTerms": ["tasks"]
}]
}
});
let listings = microsoft_listings_from_value(&value).unwrap();
assert_eq!(listings[0].language, "en-us");
assert_eq!(listings[0].title.as_deref(), Some("Todo"));
assert_eq!(listings[0].features, vec!["Fast"]);
assert_eq!(listings[0].search_terms, vec!["tasks"]);
}
}