@@ -25,6 +25,14 @@ pub struct SourcemapEntry {
2525 pub sourcemap : String ,
2626}
2727
28+ #[ derive( Debug , Deserialize ) ]
29+ pub struct ProguardEntry {
30+ #[ serde( alias = "fileName" ) ]
31+ pub file_name : String ,
32+ #[ serde( alias = "content" ) ]
33+ pub mapping : String ,
34+ }
35+
2836#[ derive( Debug , Deserialize ) ]
2937#[ serde( tag = "mappingType" ) ]
3038pub enum IngestPayload {
@@ -43,7 +51,13 @@ pub enum IngestPayload {
4351 build_id : String ,
4452 #[ serde( alias = "uploadedAt" ) ]
4553 uploaded_at : String ,
46- mapping : String ,
54+ #[ serde( alias = "fileName" ) ]
55+ file_name : Option < String > ,
56+ mapping : Option < String > ,
57+ #[ serde( default ) ]
58+ mappings : Vec < ProguardEntry > ,
59+ #[ serde( default ) ]
60+ files : Vec < ProguardEntry > ,
4761 } ,
4862}
4963
@@ -191,17 +205,34 @@ pub async fn ingest(
191205 build_id,
192206 uploaded_at,
193207 mapping,
208+ file_name,
209+ mappings,
210+ files,
194211 } => {
195- require_non_empty ( "build_id" , build_id) ?;
196- require_non_empty ( "uploaded_at" , uploaded_at) ?;
197- require_non_empty ( "mapping" , mapping) ?;
198- crate :: mappings:: proguard:: ingest ( & state. storage , project_id, build_id, mapping)
212+ let mappings = normalize_proguard_entries (
213+ build_id,
214+ uploaded_at,
215+ file_name,
216+ mapping,
217+ mappings,
218+ files,
219+ ) ?;
220+ crate :: mappings:: proguard:: ingest ( & state. storage , project_id, build_id, & mappings)
199221 . await ?;
200- let total_bytes = mapping. len ( ) ;
222+ let total_bytes: usize = mappings. iter ( ) . map ( |( _, mapping) | mapping. len ( ) ) . sum ( ) ;
223+ let file_names: Vec < & str > = mappings
224+ . iter ( )
225+ . map ( |( file_name, _) | file_name. as_str ( ) )
226+ . collect ( ) ;
227+ info ! (
228+ %project_id,
229+ build_id,
230+ files = ?file_names,
231+ ) ;
201232 (
202233 build_id. as_str ( ) ,
203234 uploaded_at. as_str ( ) ,
204- 1 ,
235+ mappings . len ( ) ,
205236 total_bytes,
206237 "proguard" ,
207238 )
@@ -412,6 +443,54 @@ fn validate_js_ingest(
412443 Ok ( ( ) )
413444}
414445
446+ fn normalize_proguard_entries (
447+ build_id : & str ,
448+ uploaded_at : & str ,
449+ file_name : & Option < String > ,
450+ mapping : & Option < String > ,
451+ mappings : & [ ProguardEntry ] ,
452+ files : & [ ProguardEntry ] ,
453+ ) -> Result < Vec < ( String , String ) > , AppError > {
454+ require_non_empty ( "build_id" , build_id) ?;
455+ require_non_empty ( "uploaded_at" , uploaded_at) ?;
456+
457+ let mut normalized =
458+ Vec :: with_capacity ( mappings. len ( ) + files. len ( ) + usize:: from ( mapping. is_some ( ) ) ) ;
459+ normalized. extend (
460+ mappings
461+ . iter ( )
462+ . map ( |entry| ( entry. file_name . clone ( ) , entry. mapping . clone ( ) ) ) ,
463+ ) ;
464+ normalized. extend (
465+ files
466+ . iter ( )
467+ . map ( |entry| ( entry. file_name . clone ( ) , entry. mapping . clone ( ) ) ) ,
468+ ) ;
469+
470+ match ( file_name. as_deref ( ) , mapping. as_deref ( ) ) {
471+ ( Some ( file_name) , Some ( mapping) ) => {
472+ normalized. push ( ( file_name. to_string ( ) , mapping. to_string ( ) ) ) ;
473+ }
474+ ( Some ( _) , None ) => return Err ( AppError :: BadRequest ( "mapping is required" . into ( ) ) ) ,
475+ ( None , Some ( mapping) ) if normalized. is_empty ( ) => {
476+ normalized. push ( ( "mapping.txt" . to_string ( ) , mapping. to_string ( ) ) ) ;
477+ }
478+ ( None , Some ( _) ) => return Err ( AppError :: BadRequest ( "file_name is required" . into ( ) ) ) ,
479+ ( None , None ) => { }
480+ }
481+
482+ if normalized. is_empty ( ) {
483+ return Err ( AppError :: BadRequest ( "no proguard mappings provided" . into ( ) ) ) ;
484+ }
485+
486+ for ( file_name, mapping) in & normalized {
487+ require_non_empty ( "file_name" , file_name) ?;
488+ require_non_empty ( "mapping" , mapping) ?;
489+ }
490+
491+ Ok ( normalized)
492+ }
493+
415494fn parse_sourcemap_key ( key : & str ) -> Option < ( String , String ) > {
416495 let mut parts = key. splitn ( 3 , '/' ) ;
417496 let _project_id = parts. next ( ) ?;
@@ -472,7 +551,10 @@ fn select_builds_for_cleanup(
472551
473552#[ cfg( test) ]
474553mod tests {
475- use super :: { normalized_build_ids, parse_sourcemap_key, select_builds_for_cleanup} ;
554+ use super :: {
555+ ProguardEntry , normalize_proguard_entries, normalized_build_ids, parse_sourcemap_key,
556+ select_builds_for_cleanup,
557+ } ;
476558 use crate :: mappings:: { javascript:: map_file_name, require_non_empty} ;
477559 use crate :: storage:: StoredObjectMeta ;
478560
@@ -491,6 +573,15 @@ mod tests {
491573 ) ;
492574 }
493575
576+ #[ test]
577+ fn parse_sourcemap_key_keeps_nested_proguard_file_name ( ) {
578+ let parsed = parse_sourcemap_key ( "proj-id/build-42/proguard/base.txt" ) ;
579+ assert_eq ! (
580+ parsed,
581+ Some ( ( "build-42" . to_string( ) , "proguard/base.txt" . to_string( ) ) )
582+ ) ;
583+ }
584+
494585 #[ test]
495586 fn require_non_empty_rejects_whitespace ( ) {
496587 let err = require_non_empty ( "build_id" , " " ) . expect_err ( "value should be invalid" ) ;
@@ -512,6 +603,69 @@ mod tests {
512603 ) ;
513604 }
514605
606+ #[ test]
607+ fn normalize_proguard_entries_accepts_multiple_named_files ( ) {
608+ let normalized = normalize_proguard_entries (
609+ "build-1" ,
610+ "2026-03-22T00:00:00Z" ,
611+ & None ,
612+ & None ,
613+ & [ ProguardEntry {
614+ file_name : "base.txt" . to_string ( ) ,
615+ mapping : "one" . to_string ( ) ,
616+ } ] ,
617+ & [ ProguardEntry {
618+ file_name : "feature.txt" . to_string ( ) ,
619+ mapping : "two" . to_string ( ) ,
620+ } ] ,
621+ )
622+ . expect ( "entries should normalize" ) ;
623+
624+ assert_eq ! (
625+ normalized,
626+ vec![
627+ ( "base.txt" . to_string( ) , "one" . to_string( ) ) ,
628+ ( "feature.txt" . to_string( ) , "two" . to_string( ) ) ,
629+ ]
630+ ) ;
631+ }
632+
633+ #[ test]
634+ fn normalize_proguard_entries_keeps_legacy_single_mapping_compatible ( ) {
635+ let normalized = normalize_proguard_entries (
636+ "build-1" ,
637+ "2026-03-22T00:00:00Z" ,
638+ & None ,
639+ & Some ( "contents" . to_string ( ) ) ,
640+ & [ ] ,
641+ & [ ] ,
642+ )
643+ . expect ( "legacy payload should normalize" ) ;
644+
645+ assert_eq ! (
646+ normalized,
647+ vec![ ( "mapping.txt" . to_string( ) , "contents" . to_string( ) ) ]
648+ ) ;
649+ }
650+
651+ #[ test]
652+ fn normalize_proguard_entries_rejects_mixed_unnamed_mapping ( ) {
653+ let err = normalize_proguard_entries (
654+ "build-1" ,
655+ "2026-03-22T00:00:00Z" ,
656+ & None ,
657+ & Some ( "contents" . to_string ( ) ) ,
658+ & [ ProguardEntry {
659+ file_name : "base.txt" . to_string ( ) ,
660+ mapping : "one" . to_string ( ) ,
661+ } ] ,
662+ & [ ] ,
663+ )
664+ . expect_err ( "mixed unnamed mapping should be rejected" ) ;
665+
666+ assert ! ( format!( "{err}" ) . contains( "file_name is required" ) ) ;
667+ }
668+
515669 #[ test]
516670 fn select_builds_for_cleanup_keeps_latest_and_excluded ( ) {
517671 let objects = vec ! [
0 commit comments