@@ -191,6 +191,8 @@ impl Input {
191191 anyhow:: bail!( "partition must be non-empty when defined" ) ;
192192 }
193193
194+ self . validate_slurm_header ( ) ?;
195+
194196 match & self . general . execution {
195197 Execution :: Local => {
196198 validate_haddock3 ( ) ?;
@@ -211,6 +213,31 @@ impl Input {
211213
212214 Ok ( ( ) )
213215 }
216+
217+ /// Validates `general.slurm_header` entries: keys must be recognized `sbatch`
218+ /// long-option names (see `SBATCH_FIELDS`), and values must be scalars.
219+ fn validate_slurm_header ( & self ) -> Result < ( ) > {
220+ let Some ( slurm_header) = & self . general . slurm_header else {
221+ return Ok ( ( ) ) ;
222+ } ;
223+
224+ for ( key, value) in slurm_header {
225+ if !SBATCH_FIELDS . contains ( & key. as_str ( ) ) {
226+ bail ! (
227+ "unknown slurm_header field '{key}': not a recognized sbatch option \
228+ (see docs/reference.md or https://slurm.schedmd.com/sbatch.html for valid fields)"
229+ ) ;
230+ }
231+
232+ if matches ! ( value, Value :: Sequence ( _) | Value :: Mapping ( _) ) {
233+ bail ! (
234+ "slurm_header field '{key}' must be a string, number, or boolean, not a list/mapping"
235+ ) ;
236+ }
237+ }
238+
239+ Ok ( ( ) )
240+ }
214241}
215242
216243#[ derive( Debug , Deserialize , Serialize , Clone ) ]
@@ -227,8 +254,122 @@ pub struct General {
227254 pub preprocess : Option < bool > ,
228255 pub postprocess : Option < bool > ,
229256 pub gen_archive : Option < bool > ,
257+ // SBATCH optional header customization
258+ pub slurm_header : Option < IndexMap < String , Value > > ,
230259}
231260
261+ /// Canonical `sbatch` long-option names accepted in `general.slurm_header`.
262+ /// Sourced from https://slurm.schedmd.com/sbatch.html.
263+ ///
264+ /// Note that some options that don't make sense to be sense embedded
265+ /// in a job script header are intentionally excluded.
266+ pub ( crate ) const SBATCH_FIELDS : & [ & str ] = & [
267+ "account" ,
268+ "acctg-freq" ,
269+ "array" ,
270+ "batch" ,
271+ "bb" ,
272+ "bbf" ,
273+ "begin" ,
274+ "chdir" ,
275+ "cluster-constraint" ,
276+ "clusters" ,
277+ "comment" ,
278+ "consolidate-segments" ,
279+ "constraint" ,
280+ "container" ,
281+ "container-id" ,
282+ "container-type" ,
283+ "contiguous" ,
284+ "core-spec" ,
285+ "cores-per-socket" ,
286+ "cpu-freq" ,
287+ "cpus-per-gpu" ,
288+ "cpus-per-task" ,
289+ "deadline" ,
290+ "delay-boot" ,
291+ "dependency" ,
292+ "distribution" ,
293+ "error" ,
294+ "exclude" ,
295+ "exclusive" ,
296+ "export" ,
297+ "export-file" ,
298+ "extra" ,
299+ "extra-node-info" ,
300+ "get-user-env" ,
301+ "gid" ,
302+ "gpu-bind" ,
303+ "gpu-freq" ,
304+ "gpus" ,
305+ "gpus-per-node" ,
306+ "gpus-per-socket" ,
307+ "gpus-per-task" ,
308+ "gres" ,
309+ "gres-flags" ,
310+ "hint" ,
311+ "hold" ,
312+ "ignore-pbs" ,
313+ "input" ,
314+ "job-name" ,
315+ "kill-on-invalid-dep" ,
316+ "licenses" ,
317+ "mail-type" ,
318+ "mail-user" ,
319+ "mcs-label" ,
320+ "mem" ,
321+ "mem-bind" ,
322+ "mem-per-cpu" ,
323+ "mem-per-gpu" ,
324+ "mem-update" ,
325+ "mincpus" ,
326+ "network" ,
327+ "nice" ,
328+ "no-kill" ,
329+ "no-requeue" ,
330+ "nodefile" ,
331+ "nodelist" ,
332+ "nodes" ,
333+ "ntasks" ,
334+ "ntasks-per-core" ,
335+ "ntasks-per-gpu" ,
336+ "ntasks-per-node" ,
337+ "ntasks-per-socket" ,
338+ "oom-kill-step" ,
339+ "open-mode" ,
340+ "output" ,
341+ "overcommit" ,
342+ "oversubscribe" ,
343+ "parsable" ,
344+ "partition" ,
345+ "prefer" ,
346+ "priority" ,
347+ "profile" ,
348+ "propagate" ,
349+ "qos" ,
350+ "reboot" ,
351+ "requeue" ,
352+ "reservation" ,
353+ "resources" ,
354+ "resv-ports" ,
355+ "segment" ,
356+ "signal" ,
357+ "sockets-per-node" ,
358+ "spread-job" ,
359+ "spread-segments" ,
360+ "stepmgr" ,
361+ "switches" ,
362+ "thread-spec" ,
363+ "threads-per-core" ,
364+ "time" ,
365+ "time-min" ,
366+ "tmp" ,
367+ "tres-bind" ,
368+ "uid" ,
369+ "wait" ,
370+ "wckey" ,
371+ ] ;
372+
232373#[ derive( Debug , Deserialize , Serialize , Clone ) ]
233374#[ serde( rename_all = "lowercase" ) ]
234375pub enum Execution {
@@ -343,6 +484,7 @@ mod tests {
343484 preprocess : None ,
344485 postprocess : None ,
345486 gen_archive : None ,
487+ slurm_header : None ,
346488 } ,
347489 scenarios : vec ! [ ] ,
348490 } ;
@@ -367,6 +509,7 @@ mod tests {
367509 preprocess : None ,
368510 postprocess : None ,
369511 gen_archive : None ,
512+ slurm_header : None ,
370513 } ,
371514 scenarios : vec ! [ ] ,
372515 } ;
@@ -391,6 +534,7 @@ mod tests {
391534 preprocess : None ,
392535 postprocess : None ,
393536 gen_archive : None ,
537+ slurm_header : None ,
394538 } ,
395539 scenarios : vec ! [ ] ,
396540 } ;
@@ -415,6 +559,7 @@ mod tests {
415559 preprocess : None ,
416560 postprocess : None ,
417561 gen_archive : None ,
562+ slurm_header : None ,
418563 } ,
419564 scenarios : vec ! [ ] ,
420565 } ;
@@ -440,6 +585,7 @@ mod tests {
440585 preprocess : None ,
441586 postprocess : None ,
442587 gen_archive : None ,
588+ slurm_header : None ,
443589 } ,
444590 scenarios : vec ! [ ] ,
445591 } ;
@@ -464,6 +610,7 @@ mod tests {
464610 preprocess : None ,
465611 postprocess : None ,
466612 gen_archive : None ,
613+ slurm_header : None ,
467614 } ,
468615 scenarios : vec ! [ ] ,
469616 } ;
@@ -487,6 +634,7 @@ mod tests {
487634 preprocess : None ,
488635 postprocess : None ,
489636 gen_archive : None ,
637+ slurm_header : None ,
490638 } ,
491639 scenarios : vec ! [ ] ,
492640 } ;
@@ -499,6 +647,85 @@ mod tests {
499647 ) ;
500648 }
501649
650+ fn make_input_with_slurm_header ( slurm_header : IndexMap < String , Value > ) -> Input {
651+ Input {
652+ general : General {
653+ mol_suffixes : vec ! [ "_r" . to_string( ) , "_l" . to_string( ) ] ,
654+ input_list : "test.txt" . to_string ( ) ,
655+ work_dir : PathBuf :: from ( "/tmp" ) ,
656+ max_concurrent : 1 ,
657+ ncores : 1 ,
658+ execution : Execution :: Local ,
659+ partition : None ,
660+ preprocess : None ,
661+ postprocess : None ,
662+ gen_archive : None ,
663+ slurm_header : Some ( slurm_header) ,
664+ } ,
665+ scenarios : vec ! [ ] ,
666+ }
667+ }
668+
669+ #[ test]
670+ fn test_validate_slurm_header_unknown_field_is_error ( ) {
671+ let mut slurm_header = IndexMap :: new ( ) ;
672+ slurm_header. insert ( "partiton" . to_string ( ) , Value :: String ( "gpu" . to_string ( ) ) ) ;
673+ let input = make_input_with_slurm_header ( slurm_header) ;
674+
675+ let result = input. validate_general ( ) ;
676+ assert ! ( result. is_err( ) ) ;
677+ assert ! (
678+ result. unwrap_err( ) . to_string( ) . contains( "partiton" ) ,
679+ "error should mention the offending key"
680+ ) ;
681+ }
682+
683+ #[ test]
684+ fn test_validate_slurm_header_known_field_is_ok ( ) {
685+ let mut slurm_header = IndexMap :: new ( ) ;
686+ slurm_header. insert ( "nodes" . to_string ( ) , Value :: Number ( 2 . into ( ) ) ) ;
687+ slurm_header. insert ( "account" . to_string ( ) , Value :: String ( "proj" . to_string ( ) ) ) ;
688+ let input = make_input_with_slurm_header ( slurm_header) ;
689+
690+ assert ! ( input. validate_slurm_header( ) . is_ok( ) ) ;
691+ }
692+
693+ #[ test]
694+ fn test_validate_slurm_header_sequence_value_is_error ( ) {
695+ let mut slurm_header = IndexMap :: new ( ) ;
696+ slurm_header. insert (
697+ "nodes" . to_string ( ) ,
698+ Value :: Sequence ( vec ! [ Value :: Number ( 1 . into( ) ) ] ) ,
699+ ) ;
700+ let input = make_input_with_slurm_header ( slurm_header) ;
701+
702+ let result = input. validate_general ( ) ;
703+ assert ! ( result. is_err( ) ) ;
704+ }
705+
706+ #[ test]
707+ fn test_validate_slurm_header_mapping_value_is_error ( ) {
708+ let mut slurm_header = IndexMap :: new ( ) ;
709+ let mut mapping = serde_yaml:: Mapping :: new ( ) ;
710+ mapping. insert ( Value :: String ( "a" . to_string ( ) ) , Value :: Bool ( true ) ) ;
711+ slurm_header. insert ( "nodes" . to_string ( ) , Value :: Mapping ( mapping) ) ;
712+ let input = make_input_with_slurm_header ( slurm_header) ;
713+
714+ let result = input. validate_general ( ) ;
715+ assert ! ( result. is_err( ) ) ;
716+ }
717+
718+ #[ test]
719+ fn test_validate_slurm_header_null_and_empty_values_are_ok ( ) {
720+ let mut slurm_header = IndexMap :: new ( ) ;
721+ slurm_header. insert ( "partition" . to_string ( ) , Value :: Null ) ;
722+ slurm_header. insert ( "qos" . to_string ( ) , Value :: String ( String :: new ( ) ) ) ;
723+ slurm_header. insert ( "exclusive" . to_string ( ) , Value :: Bool ( false ) ) ;
724+ let input = make_input_with_slurm_header ( slurm_header) ;
725+
726+ assert ! ( input. validate_slurm_header( ) . is_ok( ) ) ;
727+ }
728+
502729 #[ test]
503730 fn test_input_deserialize_unknown_top_level_field ( ) {
504731 let yaml = r#"
@@ -713,6 +940,7 @@ scenarios:
713940 preprocess : None ,
714941 postprocess : None ,
715942 gen_archive : None ,
943+ slurm_header : None ,
716944 } ,
717945 scenarios : vec ! [ Scenario {
718946 name: "test" . to_string( ) ,
0 commit comments