@@ -27,9 +27,9 @@ pub struct Artifact {
2727 pub name : String ,
2828 pub output : String ,
2929 /// Direct download URL (mutually exclusive with `sotpath`).
30- // TODO[#416]: enforce exactly one of `uri`/`sotpath` is set - add a
31- // post-deserialization validation step in Scenario::load or a custom
32- // Deserialize impl. Currently both can be set (or neither) without error .
30+ ///
31+ /// Exactly one of `uri`/`sotpath` must be set; enforced in
32+ /// `Scenario::load` after deserialization .
3333 pub uri : Option < String > ,
3434 /// JSONPath into SOT JSON to resolve download URL.
3535 pub sotpath : Option < String > ,
@@ -74,7 +74,33 @@ impl Scenario {
7474 pub fn load ( path : & Path ) -> Result < Self , String > {
7575 let content =
7676 std:: fs:: read_to_string ( path) . map_err ( |e| format ! ( "read {}: {e}" , path. display( ) ) ) ?;
77- toml:: from_str ( & content) . map_err ( |e| format ! ( "parse {}: {e}" , path. display( ) ) )
77+ let scenario: Scenario =
78+ toml:: from_str ( & content) . map_err ( |e| format ! ( "parse {}: {e}" , path. display( ) ) ) ?;
79+ scenario
80+ . validate ( )
81+ . map_err ( |e| format ! ( "validate {}: {e}" , path. display( ) ) ) ?;
82+ Ok ( scenario)
83+ }
84+
85+ fn validate ( & self ) -> Result < ( ) , String > {
86+ for artifact in & self . artifacts {
87+ match ( & artifact. uri , & artifact. sotpath ) {
88+ ( Some ( _) , Some ( _) ) => {
89+ return Err ( format ! (
90+ "artifact '{}': both 'uri' and 'sotpath' set; exactly one required" ,
91+ artifact. name
92+ ) ) ;
93+ }
94+ ( None , None ) => {
95+ return Err ( format ! (
96+ "artifact '{}': neither 'uri' nor 'sotpath' set; exactly one required" ,
97+ artifact. name
98+ ) ) ;
99+ }
100+ _ => { }
101+ }
102+ }
103+ Ok ( ( ) )
78104 }
79105}
80106
@@ -96,4 +122,55 @@ mod tests {
96122 assert_eq ! ( scenario. teardown. len( ) , 1 ) ;
97123 assert_eq ! ( scenario. test[ 0 ] . name, "nv_basic" ) ;
98124 }
125+
126+ fn scenario_with_artifacts ( artifacts : Vec < Artifact > ) -> Scenario {
127+ Scenario {
128+ rack : RackTarget {
129+ model : "gb200nvl" . to_string ( ) ,
130+ sot_release : "1.2.5" . to_string ( ) ,
131+ } ,
132+ os : OsImage { uri : "https://example.com/os.img" . to_string ( ) } ,
133+ artifacts,
134+ setup : vec ! [ ] ,
135+ test : vec ! [ ] ,
136+ teardown : vec ! [ ] ,
137+ }
138+ }
139+
140+ fn artifact ( name : & str , uri : Option < & str > , sotpath : Option < & str > ) -> Artifact {
141+ Artifact {
142+ name : name. to_string ( ) ,
143+ output : format ! ( "{name}.bin" ) ,
144+ uri : uri. map ( str:: to_string) ,
145+ sotpath : sotpath. map ( str:: to_string) ,
146+ }
147+ }
148+
149+ #[ test]
150+ fn validate_accepts_uri_only ( ) {
151+ let s = scenario_with_artifacts ( vec ! [ artifact( "a" , Some ( "https://x/y" ) , None ) ] ) ;
152+ assert ! ( s. validate( ) . is_ok( ) ) ;
153+ }
154+
155+ #[ test]
156+ fn validate_accepts_sotpath_only ( ) {
157+ let s = scenario_with_artifacts ( vec ! [ artifact( "a" , None , Some ( "$.foo" ) ) ] ) ;
158+ assert ! ( s. validate( ) . is_ok( ) ) ;
159+ }
160+
161+ #[ test]
162+ fn validate_rejects_both_uri_and_sotpath ( ) {
163+ let s = scenario_with_artifacts ( vec ! [ artifact( "a" , Some ( "https://x/y" ) , Some ( "$.foo" ) ) ] ) ;
164+ let err = s. validate ( ) . unwrap_err ( ) ;
165+ assert ! ( err. contains( "both" ) , "got: {err}" ) ;
166+ assert ! ( err. contains( "'a'" ) , "got: {err}" ) ;
167+ }
168+
169+ #[ test]
170+ fn validate_rejects_neither_uri_nor_sotpath ( ) {
171+ let s = scenario_with_artifacts ( vec ! [ artifact( "a" , None , None ) ] ) ;
172+ let err = s. validate ( ) . unwrap_err ( ) ;
173+ assert ! ( err. contains( "neither" ) , "got: {err}" ) ;
174+ assert ! ( err. contains( "'a'" ) , "got: {err}" ) ;
175+ }
99176}
0 commit comments