diff --git a/src/api/data_types/snapshots.rs b/src/api/data_types/snapshots.rs index 40d73354c6..d8205df8e2 100644 --- a/src/api/data_types/snapshots.rs +++ b/src/api/data_types/snapshots.rs @@ -3,6 +3,11 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use serde_json::Value; + +const IMAGE_FILE_NAME_FIELD: &str = "image_file_name"; +const WIDTH_FIELD: &str = "width"; +const HEIGHT_FIELD: &str = "height"; /// Response from the create snapshot endpoint. #[derive(Debug, Deserialize)] @@ -22,9 +27,61 @@ pub struct SnapshotsManifest { // Keep in sync with https://github.com/getsentry/sentry/blob/master/src/sentry/preprod/snapshots/manifest.py /// Metadata for a single image in a snapshot manifest. +/// +/// Serializes as a flat JSON object. +/// +/// CLI-managed fields (`image_file_name`, `width`, `height`) override any +/// identically named fields provided by user sidecar metadata. #[derive(Debug, Serialize)] pub struct ImageMetadata { - pub image_file_name: String, - pub width: u32, - pub height: u32, + #[serde(flatten)] + data: HashMap, +} + +impl ImageMetadata { + pub fn new( + image_file_name: String, + width: u32, + height: u32, + mut extra: HashMap, + ) -> Self { + extra.insert( + IMAGE_FILE_NAME_FIELD.to_owned(), + Value::String(image_file_name), + ); + extra.insert(WIDTH_FIELD.to_owned(), Value::from(width)); + extra.insert(HEIGHT_FIELD.to_owned(), Value::from(height)); + + Self { data: extra } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use serde_json::json; + + #[test] + fn cli_managed_fields_override_sidecar_fields() { + let extra = serde_json::from_value(json!({ + (IMAGE_FILE_NAME_FIELD): "from-sidecar.png", + (WIDTH_FIELD): 1, + (HEIGHT_FIELD): 2, + "custom": "keep-me" + })) + .unwrap(); + + let metadata = ImageMetadata::new("from-cli.png".to_owned(), 100, 200, extra); + let serialized = serde_json::to_value(metadata).unwrap(); + + let expected = json!({ + (IMAGE_FILE_NAME_FIELD): "from-cli.png", + (WIDTH_FIELD): 100, + (HEIGHT_FIELD): 200, + "custom": "keep-me" + }); + + assert_eq!(serialized, expected); + } } diff --git a/src/commands/build/snapshots.rs b/src/commands/build/snapshots.rs index 2ded836073..261a87636a 100644 --- a/src/commands/build/snapshots.rs +++ b/src/commands/build/snapshots.rs @@ -95,6 +95,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { style(images.len()).yellow(), if images.len() == 1 { "file" } else { "files" } ); + let manifest_entries = upload_images(images, &org, &project)?; // Build manifest from discovered images @@ -188,6 +189,40 @@ fn is_image_file(path: &Path) -> bool { .unwrap_or(false) } +/// Reads the companion JSON sidecar for an image, if it exists. +/// +/// For an image at `path/to/button.png`, looks for `path/to/button.json`. +/// Returns a map of all key-value pairs from the JSON file. +fn read_sidecar_metadata(image_path: &Path) -> HashMap { + let sidecar_path = image_path.with_extension("json"); + if !sidecar_path.is_file() { + return HashMap::new(); + } + + debug!("Reading sidecar metadata: {}", sidecar_path.display()); + let contents = match fs::read_to_string(&sidecar_path) { + Ok(c) => c, + Err(err) => { + warn!( + "Failed to read sidecar file {}: {err}", + sidecar_path.display() + ); + return HashMap::new(); + } + }; + + match serde_json::from_str(&contents) { + Ok(map) => map, + Err(err) => { + warn!( + "Failed to parse sidecar file {}: {err}", + sidecar_path.display() + ); + HashMap::new() + } + } +} + fn upload_images( images: Vec, org: &str, @@ -247,13 +282,12 @@ fn upload_images( .unwrap_or_default() .to_string_lossy() .into_owned(); + + let extra = read_sidecar_metadata(&image.path); + manifest_entries.insert( hash, - ImageMetadata { - image_file_name, - width: image.width, - height: image.height, - }, + ImageMetadata::new(image_file_name, image.width, image.height, extra), ); }