cron_descriptor/
lib.rs

1#[macro_use]
2extern crate rust_i18n;
3extern crate strfmt;
4
5use string_builder::Builder;
6
7mod description_builder;
8
9rust_i18n::i18n!("locales");
10
11mod string_utils {
12    pub fn not_contains_any(str: &String, chars: &[char]) -> bool {
13        str.chars().all(|c| !chars.contains(&c))
14    }
15
16    pub fn is_numeric(s: &str) -> bool {
17        for c in s.chars() {
18            if !c.is_numeric() {
19                return false;
20            }
21        }
22        return true;
23    }
24}
25
26mod date_time_utils {
27    pub static DAYS_OF_WEEK_ARR: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
28    pub static MONTHS_ARR: [&str; 12] = [
29        "january",
30        "february",
31        "march",
32        "april",
33        "may",
34        "june",
35        "july",
36        "august",
37        "september",
38        "october",
39        "november",
40        "december",
41    ];
42
43    use crate::cronparser::Options;
44
45    pub fn format_time(
46        hours_expression: &String,
47        minutes_expression: &String,
48        opts: &Options,
49    ) -> String {
50        format_time_secs(
51            &hours_expression,
52            &minutes_expression,
53            &"".to_string(),
54            opts,
55        )
56    }
57
58    pub fn format_time_secs(
59        hours_expression: &String,
60        minutes_expression: &String,
61        seconds_expression: &String,
62        opts: &Options,
63    ) -> String {
64        let mut hour: i8 = hours_expression.parse().unwrap();
65        let mut period: String = "".to_string();
66
67        if !opts.twenty_four_hour_time {
68            period = if hour >= 12 {
69                t!("time_pm")
70            } else {
71                t!("time_am")
72            };
73            if !period.len() > 0 {
74                period = " ".to_string() + .
75            }
76            if hour > 12 {
77                hour -= 12;
78            }
79            if hour == 0 {
80                hour = 12;
81            }
82        }
83
84        let minutes = minutes_expression.parse::<i8>().unwrap().to_string();
85        let mut seconds: String = "".to_string();
86
87        if !seconds_expression.is_empty() {
88            seconds = ":".to_string() + &seconds_expression.parse::<i8>().unwrap().to_string();
89            seconds = format!("{:0>2}", seconds);
90        }
91        let formatted_hours = if opts.twenty_four_hour_time {
92            format!("{:0>2}", hour)
93        } else {
94            format!("{}", hour)
95        };
96        format!(
97            "{0}:{1}{2}{3}",
98            formatted_hours,
99            format!("{:0>2}", minutes),
100            seconds,
101            period
102        )
103    }
104
105    pub fn get_day_of_week_name(day_of_week: usize) -> String {
106        let day_str = DAYS_OF_WEEK_ARR[day_of_week % 7];
107        t!(day_str)
108    }
109}
110
111pub fn format_minutes(minutes_expression: &str) -> String {
112    if minutes_expression.contains(",") {
113        let mparts = minutes_expression.split(",");
114        let mut formatted_expression = Builder::default();
115        for mpt in mparts {
116            formatted_expression.append(format!("{:02}", mpt.parse::<i8>().unwrap()));
117            formatted_expression.append(",");
118        }
119        formatted_expression.string().unwrap()
120    } else {
121        format!("{:02}", minutes_expression.parse::<i8>().unwrap())
122    }
123}
124
125pub mod cronparser {
126    pub enum CasingTypeEnum {
127        Title,
128        Sentence,
129        LowerCase,
130    }
131
132    pub enum DescriptionTypeEnum {
133        FULL,
134        TIMEOFDAY,
135        SECONDS,
136        MINUTES,
137        HOURS,
138        DAYOFWEEK,
139        MONTH,
140        DAYOFMONTH,
141        YEAR,
142    }
143
144    pub struct Options {
145        pub throw_exception_on_parse_error: bool,
146        pub casing_type: CasingTypeEnum,
147        pub verbose: bool,
148        pub zero_based_day_of_week: bool,
149        pub twenty_four_hour_time: bool,
150        pub need_space_between_words: bool,
151    }
152
153    impl Options {
154        pub fn options() -> Options {
155            return Options {
156                throw_exception_on_parse_error: true,
157                casing_type: CasingTypeEnum::Sentence,
158                verbose: false,
159                zero_based_day_of_week: true,
160                twenty_four_hour_time: false,
161                need_space_between_words: true,
162            };
163        }
164
165        pub fn twenty_four_hour() -> Options {
166            let opts = Options::options();
167            let opts2 = Options {
168                twenty_four_hour_time: true,
169                ..opts
170            };
171            return opts2;
172        }
173    }
174
175    pub mod cron_expression_descriptor {
176        use lazy_static::lazy_static;
177        use std::collections::HashMap;
178        use string_builder::Builder;
179
180        use crate::cronparser::{CasingTypeEnum, DescriptionTypeEnum, Options};
181        use crate::date_time_utils::{format_time, format_time_secs};
182        use crate::description_builder::DescriptionBuilder;
183        use crate::description_builder::{
184            DayOfMonthDescriptionBuilder, DayOfWeekDescriptionBuilder, HoursDescriptionBuilder,
185            MinutesDescriptionBuilder, MonthDescriptionBuilder, SecondsDescriptionBuilder,
186            YearDescriptionBuilder,
187        };
188        use crate::{cronparser, string_utils};
189
190        const SPECIAL_CHARACTERS: [char; 4] = ['/', '-', ',', '*'];
191
192        #[derive(Debug, PartialEq)]
193        pub struct ParseException {
194            pub s: String,
195            pub error_offset: u8,
196        }
197
198        mod expression_parser {
199            /* Cron reference
200             ┌───────────── minute (0 - 59)
201             │ ┌───────────── hour (0 - 23)
202             │ │ ┌───────────── day of month (1 - 31)
203             │ │ │ ┌───────────── month (1 - 12)
204             │ │ │ │ ┌───────────── day of week (0 - 6) (Sunday to Saturday; 7 is also Sunday on some systems)
205             │ │ │ │ │
206             │ │ │ │ │
207             │ │ │ │ │
208             * * * * *  command to execute
209            */
210
211            use lazy_static::lazy_static;
212
213            use crate::cronparser::cron_expression_descriptor::ParseException;
214            use crate::cronparser::Options;
215            use crate::string_utils;
216            use regex::Regex;
217
218            pub fn parse(
219                expression: &str,
220                options: &Options,
221            ) -> Result<Vec<String>, ParseException> {
222                let mut parsed: Vec<&str> = vec![""; 7];
223                if expression.trim().is_empty() {
224                    lazy_static! {
225                        static ref ERR_STR: String = t!("expression_empty_exception");
226                    }
227                    Err(ParseException {
228                        s: expression.to_string(),
229                        error_offset: 0,
230                    })
231                } else {
232                    let expression_parts: Vec<&str> =
233                        expression.trim().split_whitespace().collect();
234                    if expression_parts.len() < 5 {
235                        return Err(ParseException {
236                            s: expression.to_string(),
237                            error_offset: 0,
238                        });
239                    } else if expression_parts.len() == 5 {
240                        parsed[0] = "";
241                        (1..=5).for_each(|i| parsed[i] = expression_parts[i - 1]);
242                        // println!("length is 5: {}", parsed[5]);
243                    } else if expression_parts.len() == 6 {
244                        lazy_static! {
245                            static ref YEAR_RE: Regex = Regex::new(r"\d{4}$").unwrap();
246                        }
247                        if YEAR_RE.is_match(expression_parts[5]) {
248                            (1..=6).for_each(|i| parsed[i] = expression_parts[i - 1]);
249                        } else {
250                            (0..6).for_each(|i| parsed[i] = expression_parts[i]);
251                        }
252                    } else if expression_parts.len() == 7 {
253                        (0..=6).for_each(|i| parsed[i] = expression_parts[i]);
254                    } else {
255                        let result2 = Err(ParseException {
256                            s: expression.to_string(),
257                            error_offset: 7,
258                        });
259                        return result2;
260                    }
261
262                    let normalized_expr = normalise_expression(parsed, options);
263                    Ok(normalized_expr)
264                }
265            }
266
267            fn normalise_expression(expression_parts: Vec<&str>, options: &Options) -> Vec<String> {
268                static DAYS_OF_WEEK_ARR: [&str; 7] =
269                    ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
270                static MONTHS_ARR: [&str; 12] = [
271                    "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV",
272                    "DEC",
273                ];
274                let mut normalised: Vec<String> = vec!["".to_string(); 7];
275
276                (0..expression_parts.len()).for_each(|i| {
277                    normalised[i] = expression_parts[i].to_string();
278                });
279
280                normalised[3] = normalised[3].replace("?", "*");
281                normalised[5] = normalised[5].replace("?", "*");
282
283                (0..=2).for_each(|i| {
284                    normalised[i] = if normalised[i].starts_with("0/") {
285                        normalised[i].replace("0/", "*/")
286                    } else {
287                        normalised[i].to_string()
288                    }
289                });
290
291                (3..=5).for_each(|i| {
292                    normalised[i] = if normalised[i].starts_with("1/") {
293                        normalised[i].replace("1/", "*/")
294                    } else {
295                        normalised[i].to_string()
296                    }
297                });
298
299                for i in 0..normalised.len() {
300                    if normalised[i] == "*/1" {
301                        normalised[i] = "*".to_string();
302                    }
303                }
304                // println!("normalised after replacing */1: {:?}", normalised);
305                // convert SUN-SAT format to 0-6 format
306                if !string_utils::is_numeric(&normalised[5]) {
307                    for i in 0..=6 {
308                        normalised[5] =
309                            normalised[5].replace(DAYS_OF_WEEK_ARR[i], i.to_string().as_str());
310                    }
311                }
312
313                // convert JAN-DEC format to 1-12 format
314                if !string_utils::is_numeric(&normalised[4]) {
315                    for i in 1..12 {
316                        normalised[4] =
317                            normalised[4].replace(MONTHS_ARR[i - 1], i.to_string().as_str());
318                    }
319                }
320
321                // convert 0 second to (empty)
322                if "0" == normalised[0] {
323                    normalised[0] = "".to_string();
324                }
325
326                // convert 0 DOW to 7 so that 0 for Sunday in zeroBasedDayOfWeek is valid
327                // this logic is copied from the Java version and seems different than the C#
328                // version.
329                if options.zero_based_day_of_week && "0" == normalised[5] {
330                    normalised[5] = "7".to_string();
331                }
332
333                // println!("normalised: {:?}", normalised);
334                // Bunch of logic in the C# version is missing from the Java version,
335                // such as regex handling of the DOW, stepping and between ranges.
336                normalised
337            }
338        }
339
340        pub fn get_description(
341            description_type: DescriptionTypeEnum,
342            expression: &str,
343            options: &Options,
344            locale: &str,
345        ) -> Result<String, ParseException> {
346            rust_i18n::set_locale(&locale);
347            let expression_parsed = expression_parser::parse(expression, options);
348            match expression_parsed {
349                Ok(expression_parts) => {
350                    let description_res = match description_type {
351                        DescriptionTypeEnum::FULL => {
352                            get_full_description(&expression_parts, options)
353                        }
354                        DescriptionTypeEnum::TIMEOFDAY => {
355                            get_time_of_day_description(&expression_parts, options)
356                        }
357                        DescriptionTypeEnum::SECONDS => {
358                            get_seconds_description(&expression_parts, options)
359                        }
360                        DescriptionTypeEnum::MINUTES => {
361                            get_minutes_description(&expression_parts, options)
362                        }
363                        DescriptionTypeEnum::HOURS => {
364                            get_hours_description(&expression_parts, options)
365                        }
366                        DescriptionTypeEnum::DAYOFWEEK => {
367                            get_day_of_week_description(&expression_parts, options)
368                        }
369                        DescriptionTypeEnum::MONTH => {
370                            get_month_description(&expression_parts, options)
371                        }
372                        DescriptionTypeEnum::DAYOFMONTH => {
373                            get_day_of_month_description(&expression_parts, options)
374                        }
375                        DescriptionTypeEnum::YEAR => {
376                            get_year_description(&expression_parts, options)
377                        }
378                    };
379                    Ok(description_res)
380                }
381                Err(pe) => Err(pe),
382            }
383        }
384
385        // From the C# code, not Java.
386        fn get_full_description(expression_parts: &Vec<String>, options: &Options) -> String {
387            let time_segment = get_time_of_day_description(&expression_parts, options);
388            let day_of_month_desc = get_day_of_month_description(&expression_parts, options);
389            let month_desc = get_month_description(&expression_parts, options);
390            let day_of_week_desc = get_day_of_week_description(&expression_parts, options);
391            let year_desc = get_year_description(&expression_parts, options);
392            let week_or_month_desc = if "*" == &expression_parts[3] {
393                day_of_week_desc
394            } else {
395                day_of_month_desc
396            };
397            let desc1 = format!(
398                "{0}{1}{2}{3}",
399                time_segment, week_or_month_desc, month_desc, year_desc
400            );
401            // eprintln!("time: \"{}\"; day_of_month: \"{}\"; month: \"{}\"; year: \"{}\"",
402            //           time_segment, week_or_month_desc, month_desc, year_desc);
403            // println!("before verbosity: {}", desc1);
404            let desc2 = transform_verbosity(desc1, options);
405            transform_case(&desc2, options)
406        }
407
408        fn transform_verbosity(description: String, options: &Options) -> String {
409            let mut desc_temp = description.clone();
410            if !options.verbose {
411                desc_temp =
412                    desc_temp.replace(&t!("messages.every_1_minute"), &t!("messages.every_minute"));
413                desc_temp =
414                    desc_temp.replace(&t!("messages.every_1_hour"), &t!("messages.every_hour"));
415                desc_temp =
416                    desc_temp.replace(&t!("messages.every_1_day"), &t!("messages.every_day"));
417                desc_temp = desc_temp.replace(&format!(", {}", &t!("messages.every_minute")), "");
418                desc_temp = desc_temp.replace(&format!(", {}", &t!("messages.every_hour")), "");
419                desc_temp = desc_temp.replace(&format!(", {}", &t!("messages.every_day")), "");
420                desc_temp = desc_temp.replace(&format!(", {}", &t!("messages.every_year")), "");
421            }
422            desc_temp
423        }
424
425        fn transform_case(description: &str, options: &Options) -> String {
426            match &options.casing_type {
427                CasingTypeEnum::Sentence => description[0..1].to_uppercase() + &description[1..],
428                CasingTypeEnum::Title => description[0..1].to_uppercase() + &description[1..],
429                CasingTypeEnum::LowerCase => description.to_lowercase(),
430            }
431        }
432
433        fn get_year_description(expression_parts: &Vec<String>, options: &Options) -> String {
434            let builder = YearDescriptionBuilder { options };
435            builder.get_segment_description(
436                &expression_parts[6],
437                format!(", {}", t!("messages.every_year")),
438            )
439        }
440
441        fn get_day_of_week_description(
442            expression_parts: &Vec<String>,
443            options: &Options,
444        ) -> String {
445            let builder = DayOfWeekDescriptionBuilder { options };
446            // println!("in get_day_of_week_description, expr: {}", &expression_parts[5]);
447            builder.get_segment_description(
448                &expression_parts[5],
449                format!(", {}", t!("messages.every_day")),
450            )
451        }
452
453        fn get_minutes_description(expression_parts: &Vec<String>, options: &Options) -> String {
454            let builder = MinutesDescriptionBuilder { options };
455            builder.get_segment_description(&expression_parts[1], t!("messages.every_minute"))
456        }
457
458        fn get_seconds_description(expression_parts: &Vec<String>, options: &Options) -> String {
459            let builder = SecondsDescriptionBuilder { options };
460            builder.get_segment_description(&expression_parts[0], t!("messages.every_second"))
461        }
462
463        fn get_hours_description(expression_parts: &Vec<String>, options: &Options) -> String {
464            let builder = HoursDescriptionBuilder { options };
465            builder.get_segment_description(&expression_parts[2], t!("messages.every_hour"))
466        }
467
468        fn get_month_description(expression_parts: &Vec<String>, options: &Options) -> String {
469            let builder = MonthDescriptionBuilder { options };
470            builder.get_segment_description(&expression_parts[4], "".to_string())
471        }
472
473        fn get_day_of_month_description(
474            expression_parts: &Vec<String>,
475            options: &Options,
476        ) -> String {
477            use regex::Regex;
478            use strfmt::strfmt;
479            let exp = expression_parts[3].replace("?", "*");
480            let description = if "L" == exp {
481                format!(", {}", t!("messages.on_the_last_day_of_the_month"))
482            } else if "WL" == exp || "LW" == exp {
483                format!(", {}", t!("messages.on_the_last_weekday_of_the_month"))
484            } else {
485                lazy_static! {
486                    static ref DOM_RE: Regex = Regex::new(r"(\dW)|(W\d)").unwrap();
487                }
488                if DOM_RE.is_match(&exp) {
489                    let capt = DOM_RE.captures_iter(&exp).next().unwrap();
490                    let no_w = capt[0].replace("W", "");
491                    let day_number = no_w.parse::<u8>().unwrap();
492                    let day_string = if day_number == 1 {
493                        t!("messages.first_weekday")
494                    } else {
495                        t!("messages.weekday_nearest_day", 0 = &no_w)
496                    };
497                    let fmt_str = format!(", {}", t!("messages.on_the_of_the_month"));
498                    let mut vars = HashMap::new();
499                    vars.insert("0".to_string(), day_string);
500                    strfmt(&fmt_str, &vars).unwrap()
501                } else {
502                    let builder = DayOfMonthDescriptionBuilder { options };
503                    // eprintln!("in get_day_of_month_description, exp: {}", exp);
504                    builder.get_segment_description(&exp, format!(", {}", t!("messages.every_day")))
505                }
506            };
507            description
508        }
509
510        fn get_time_of_day_description(
511            expression_parts: &Vec<String>,
512            options: &Options,
513        ) -> String {
514            let seconds_expression = &expression_parts[0];
515            let minutes_expression = &expression_parts[1];
516            let hours_expression = &expression_parts[2];
517
518            let mut description = Builder::default();
519
520            if minutes_expression
521                .chars()
522                .all(|c| !SPECIAL_CHARACTERS.contains(&c))
523                && hours_expression
524                    .chars()
525                    .all(|c| !SPECIAL_CHARACTERS.contains(&c))
526                && seconds_expression
527                    .chars()
528                    .all(|c| !SPECIAL_CHARACTERS.contains(&c))
529            {
530                description.append(t!("at"));
531                if options.need_space_between_words {
532                    description.append(" ");
533                }
534                description.append(format_time_secs(
535                    hours_expression,
536                    minutes_expression,
537                    seconds_expression,
538                    options,
539                ));
540            } else if minutes_expression.contains("-")
541                && !minutes_expression.contains("/")
542                && string_utils::not_contains_any(hours_expression, &SPECIAL_CHARACTERS)
543            {
544                let mut minute_parts = minutes_expression.split("-");
545                let msg0 = format_time(
546                    hours_expression,
547                    &minute_parts.next().unwrap().to_string(),
548                    options,
549                );
550                let msg1 = format_time(
551                    hours_expression,
552                    &minute_parts.next().unwrap().to_string(),
553                    options,
554                );
555                description.append(t!("messages.every_minute_between", 0 = &msg0, 1 = &msg1));
556            } else if hours_expression.contains(",")
557                && string_utils::not_contains_any(minutes_expression, &SPECIAL_CHARACTERS)
558            {
559                let hour_parts: Vec<_> = hours_expression.split(",").collect();
560                let hpsz = hour_parts.len();
561                description.append(t!("at"));
562
563                for (i, hp) in hour_parts.iter().enumerate() {
564                    description.append(" ");
565                    description.append(format_time(&hp.to_string(), minutes_expression, options));
566                    if i < hpsz - 2 {
567                        description.append(",");
568                    }
569                    if i == hpsz - 2 {
570                        description.append(" ");
571                        description.append(t!("and"));
572                    }
573                }
574            } else {
575                let seconds_description = get_seconds_description(expression_parts, options);
576                let minutes_description = get_minutes_description(expression_parts, options);
577                let hours_description = get_hours_description(expression_parts, options);
578                // println!("file: {}, line: {}", file!(), line!());
579                // println!("seconds_description: {} minutes_description: {}, hours_description: {}",
580                //   seconds_description, minutes_description, hours_description);
581                description.append(seconds_description);
582                if description.len() > 0 && !minutes_description.is_empty() {
583                    description.append(", ");
584                }
585                description.append(minutes_description);
586                if description.len() > 0 && !hours_description.is_empty() {
587                    description.append(", ");
588                }
589                description.append(hours_description);
590            }
591            description.string().unwrap()
592        }
593
594        pub fn get_description_cron(expression: &str) -> Result<String, ParseException> {
595            // println!("Expression: {}", expression);
596            get_description(
597                DescriptionTypeEnum::FULL,
598                expression,
599                &Options::options(),
600                &rust_i18n::locale(),
601            )
602        }
603
604        pub fn get_description_cron_options(
605            expression: &str,
606            options: &cronparser::Options,
607        ) -> Result<String, ParseException> {
608            get_description(
609                DescriptionTypeEnum::FULL,
610                expression,
611                options,
612                &rust_i18n::locale(),
613            )
614        }
615
616        pub fn get_description_cron_locale(expression: &str, locale: &str) -> Result<String, ParseException> {
617            get_description(
618                DescriptionTypeEnum::FULL,
619                expression,
620                &Options::options(),
621                locale,
622            )
623        }
624
625        pub fn get_description_cron_options_locale(
626            expression: &str,
627            options: &Options,
628            locale: &str,
629        ) -> Result<String, ParseException> {
630            get_description(DescriptionTypeEnum::FULL, expression, options, locale)
631        }
632
633        pub fn get_description_cron_type_expr(
634            desc_type: DescriptionTypeEnum,
635            expression: &str,
636        ) -> Result<String, ParseException> {
637            get_description(
638                desc_type,
639                expression,
640                &Options::options(),
641                &rust_i18n::locale(),
642            )
643        }
644
645        pub fn get_description_cron_type_expr_locale(
646            desc_type: DescriptionTypeEnum,
647            expression: &str,
648            locale: &str,
649        ) -> Result<String, ParseException> {
650            get_description(desc_type, expression, &Options::options(), locale)
651        }
652
653        pub fn get_description_cron_type_expr_opts(
654            desc_type: DescriptionTypeEnum,
655            expression: &str,
656            options: &Options,
657        ) ->  Result<String, ParseException> {
658            get_description(desc_type, expression, options, &rust_i18n::locale())
659        }
660    }
661}