@@ -9,7 +9,13 @@ use crate::format_engine;
99
1010/// View information about jobs in the scheduling queue.
1111#[ derive( Parser , Debug ) ]
12- #[ command( name = "squeue" , about = "View the job queue" ) ]
12+ // -h is squeue's --noheader (Slurm convention), so disable clap's auto -h and
13+ // re-add --help below as long-only.
14+ #[ command(
15+ name = "squeue" ,
16+ about = "View the job queue" ,
17+ disable_help_flag = true
18+ ) ]
1319pub struct SqueueArgs {
1420 /// Show only jobs for this user
1521 #[ arg( short = 'u' , long) ]
@@ -47,8 +53,12 @@ pub struct SqueueArgs {
4753 #[ arg( short = 'h' , long) ]
4854 pub noheader : bool ,
4955
50- /// Sort by field
51- #[ arg( short = 'S' , long) ]
56+ /// Print help
57+ #[ arg( long, action = clap:: ArgAction :: Help ) ]
58+ pub help : Option < bool > ,
59+
60+ /// Sort by field(s), comma-separated, each optionally prefixed with + (asc) or - (desc)
61+ #[ arg( short = 'S' , long, allow_hyphen_values = true ) ]
5262 pub sort : Option < String > ,
5363
5464 /// Controller address
@@ -84,6 +94,13 @@ pub async fn main_with_args(args: Vec<String>) -> Result<()> {
8494 None => default_squeue_states ( ) ,
8595 } ;
8696
97+ // Parse the sort spec before any network I/O so an invalid -S surfaces its own error
98+ // rather than a downstream connect failure.
99+ let sort_keys = match args. sort . as_deref ( ) {
100+ Some ( s) => parse_sort_arg ( s) ?,
101+ None => default_sort_keys ( ) ,
102+ } ;
103+
87104 // Parse job ID filter
88105 let job_ids = args
89106 . jobs
@@ -113,7 +130,8 @@ pub async fn main_with_args(args: Vec<String>) -> Result<()> {
113130 . await
114131 . context ( "failed to get jobs" ) ?;
115132
116- let jobs = response. into_inner ( ) . jobs ;
133+ let mut jobs = response. into_inner ( ) . jobs ;
134+ sort_jobs ( & mut jobs, & sort_keys) ;
117135
118136 // Print header
119137 if !args. noheader {
@@ -173,6 +191,132 @@ fn resolve_job_field(job: &spur_proto::proto::JobInfo, spec: char) -> String {
173191 }
174192}
175193
194+ /// One field in a sort spec, with direction.
195+ #[ derive( Debug , Clone , Copy , PartialEq ) ]
196+ struct SortKey {
197+ spec : char ,
198+ descending : bool ,
199+ }
200+
201+ /// Slurm's documented default job sort: partition asc, state asc, priority desc.
202+ /// A trailing jobid asc keeps equal-priority jobs deterministically ordered.
203+ fn default_sort_keys ( ) -> Vec < SortKey > {
204+ vec ! [
205+ SortKey {
206+ spec: 'P' ,
207+ descending: false ,
208+ } ,
209+ SortKey {
210+ spec: 't' ,
211+ descending: false ,
212+ } ,
213+ SortKey {
214+ spec: 'p' ,
215+ descending: true ,
216+ } ,
217+ SortKey {
218+ spec: 'i' ,
219+ descending: false ,
220+ } ,
221+ ]
222+ }
223+
224+ /// Parse `-S` / `--sort`: comma-separated fields, each optionally prefixed with
225+ /// `+` (asc, default) or `-` (desc). Reuses the format-spec letters.
226+ fn parse_sort_arg ( s : & str ) -> Result < Vec < SortKey > > {
227+ let mut keys = Vec :: new ( ) ;
228+ for token in s. split ( ',' ) . map ( str:: trim) . filter ( |t| !t. is_empty ( ) ) {
229+ let ( descending, rest) = match token. strip_prefix ( '-' ) {
230+ Some ( r) => ( true , r) ,
231+ None => ( false , token. strip_prefix ( '+' ) . unwrap_or ( token) ) ,
232+ } ;
233+ let mut chars = rest. chars ( ) ;
234+ let spec = match ( chars. next ( ) , chars. next ( ) ) {
235+ ( Some ( c) , None ) => c,
236+ _ => anyhow:: bail!( "Invalid sort specification: {token}" ) ,
237+ } ;
238+ if format_engine:: squeue_header ( spec) == "?" {
239+ anyhow:: bail!( "Invalid sort specification: {token}" ) ;
240+ }
241+ keys. push ( SortKey { spec, descending } ) ;
242+ }
243+ if keys. is_empty ( ) {
244+ anyhow:: bail!( "Invalid sort specification: (empty)" ) ;
245+ }
246+ Ok ( keys)
247+ }
248+
249+ fn sort_jobs ( jobs : & mut [ spur_proto:: proto:: JobInfo ] , keys : & [ SortKey ] ) {
250+ jobs. sort_by ( |a, b| {
251+ for key in keys {
252+ let ord = compare_field ( a, b, key. spec ) ;
253+ let ord = if key. descending { ord. reverse ( ) } else { ord } ;
254+ if ord != std:: cmp:: Ordering :: Equal {
255+ return ord;
256+ }
257+ }
258+ std:: cmp:: Ordering :: Equal
259+ } ) ;
260+ }
261+
262+ /// Compare two jobs on a single field. Numeric fields compare numerically;
263+ /// timestamps by epoch seconds; everything else lexically on the rendered value.
264+ fn compare_field (
265+ a : & spur_proto:: proto:: JobInfo ,
266+ b : & spur_proto:: proto:: JobInfo ,
267+ spec : char ,
268+ ) -> std:: cmp:: Ordering {
269+ match spec {
270+ 'i' | 'A' => a. job_id . cmp ( & b. job_id ) ,
271+ 'p' => a. priority . cmp ( & b. priority ) ,
272+ 'D' => a. num_nodes . cmp ( & b. num_nodes ) ,
273+ 'C' => a. cpus_per_task . cmp ( & b. cpus_per_task ) ,
274+ 't' | 'T' => state_sort_rank ( a. state ) . cmp ( & state_sort_rank ( b. state ) ) ,
275+ 'M' => run_time_secs ( a) . cmp ( & run_time_secs ( b) ) ,
276+ 'l' => time_limit_secs ( a) . cmp ( & time_limit_secs ( b) ) ,
277+ 'L' => time_left_secs ( a) . cmp ( & time_left_secs ( b) ) ,
278+ 'S' => ts_secs ( a. start_time . as_ref ( ) ) . cmp ( & ts_secs ( b. start_time . as_ref ( ) ) ) ,
279+ 'V' => ts_secs ( a. submit_time . as_ref ( ) ) . cmp ( & ts_secs ( b. submit_time . as_ref ( ) ) ) ,
280+ 'e' => ts_secs ( a. end_time . as_ref ( ) ) . cmp ( & ts_secs ( b. end_time . as_ref ( ) ) ) ,
281+ 'P' => a. partition . cmp ( & b. partition ) ,
282+ 'u' => a. user . cmp ( & b. user ) ,
283+ 'j' | 'n' => a. name . cmp ( & b. name ) ,
284+ 'a' => a. account . cmp ( & b. account ) ,
285+ 'q' => a. qos . cmp ( & b. qos ) ,
286+ _ => resolve_job_field ( a, spec) . cmp ( & resolve_job_field ( b, spec) ) ,
287+ }
288+ }
289+
290+ fn state_sort_rank ( state : i32 ) -> u8 {
291+ spur_core:: job:: JobState :: from_proto_i32 ( state)
292+ . map ( |s| s. sort_rank ( ) )
293+ . unwrap_or ( u8:: MAX )
294+ }
295+
296+ fn run_time_secs ( job : & spur_proto:: proto:: JobInfo ) -> i64 {
297+ job. run_time . as_ref ( ) . map ( |d| d. seconds ) . unwrap_or ( 0 )
298+ }
299+
300+ fn time_limit_secs ( job : & spur_proto:: proto:: JobInfo ) -> i64 {
301+ job. time_limit
302+ . as_ref ( )
303+ . map ( |d| d. seconds )
304+ . unwrap_or ( i64:: MAX )
305+ }
306+
307+ fn ts_secs ( ts : Option < & prost_types:: Timestamp > ) -> i64 {
308+ ts. map ( |t| t. seconds ) . unwrap_or ( 0 )
309+ }
310+
311+ /// Unlimited time limit sorts last, matching `-S L`.
312+ fn time_left_secs ( job : & spur_proto:: proto:: JobInfo ) -> i64 {
313+ let limit = time_limit_secs ( job) ;
314+ if limit == i64:: MAX {
315+ return i64:: MAX ;
316+ }
317+ limit. saturating_sub ( run_time_secs ( job) )
318+ }
319+
176320fn state_code ( state : i32 ) -> String {
177321 spur_core:: job:: JobState :: from_proto_i32 ( state)
178322 . map ( |s| s. code ( ) . to_string ( ) )
@@ -273,6 +417,26 @@ mod tests {
273417 use super :: * ;
274418 use spur_proto:: proto:: JobState as P ;
275419
420+ #[ test]
421+ fn sort_flag_accepts_hyphen_prefixed_value ( ) {
422+ // -S -i must parse as a descending sort spec, not a flag (allow_hyphen_values).
423+ let args = SqueueArgs :: try_parse_from ( [ "squeue" , "-S" , "-i" ] ) . unwrap ( ) ;
424+ assert_eq ! ( args. sort. as_deref( ) , Some ( "-i" ) ) ;
425+ }
426+
427+ #[ test]
428+ fn long_help_flag_is_preserved ( ) {
429+ // -h is reclaimed for --noheader, but --help must still print help.
430+ let err = SqueueArgs :: try_parse_from ( [ "squeue" , "--help" ] ) . unwrap_err ( ) ;
431+ assert_eq ! ( err. kind( ) , clap:: error:: ErrorKind :: DisplayHelp ) ;
432+ }
433+
434+ #[ test]
435+ fn short_h_is_noheader_not_help ( ) {
436+ let args = SqueueArgs :: try_parse_from ( [ "squeue" , "-h" ] ) . unwrap ( ) ;
437+ assert ! ( args. noheader) ;
438+ }
439+
276440 #[ test]
277441 fn default_squeue_states_includes_completing ( ) {
278442 let states = default_squeue_states ( ) ;
@@ -311,4 +475,181 @@ mod tests {
311475 assert ! ( parse_states_arg( "" ) . is_err( ) ) ;
312476 assert ! ( parse_states_arg( " , " ) . is_err( ) ) ;
313477 }
478+
479+ fn job ( id : u32 , partition : & str , state : P , priority : u32 ) -> spur_proto:: proto:: JobInfo {
480+ spur_proto:: proto:: JobInfo {
481+ job_id : id,
482+ partition : partition. into ( ) ,
483+ state : state as i32 ,
484+ priority,
485+ ..Default :: default ( )
486+ }
487+ }
488+
489+ fn ids ( jobs : & [ spur_proto:: proto:: JobInfo ] ) -> Vec < u32 > {
490+ jobs. iter ( ) . map ( |j| j. job_id ) . collect ( )
491+ }
492+
493+ #[ test]
494+ fn parse_sort_arg_directions ( ) {
495+ let keys = parse_sort_arg ( "P,-p,+i" ) . unwrap ( ) ;
496+ assert_eq ! ( keys. len( ) , 3 ) ;
497+ assert_eq ! (
498+ keys[ 0 ] ,
499+ SortKey {
500+ spec: 'P' ,
501+ descending: false
502+ }
503+ ) ;
504+ assert_eq ! (
505+ keys[ 1 ] ,
506+ SortKey {
507+ spec: 'p' ,
508+ descending: true
509+ }
510+ ) ;
511+ assert_eq ! (
512+ keys[ 2 ] ,
513+ SortKey {
514+ spec: 'i' ,
515+ descending: false
516+ }
517+ ) ;
518+ }
519+
520+ #[ test]
521+ fn parse_sort_arg_rejects_bad_tokens ( ) {
522+ assert ! ( parse_sort_arg( "" ) . is_err( ) ) ;
523+ assert ! ( parse_sort_arg( "ii" ) . is_err( ) ) ;
524+ assert ! ( parse_sort_arg( "-" ) . is_err( ) ) ;
525+ }
526+
527+ #[ test]
528+ fn sort_ascending_and_descending_jobid ( ) {
529+ let mut jobs = vec ! [
530+ job( 70 , "default" , P :: JobRunning , 100 ) ,
531+ job( 72 , "default" , P :: JobRunning , 100 ) ,
532+ job( 71 , "default" , P :: JobRunning , 100 ) ,
533+ ] ;
534+ sort_jobs ( & mut jobs, & parse_sort_arg ( "i" ) . unwrap ( ) ) ;
535+ assert_eq ! ( ids( & jobs) , vec![ 70 , 71 , 72 ] ) ;
536+ sort_jobs ( & mut jobs, & parse_sort_arg ( "-i" ) . unwrap ( ) ) ;
537+ assert_eq ! ( ids( & jobs) , vec![ 72 , 71 , 70 ] ) ;
538+ }
539+
540+ #[ test]
541+ fn default_sort_matches_slurm_p_t_negp ( ) {
542+ // partition asc, then state asc (PD<R), then priority desc, then jobid asc
543+ let mut jobs = vec ! [
544+ job( 10 , "b" , P :: JobRunning , 100 ) ,
545+ job( 11 , "a" , P :: JobRunning , 50 ) ,
546+ job( 12 , "a" , P :: JobPending , 200 ) ,
547+ job( 13 , "a" , P :: JobRunning , 50 ) ,
548+ ] ;
549+ sort_jobs ( & mut jobs, & default_sort_keys ( ) ) ;
550+ assert_eq ! ( ids( & jobs) , vec![ 12 , 11 , 13 , 10 ] ) ;
551+ }
552+
553+ #[ test]
554+ fn sort_by_priority_desc_then_jobid_tiebreak ( ) {
555+ let mut jobs = vec ! [
556+ job( 70 , "default" , P :: JobPending , 100 ) ,
557+ job( 71 , "default" , P :: JobPending , 300 ) ,
558+ job( 72 , "default" , P :: JobPending , 300 ) ,
559+ ] ;
560+ sort_jobs ( & mut jobs, & parse_sort_arg ( "-p,i" ) . unwrap ( ) ) ;
561+ assert_eq ! ( ids( & jobs) , vec![ 71 , 72 , 70 ] ) ;
562+ }
563+
564+ #[ test]
565+ fn parse_sort_arg_rejects_unknown_spec ( ) {
566+ let err = parse_sort_arg ( "x" ) . unwrap_err ( ) ;
567+ assert ! ( err. to_string( ) . contains( "Invalid sort specification" ) ) ;
568+ assert ! ( parse_sort_arg( "-z" ) . is_err( ) ) ;
569+ assert ! ( parse_sort_arg( "i,x" ) . is_err( ) ) ;
570+ }
571+
572+ #[ test]
573+ fn state_sort_places_suspended_after_running ( ) {
574+ let mut jobs = vec ! [
575+ job( 1 , "default" , P :: JobFailed , 0 ) ,
576+ job( 2 , "default" , P :: JobSuspended , 0 ) ,
577+ job( 3 , "default" , P :: JobRunning , 0 ) ,
578+ job( 4 , "default" , P :: JobPending , 0 ) ,
579+ ] ;
580+ sort_jobs ( & mut jobs, & parse_sort_arg ( "t" ) . unwrap ( ) ) ;
581+ assert_eq ! ( ids( & jobs) , vec![ 4 , 3 , 2 , 1 ] ) ;
582+ }
583+
584+ #[ test]
585+ fn sort_by_timelimit_puts_unlimited_last ( ) {
586+ let mut a = job ( 1 , "default" , P :: JobRunning , 0 ) ;
587+ a. time_limit = Some ( prost_types:: Duration {
588+ seconds : 600 ,
589+ nanos : 0 ,
590+ } ) ;
591+ let b = job ( 2 , "default" , P :: JobRunning , 0 ) ;
592+ let mut c = job ( 3 , "default" , P :: JobRunning , 0 ) ;
593+ c. time_limit = Some ( prost_types:: Duration {
594+ seconds : 60 ,
595+ nanos : 0 ,
596+ } ) ;
597+ let mut jobs = vec ! [ a, b, c] ;
598+ sort_jobs ( & mut jobs, & parse_sort_arg ( "l" ) . unwrap ( ) ) ;
599+ assert_eq ! ( ids( & jobs) , vec![ 3 , 1 , 2 ] ) ;
600+ }
601+
602+ #[ test]
603+ fn sort_by_submit_time_ascending ( ) {
604+ let mut a = job ( 1 , "default" , P :: JobPending , 0 ) ;
605+ a. submit_time = Some ( prost_types:: Timestamp {
606+ seconds : 300 ,
607+ nanos : 0 ,
608+ } ) ;
609+ let mut b = job ( 2 , "default" , P :: JobPending , 0 ) ;
610+ b. submit_time = Some ( prost_types:: Timestamp {
611+ seconds : 100 ,
612+ nanos : 0 ,
613+ } ) ;
614+ let mut jobs = vec ! [ a, b] ;
615+ sort_jobs ( & mut jobs, & parse_sort_arg ( "V" ) . unwrap ( ) ) ;
616+ assert_eq ! ( ids( & jobs) , vec![ 2 , 1 ] ) ;
617+ }
618+
619+ #[ test]
620+ fn sort_by_partition_orders_lexically ( ) {
621+ let mut jobs = vec ! [
622+ job( 1 , "gamma" , P :: JobRunning , 0 ) ,
623+ job( 2 , "alpha" , P :: JobRunning , 0 ) ,
624+ job( 3 , "beta" , P :: JobRunning , 0 ) ,
625+ ] ;
626+ sort_jobs ( & mut jobs, & parse_sort_arg ( "P" ) . unwrap ( ) ) ;
627+ assert_eq ! ( ids( & jobs) , vec![ 2 , 3 , 1 ] ) ;
628+ }
629+
630+ #[ test]
631+ fn sort_by_time_left_puts_unlimited_last ( ) {
632+ let mut a = job ( 1 , "default" , P :: JobRunning , 0 ) ;
633+ a. time_limit = Some ( prost_types:: Duration {
634+ seconds : 600 ,
635+ nanos : 0 ,
636+ } ) ;
637+ a. run_time = Some ( prost_types:: Duration {
638+ seconds : 60 ,
639+ nanos : 0 ,
640+ } ) ;
641+ let b = job ( 2 , "default" , P :: JobRunning , 0 ) ;
642+ let mut c = job ( 3 , "default" , P :: JobRunning , 0 ) ;
643+ c. time_limit = Some ( prost_types:: Duration {
644+ seconds : 600 ,
645+ nanos : 0 ,
646+ } ) ;
647+ c. run_time = Some ( prost_types:: Duration {
648+ seconds : 500 ,
649+ nanos : 0 ,
650+ } ) ;
651+ let mut jobs = vec ! [ a, b, c] ;
652+ sort_jobs ( & mut jobs, & parse_sort_arg ( "L" ) . unwrap ( ) ) ;
653+ assert_eq ! ( ids( & jobs) , vec![ 3 , 1 , 2 ] ) ;
654+ }
314655}
0 commit comments