@@ -538,11 +538,174 @@ pub fn next_cron_fire(
538538 }
539539}
540540
541+ /// Describe common routine cron patterns in plain English.
542+ ///
543+ /// Falls back to `cron: <raw>` for malformed or complex expressions.
544+ pub fn describe_cron ( schedule : & str , timezone : Option < & str > ) -> String {
545+ fn fallback ( raw : & str ) -> String {
546+ if raw. trim ( ) . is_empty ( ) {
547+ "cron: (empty)" . to_string ( )
548+ } else {
549+ format ! ( "cron: {}" , raw. trim( ) )
550+ }
551+ }
552+
553+ fn parse_u8_token ( token : & str ) -> Option < u8 > {
554+ token. parse :: < u8 > ( ) . ok ( )
555+ }
556+
557+ fn parse_step ( token : & str ) -> Option < u8 > {
558+ token
559+ . strip_prefix ( "*/" )
560+ . and_then ( parse_u8_token)
561+ . filter ( |n| * n > 0 )
562+ }
563+
564+ fn weekday_name ( dow : & str ) -> Option < & ' static str > {
565+ let normalized = dow. trim ( ) . to_ascii_uppercase ( ) ;
566+ match normalized. as_str ( ) {
567+ "MON" | "1" => Some ( "Monday" ) ,
568+ "TUE" | "2" => Some ( "Tuesday" ) ,
569+ "WED" | "3" => Some ( "Wednesday" ) ,
570+ "THU" | "4" => Some ( "Thursday" ) ,
571+ "FRI" | "5" => Some ( "Friday" ) ,
572+ "SAT" | "6" => Some ( "Saturday" ) ,
573+ "SUN" | "0" | "7" => Some ( "Sunday" ) ,
574+ _ => None ,
575+ }
576+ }
577+
578+ fn format_time ( hour : u8 , minute : u8 ) -> String {
579+ if hour == 0 && minute == 0 {
580+ return "midnight" . to_string ( ) ;
581+ }
582+ let ( display_hour, am_pm) = match hour {
583+ 0 => ( 12 , "AM" ) ,
584+ 1 ..=11 => ( hour, "AM" ) ,
585+ 12 => ( 12 , "PM" ) ,
586+ _ => ( hour - 12 , "PM" ) ,
587+ } ;
588+ format ! ( "{display_hour}:{minute:02} {am_pm}" )
589+ }
590+
591+ fn ordinal ( n : u8 ) -> String {
592+ let suffix = if ( 11 ..=13 ) . contains ( & ( n % 100 ) ) {
593+ "th"
594+ } else {
595+ match n % 10 {
596+ 1 => "st" ,
597+ 2 => "nd" ,
598+ 3 => "rd" ,
599+ _ => "th" ,
600+ }
601+ } ;
602+ format ! ( "{n}{suffix}" )
603+ }
604+
605+ fn describe_inner ( raw : & str ) -> Option < String > {
606+ let fields: Vec < & str > = raw. split_whitespace ( ) . collect ( ) ;
607+ let ( sec, min, hour, dom, month, dow, year) = match fields. len ( ) {
608+ 5 => (
609+ "0" , fields[ 0 ] , fields[ 1 ] , fields[ 2 ] , fields[ 3 ] , fields[ 4 ] , None ,
610+ ) ,
611+ 6 => (
612+ fields[ 0 ] , fields[ 1 ] , fields[ 2 ] , fields[ 3 ] , fields[ 4 ] , fields[ 5 ] , None ,
613+ ) ,
614+ 7 => (
615+ fields[ 0 ] ,
616+ fields[ 1 ] ,
617+ fields[ 2 ] ,
618+ fields[ 3 ] ,
619+ fields[ 4 ] ,
620+ fields[ 5 ] ,
621+ Some ( fields[ 6 ] ) ,
622+ ) ,
623+ _ => return None ,
624+ } ;
625+
626+ if year. is_some_and ( |v| v != "*" ) {
627+ return None ;
628+ }
629+
630+ if sec == "0"
631+ && hour == "*"
632+ && dom == "*"
633+ && month == "*"
634+ && dow == "*"
635+ && let Some ( step) = parse_step ( min)
636+ {
637+ return Some ( match step {
638+ 1 => "Every minute" . to_string ( ) ,
639+ n => format ! ( "Every {n} minutes" ) ,
640+ } ) ;
641+ }
642+
643+ if sec == "0"
644+ && min == "0"
645+ && dom == "*"
646+ && month == "*"
647+ && dow == "*"
648+ && let Some ( step) = parse_step ( hour)
649+ {
650+ return Some ( match step {
651+ 1 => "Every hour" . to_string ( ) ,
652+ n => format ! ( "Every {n} hours" ) ,
653+ } ) ;
654+ }
655+
656+ let hour = parse_u8_token ( hour) . filter ( |h| * h <= 23 ) ?;
657+ let minute = parse_u8_token ( min) . filter ( |m| * m <= 59 ) ?;
658+ let time = format_time ( hour, minute) ;
659+ let time_phrase = if time == "midnight" {
660+ "at midnight" . to_string ( )
661+ } else {
662+ format ! ( "at {time}" )
663+ } ;
664+
665+ if sec == "0" && dom == "*" && month == "*" && dow == "*" {
666+ return Some ( format ! ( "Daily {time_phrase}" ) ) ;
667+ }
668+
669+ if sec == "0" && dom == "*" && month == "*" && dow. eq_ignore_ascii_case ( "MON-FRI" ) {
670+ return Some ( format ! ( "Weekdays {time_phrase}" ) ) ;
671+ }
672+
673+ if sec == "0"
674+ && dom == "*"
675+ && month == "*"
676+ && let Some ( day_name) = weekday_name ( dow)
677+ {
678+ return Some ( format ! ( "Every {day_name} {time_phrase}" ) ) ;
679+ }
680+
681+ if sec == "0"
682+ && month == "*"
683+ && dow == "*"
684+ && let Some ( day_of_month) = parse_u8_token ( dom) . filter ( |d| ( 1 ..=31 ) . contains ( d) )
685+ {
686+ return Some ( format ! (
687+ "{} of every month {time_phrase}" ,
688+ ordinal( day_of_month)
689+ ) ) ;
690+ }
691+
692+ None
693+ }
694+
695+ let mut description = describe_inner ( schedule) . unwrap_or_else ( || fallback ( schedule) ) ;
696+ if let Some ( tz) = timezone. map ( str:: trim) . filter ( |tz| !tz. is_empty ( ) ) {
697+ description. push_str ( " (" ) ;
698+ description. push_str ( tz) ;
699+ description. push ( ')' ) ;
700+ }
701+ description
702+ }
703+
541704#[ cfg( test) ]
542705mod tests {
543706 use crate :: agent:: routine:: {
544707 MAX_TOOL_ROUNDS_LIMIT , RoutineAction , RoutineGuardrails , RunStatus , Trigger , content_hash,
545- next_cron_fire,
708+ describe_cron , next_cron_fire,
546709 } ;
547710
548711 #[ test]
@@ -698,6 +861,40 @@ mod tests {
698861 assert_ne ! ( next_utc, next_est, "timezone should shift the fire time" ) ;
699862 }
700863
864+ #[ test]
865+ fn test_describe_cron_common_patterns ( ) {
866+ let cases = vec ! [
867+ ( "0 */30 * * * *" , None , "Every 30 minutes" ) ,
868+ ( "0 0 9 * * *" , None , "Daily at 9:00 AM" ) ,
869+ ( "0 0 9 * * MON-FRI" , None , "Weekdays at 9:00 AM" ) ,
870+ ( "0 0 */2 * * *" , None , "Every 2 hours" ) ,
871+ ( "0 0 0 * * *" , None , "Daily at midnight" ) ,
872+ ( "0 0 9 * * 1" , None , "Every Monday at 9:00 AM" ) ,
873+ ( "0 0 9 1 * *" , None , "1st of every month at 9:00 AM" ) ,
874+ (
875+ "0 0 9 * * MON-FRI" ,
876+ Some ( "America/New_York" ) ,
877+ "Weekdays at 9:00 AM (America/New_York)" ,
878+ ) ,
879+ ( "1 2 3 4 5 6" , None , "cron: 1 2 3 4 5 6" ) ,
880+ ] ;
881+
882+ for ( schedule, timezone, expected) in cases {
883+ let actual = describe_cron ( schedule, timezone) ;
884+ assert_eq ! ( actual, expected) ; // safety: test-only assertion in #[cfg(test)] module
885+ }
886+ }
887+
888+ #[ test]
889+ fn test_describe_cron_edge_cases ( ) {
890+ assert_eq ! ( describe_cron( "" , None ) , "cron: (empty)" ) ; // safety: test-only assertion in #[cfg(test)] module
891+ assert_eq ! ( describe_cron( "not a cron" , None ) , "cron: not a cron" ) ; // safety: test-only assertion in #[cfg(test)] module
892+ let weekdays_5_field = describe_cron ( "0 9 * * MON-FRI" , None ) ;
893+ assert_eq ! ( weekdays_5_field, "Weekdays at 9:00 AM" ) ; // safety: test-only assertion in #[cfg(test)] module
894+ let weekdays_7_field = describe_cron ( "0 0 9 * * MON-FRI *" , None ) ;
895+ assert_eq ! ( weekdays_7_field, "Weekdays at 9:00 AM" ) ; // safety: test-only assertion in #[cfg(test)] module
896+ }
897+
701898 #[ test]
702899 fn test_guardrails_default ( ) {
703900 let g = RoutineGuardrails :: default ( ) ;
0 commit comments