Return-Path: X-Original-To: apmail-aurora-commits-archive@minotaur.apache.org Delivered-To: apmail-aurora-commits-archive@minotaur.apache.org Received: from mail.apache.org (hermes.apache.org [140.211.11.3]) by minotaur.apache.org (Postfix) with SMTP id 2A27B112F9 for ; Fri, 28 Mar 2014 17:34:13 +0000 (UTC) Received: (qmail 80000 invoked by uid 500); 28 Mar 2014 17:34:12 -0000 Delivered-To: apmail-aurora-commits-archive@aurora.apache.org Received: (qmail 79951 invoked by uid 500); 28 Mar 2014 17:34:11 -0000 Mailing-List: contact commits-help@aurora.incubator.apache.org; run by ezmlm Precedence: bulk List-Help: List-Unsubscribe: List-Post: List-Id: Reply-To: dev@aurora.incubator.apache.org Delivered-To: mailing list commits@aurora.incubator.apache.org Received: (qmail 79909 invoked by uid 99); 28 Mar 2014 17:34:08 -0000 Received: from nike.apache.org (HELO nike.apache.org) (192.87.106.230) by apache.org (qpsmtpd/0.29) with ESMTP; Fri, 28 Mar 2014 17:34:07 +0000 X-ASF-Spam-Status: No, hits=-2000.5 required=5.0 tests=ALL_TRUSTED,RP_MATCHES_RCVD X-Spam-Check-By: apache.org Received: from [140.211.11.3] (HELO mail.apache.org) (140.211.11.3) by apache.org (qpsmtpd/0.29) with SMTP; Fri, 28 Mar 2014 17:34:02 +0000 Received: (qmail 79068 invoked by uid 99); 28 Mar 2014 17:33:36 -0000 Received: from tyr.zones.apache.org (HELO tyr.zones.apache.org) (140.211.11.114) by apache.org (qpsmtpd/0.29) with ESMTP; Fri, 28 Mar 2014 17:33:36 +0000 Received: by tyr.zones.apache.org (Postfix, from userid 65534) id 4771F9150D0; Fri, 28 Mar 2014 17:33:36 +0000 (UTC) Content-Type: text/plain; charset="us-ascii" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit From: kevints@apache.org To: commits@aurora.incubator.apache.org Message-Id: X-Mailer: ASF-Git Admin Mailer Subject: git commit: AURORA-132: crontab(5) entry parser. Date: Fri, 28 Mar 2014 17:33:36 +0000 (UTC) X-Virus-Checked: Checked by ClamAV on apache.org Repository: incubator-aurora Updated Branches: refs/heads/master 6db811d72 -> d209a21cf AURORA-132: crontab(5) entry parser. Testing Done: ./gradlew build Bugs closed: AURORA-132 Reviewed at https://reviews.apache.org/r/19709/ Project: http://git-wip-us.apache.org/repos/asf/incubator-aurora/repo Commit: http://git-wip-us.apache.org/repos/asf/incubator-aurora/commit/d209a21c Tree: http://git-wip-us.apache.org/repos/asf/incubator-aurora/tree/d209a21c Diff: http://git-wip-us.apache.org/repos/asf/incubator-aurora/diff/d209a21c Branch: refs/heads/master Commit: d209a21cfe819c8990be6d5e3de35dc5fc577a29 Parents: 6db811d Author: Kevin Sweeney Authored: Fri Mar 28 10:33:20 2014 -0700 Committer: Kevin Sweeney Committed: Fri Mar 28 10:33:20 2014 -0700 ---------------------------------------------------------------------- build.gradle | 1 + .../aurora/scheduler/cron/CrontabEntry.java | 480 +++++++++++++++++++ .../aurora/scheduler/cron/CrontabEntryTest.java | 163 +++++++ 3 files changed, 644 insertions(+) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/d209a21c/build.gradle ---------------------------------------------------------------------- diff --git a/build.gradle b/build.gradle index f6eb2f3..f10f22c 100644 --- a/build.gradle +++ b/build.gradle @@ -212,6 +212,7 @@ dependencies { checkstyle 'com.puppycrawl.tools:checkstyle:5.6' configurations.compile { + exclude module: 'junit-dep' resolutionStrategy { failOnVersionConflict() http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/d209a21c/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntry.java ---------------------------------------------------------------------- diff --git a/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntry.java b/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntry.java new file mode 100644 index 0000000..6411244 --- /dev/null +++ b/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntry.java @@ -0,0 +1,480 @@ +/** + * Copyright 2014 Apache Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.aurora.scheduler.cron; + +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Joiner; +import com.google.common.base.Objects; +import com.google.common.base.Optional; +import com.google.common.base.Splitter; +import com.google.common.collect.BiMap; +import com.google.common.collect.ContiguousSet; +import com.google.common.collect.DiscreteDomain; +import com.google.common.collect.ImmutableBiMap; +import com.google.common.collect.ImmutableRangeSet; +import com.google.common.collect.Lists; +import com.google.common.collect.Range; +import com.google.common.collect.RangeSet; +import com.google.common.collect.TreeRangeSet; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * A pattern that describes one or more cron 5-tuples (minute, hour, dayOfMonth, month, dayOfWeek). + * + * CrontabEntries are immutable and thread-safe. Unless otherwise specified any public methods will + * throw {@link java.lang.NullPointerException} if given a {@code null} parameter. + * + * The quickest way to create a {@code CrontabEntry} is to use {@link #parse(String)} or + * {@link #tryParse(String)}. + */ +public final class CrontabEntry { + private static final Range MINUTE = + Range.closed(0, 59).canonical(DiscreteDomain.integers()); + private static final Range HOUR = + Range.closed(0, 23).canonical(DiscreteDomain.integers()); + private static final Range DAY_OF_MONTH = + Range.closed(1, 31).canonical(DiscreteDomain.integers()); + private static final Range MONTH = + Range.closed(1, 12).canonical(DiscreteDomain.integers()); + // NOTE: Unlike FreeBSD we don't allow "7" to mean Sunday. + private static final Range DAY_OF_WEEK = + Range.closed(0, 6).canonical(DiscreteDomain.integers()); + + private final RangeSet minute; + private final RangeSet hour; + private final RangeSet dayOfMonth; + private final RangeSet month; + private final RangeSet dayOfWeek; + + private CrontabEntry( + RangeSet minute, + RangeSet hour, + RangeSet dayOfMonth, + RangeSet month, + RangeSet dayOfWeek) { + + checkEnclosed("minute", MINUTE, minute); + checkEnclosed("hour", HOUR, hour); + checkEnclosed("dayOfMonth", DAY_OF_MONTH, dayOfMonth); + checkEnclosed("month", MONTH, month); + checkEnclosed("dayOfWeek", DAY_OF_WEEK, dayOfWeek); + + this.minute = ImmutableRangeSet.copyOf(minute); + this.hour = ImmutableRangeSet.copyOf(hour); + this.dayOfMonth = ImmutableRangeSet.copyOf(dayOfMonth); + this.month = ImmutableRangeSet.copyOf(month); + this.dayOfWeek = ImmutableRangeSet.copyOf(dayOfWeek); + + checkArgument(hasWildcardDayOfMonth() || hasWildcardDayOfWeek(), + "Specifying both dayOfWeek and dayOfMonth is not supported."); + } + + private static void checkEnclosed( + String fieldName, + Range fieldEnclosure, + RangeSet field) { + + checkArgument(fieldEnclosure.encloses(field.span()), + String.format( + "Bad specification for field %s: span(%s) = %s is not enclosed by boundary %s.", + fieldName, + field, + field.span(), + fieldEnclosure)); + } + + /** + * Create a new {@link CrontabEntry} from a crontab(5)-style schedule. + * + * The acceptable format of {@code schedule} is mostly compatible with FreeBSD's crontab(5) + * format, excluding "extensions" like "@every_minute." + * + * A crontab(5) entry consists of 5 fields (minute, hour, dayOfMonth, month, dayOfWeek) and for + * each field supports singletons ("50"), wildcards ("*"), ranges ("1-50", "MON-SAT"), and + * "skips" ("1-50/2", "*/2"). + * + * See http://www.freebsd.org/cgi/man.cgi?query=crontab&sektion=5 for full syntax examples. + * + * NOTE: While entries such as "Thursdays that fall on the 15th day of the month" are expressible + * in the original BSD syntax, this parser rejects them with {@link IllegalArgumentException}. + * + * @param schedule The crontab entry to parse. + * @return A new entry if parsing was successful. + * @throws IllegalArgumentException if parsing failed for any reason. + */ + public static CrontabEntry parse(String schedule) throws IllegalArgumentException { + return new Parser(schedule).get(); + } + + /** + * Create a new {@link CrontabEntry} from a crontab(5)-style schedule. + * + * @see #parse(String) + * @param schedule The crontab entry to parse. + * @return A new entry if parsing was successful, absent otherwise. + */ + public static Optional tryParse(String schedule) { + try { + return Optional.of(parse(schedule)); + } catch (IllegalArgumentException e) { + return Optional.absent(); + } + } + + private static CrontabEntry from( + RangeSet minute, + RangeSet hour, + RangeSet dayOfMonth, + RangeSet month, + RangeSet dayOfWeek) throws IllegalArgumentException { + + return new CrontabEntry(minute, hour, dayOfMonth, month, dayOfWeek); + } + + private RangeSet getMinute() { + return minute; + } + + private RangeSet getHour() { + return hour; + } + + private RangeSet getDayOfMonth() { + return dayOfMonth; + } + + private RangeSet getMonth() { + return month; + } + + /** + * The days of the week this entry matches. 0 is Sun and 6 is Sat. + * + * @return An immutable view of the days of the week this entry matches within [0,7). + */ + public RangeSet getDayOfWeek() { + return dayOfWeek; + } + + @VisibleForTesting + boolean hasWildcardMinute() { + return getMinute().encloses(MINUTE); + } + + @VisibleForTesting + boolean hasWildcardHour() { + return getHour().encloses(HOUR); + } + + /** + * True if this entry covers all possible days of the month. + */ + public boolean hasWildcardDayOfMonth() { + return getDayOfMonth().encloses(DAY_OF_MONTH); + } + + + @VisibleForTesting + boolean hasWildcardMonth() { + return getMonth().encloses(MONTH); + } + + /** + * True if this entry covers all possible days of the week. + */ + public boolean hasWildcardDayOfWeek() { + return getDayOfWeek().encloses(DAY_OF_WEEK); + } + + private String fieldToString(RangeSet rangeSet, Range coveringRange) { + if (rangeSet.asRanges().size() == 1 && rangeSet.encloses(coveringRange)) { + return "*"; + } + List components = Lists.newArrayList(); + for (Range range : rangeSet.asRanges()) { + ContiguousSet set = ContiguousSet.create(range, DiscreteDomain.integers()); + if (set.size() == 1) { + components.add(set.first().toString()); + } else { + components.add(set.first() + "-" + set.last()); + } + } + return Joiner.on(",").join(components); + } + + /** + * The minute component, in canonical form. + */ + public String getMinuteAsString() { + return fieldToString(getMinute(), MINUTE); + } + + /** + * The hour component, in canonical form. + */ + public String getHourAsString() { + return fieldToString(getHour(), HOUR); + } + + /** + * The dayOfMonth component, in canonical form. + */ + public String getDayOfMonthAsString() { + return fieldToString(getDayOfMonth(), DAY_OF_MONTH); + } + + /** + * The month component, in canonical form. + */ + public String getMonthAsString() { + return fieldToString(getMonth(), MONTH); + } + + /** + * The dayOfWeek component, in canonical form. + */ + public String getDayOfWeekAsString() { + return fieldToString(getDayOfWeek(), DAY_OF_WEEK); + } + + /** + * Returns a parsable string representation schedule such that + * c.equals(CrontabEntry.parse(c.toString()) + */ + @Override + public String toString() { + return Joiner.on(" ").join( + getMinuteAsString(), + getHourAsString(), + getDayOfMonthAsString(), + getMonthAsString(), + getDayOfWeekAsString()); + } + + /** + * True when both sides would match the same set of instants. + */ + @Override + public boolean equals(Object o) { + if (!(o instanceof CrontabEntry)) { + return false; + } + CrontabEntry that = (CrontabEntry) o; + return Objects.equal(getMinute(), that.getMinute()) + && Objects.equal(getHour(), that.getHour()) + && Objects.equal(getDayOfMonth(), that.getDayOfMonth()) + && Objects.equal(getMonth(), that.getMonth()) + && Objects.equal(getDayOfWeek(), that.getDayOfWeek()); + } + + @Override + public int hashCode() { + return Objects.hashCode(getMinute(), getHour(), getDayOfWeek(), getMonth(), getDayOfMonth()); + } + + private static class Parser { + private static final Pattern CRONTAB_ENTRY = Pattern.compile( + "^(?\\S+)" + + "\\s+(?\\S+)" + + "\\s+(?\\S+)" + + "\\s+(?\\S+)" + + "\\s+(?\\S+)$" + ); + + // A single time like "5", "10", "50". + private static final Pattern NUMBER = Pattern.compile("^(?\\d+)$"); + // A wildcard ("*"). + private static final Pattern WILDCARD = Pattern.compile("^\\*$"); + // A range like "1-2", "5-10", "14-50". + private static final Pattern RANGE = Pattern.compile("^(?\\d+)-(?\\d+)$"); + // A wildcard with a "skip" like "*/5", "*/10" + private static final Pattern WILDCARD_WITH_SKIP = Pattern.compile("^\\*/(?\\d+)$"); + // A range with a "skip" like "1-2/2", "0-59/5" + private static final Pattern RANGE_WITH_SKIP = + Pattern.compile("^(?\\d+)-(?\\d+)/(?\\d+)$"); + + private static final BiMap MONTH_NAMES = ImmutableBiMap + .builder() + .put("JAN", 1) + .put("FEB", 2) + .put("MAR", 3) + .put("APR", 4) + .put("MAY", 5) + .put("JUN", 6) + .put("JUL", 7) + .put("AUG", 8) + .put("SEP", 9) + .put("OCT", 10) + .put("NOV", 11) + .put("DEC", 12) + .build(); + + // NOTE: Unlike FreeBSD we don't allow "7" to mean Sunday. + private static final BiMap DAY_NAMES = ImmutableBiMap + .builder() + .put("SUN", 0) + .put("MON", 1) + .put("TUE", 2) + .put("WED", 3) + .put("THU", 4) + .put("FRI", 5) + .put("SAT", 6) + .build(); + + private final String rawMinute; + private final String rawHour; + private final String rawDayOfMonth; + private final String rawMonth; + private final String rawDayOfWeek; + + Parser(String schedule) throws IllegalArgumentException { + Matcher matcher = CRONTAB_ENTRY.matcher(schedule); + checkArgument(matcher.matches(), "Invalid cron schedule " + schedule); + + rawMinute = checkNotNull(matcher.group("minute")); + rawHour = checkNotNull(matcher.group("hour")); + rawDayOfMonth = checkNotNull(matcher.group("dayOfMonth")); + rawMonth = checkNotNull(matcher.group("month")); + rawDayOfWeek = checkNotNull(matcher.group("dayOfWeek")); + } + + CrontabEntry get() throws IllegalArgumentException { + return CrontabEntry.from( + parseMinute(), + parseHour(), + parseDayOfMonth(), + parseMonth(), + parseDayOfWeek()); + } + + private List getComponents(String rawField) { + return Splitter.on(",").omitEmptyStrings().splitToList(rawField); + } + + private String replaceNameAliases(String rawComponent, Map names) { + String component = rawComponent.toUpperCase(); + for (Map.Entry entry : names.entrySet()) { + if (component.contains(entry.getKey())) { + component = component.replaceAll(entry.getKey(), entry.getValue().toString()); + } + } + return component; + } + + private static RangeSet parseComponent( + final Range enclosure, + String rawComponent) throws IllegalArgumentException { + + if (WILDCARD.matcher(rawComponent).matches()) { + return ImmutableRangeSet.of(enclosure); + } + + Matcher matcher; + if ((matcher = NUMBER.matcher(rawComponent)).matches()) { + int number = Integer.parseInt(matcher.group("number")); + Range range = Range.singleton(number).canonical(DiscreteDomain.integers()); + + checkArgument(enclosure.encloses(range), enclosure + " does not enclose " + range); + + return ImmutableRangeSet.of(range); + } else if ((matcher = RANGE.matcher(rawComponent)).matches()) { + int lower = Integer.parseInt(matcher.group("lower")); + int upper = Integer.parseInt(matcher.group("upper")); + Range range = Range.closed(lower, upper).canonical(DiscreteDomain.integers()); + + checkArgument(enclosure.encloses(range), enclosure + " does not enclose " + range); + + return ImmutableRangeSet.of(range); + } else if ((matcher = WILDCARD_WITH_SKIP.matcher(rawComponent)).matches()) { + int skip = Integer.parseInt(matcher.group("skip")); + int start = enclosure.lowerEndpoint(); + + checkArgument(skip > 0); + + ImmutableRangeSet.Builder rangeSet = ImmutableRangeSet.builder(); + for (int i = start; enclosure.contains(i); i += skip) { + rangeSet.add(Range.singleton(i).canonical(DiscreteDomain.integers())); + } + return rangeSet.build(); + } else if ((matcher = RANGE_WITH_SKIP.matcher(rawComponent)).matches()) { + final int lower = Integer.parseInt(matcher.group("lower")); + final int upper = Integer.parseInt(matcher.group("upper")); + final int skip = Integer.parseInt(matcher.group("skip")); + Range range = Range.closed(lower, upper).canonical(DiscreteDomain.integers()); + + checkArgument(enclosure.encloses(range), enclosure + " does not enclose " + range); + checkArgument(skip > 0, "skip value " + skip + " must be >0"); + checkArgument(skip < upper, "skip value " + skip + " must be smaller than " + upper); + + ImmutableRangeSet.Builder rangeSet = ImmutableRangeSet.builder(); + for (int i = lower; range.contains(i); i += skip) { + rangeSet.add(Range.singleton(i).canonical(DiscreteDomain.integers())); + } + return rangeSet.build(); + } else { + throw new IllegalArgumentException( + "Cron schedule component " + rawComponent + " does not match any known patterns."); + } + } + + private RangeSet parseMinute() { + RangeSet minutes = TreeRangeSet.create(); + for (String component : getComponents(rawMinute)) { + minutes.addAll(parseComponent(MINUTE, component)); + } + return ImmutableRangeSet.copyOf(minutes); + } + + private RangeSet parseHour() { + RangeSet hours = TreeRangeSet.create(); + for (String component : getComponents(rawHour)) { + hours.addAll(parseComponent(HOUR, component)); + } + return ImmutableRangeSet.copyOf(hours); + } + + private RangeSet parseDayOfWeek() { + RangeSet daysOfWeek = TreeRangeSet.create(); + for (String component : getComponents(rawDayOfWeek)) { + daysOfWeek.addAll(parseComponent(DAY_OF_WEEK, replaceNameAliases(component, DAY_NAMES))); + } + return ImmutableRangeSet.copyOf(daysOfWeek); + } + + private RangeSet parseMonth() { + RangeSet months = TreeRangeSet.create(); + for (String component : getComponents(rawMonth)) { + months.addAll(parseComponent(MONTH, replaceNameAliases(component, MONTH_NAMES))); + } + return ImmutableRangeSet.copyOf(months); + } + + private RangeSet parseDayOfMonth() { + RangeSet daysOfMonth = TreeRangeSet.create(); + for (String component : getComponents(rawDayOfMonth)) { + daysOfMonth.addAll(parseComponent(DAY_OF_MONTH, component)); + } + return ImmutableRangeSet.copyOf(daysOfMonth); + } + } +} http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/d209a21c/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntryTest.java ---------------------------------------------------------------------- diff --git a/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntryTest.java b/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntryTest.java new file mode 100644 index 0000000..2bb848a --- /dev/null +++ b/src/main/java/org/apache/aurora/scheduler/cron/CrontabEntryTest.java @@ -0,0 +1,163 @@ +/** + * Copyright 2014 Apache Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.aurora.scheduler.cron; + +import java.util.List; +import java.util.Set; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Sets; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class CrontabEntryTest { + @Test + public void testHashCodeAndEquals() { + + List entries = ImmutableList.of( + CrontabEntry.parse("* * * * *"), + CrontabEntry.parse("0-59 * * * *"), + CrontabEntry.parse("0-57,58,59 * * * *"), + CrontabEntry.parse("* 23,1,2,4,0-22 * * *"), + CrontabEntry.parse("1-50,0,51-59 * * * sun-sat")); + + for (CrontabEntry lhs : entries) { + for (CrontabEntry rhs : entries) { + assertEquals(lhs, rhs); + } + } + + Set equivalentEntries = Sets.newHashSet(entries); + assertTrue(equivalentEntries.size() == 1); + } + + @Test + public void testEqualsCoverage() { + assertNotEquals(CrontabEntry.parse("* * * * *"), new Object()); + + assertNotEquals(CrontabEntry.parse("* * * * *"), CrontabEntry.parse("1 * * * *")); + assertEquals(CrontabEntry.parse("1,2,3 * * * *"), CrontabEntry.parse("1-3 * * * *")); + + assertNotEquals(CrontabEntry.parse("* 0-22 * * *"), CrontabEntry.parse("* * * * *")); + assertEquals(CrontabEntry.parse("* 0-23 * * *"), CrontabEntry.parse("* * * * *")); + + assertNotEquals(CrontabEntry.parse("1 1 1-30 * *"), CrontabEntry.parse("1 1 * * *")); + assertEquals(CrontabEntry.parse("1 1 1-31 * *"), CrontabEntry.parse("1 1 * * *")); + + assertNotEquals(CrontabEntry.parse("1 1 * JAN,FEB-NOV *"), CrontabEntry.parse("1 1 * * *")); + assertEquals(CrontabEntry.parse("1 1 * JAN,FEB-DEC *"), CrontabEntry.parse("1 1 * * *")); + + assertNotEquals(CrontabEntry.parse("* * * * SUN"), CrontabEntry.parse("* * * * SAT")); + assertEquals(CrontabEntry.parse("* * * * 0"), CrontabEntry.parse("* * * * SUN")); + } + + @Test + public void testSkip() { + assertEquals(CrontabEntry.parse("*/15 * * * *"), CrontabEntry.parse("0,15,30,45 * * * *")); + assertEquals( + CrontabEntry.parse("* */2 * * *"), + CrontabEntry.parse("0-59 0,2,4,6,8,10,12-23/2 * * *")); + } + + @Test + public void testToString() { + assertEquals("0-58 * * * *", CrontabEntry.parse("0,1-57,58 * * * *").toString()); + assertEquals("* * * * *", CrontabEntry.parse("* * * * *").toString()); + } + + @Test + public void testWildcards() { + CrontabEntry wildcardMinuteEntry = CrontabEntry.parse("* 1 1 1 *"); + assertEquals("*", wildcardMinuteEntry.getMinuteAsString()); + assertTrue(wildcardMinuteEntry.hasWildcardMinute()); + assertFalse(wildcardMinuteEntry.hasWildcardHour()); + assertFalse(wildcardMinuteEntry.hasWildcardDayOfMonth()); + assertFalse(wildcardMinuteEntry.hasWildcardMonth()); + assertTrue(wildcardMinuteEntry.hasWildcardDayOfWeek()); + + CrontabEntry wildcardHourEntry = CrontabEntry.parse("1 * 1 1 *"); + assertEquals("*", wildcardHourEntry.getHourAsString()); + assertFalse(wildcardHourEntry.hasWildcardMinute()); + assertTrue(wildcardHourEntry.hasWildcardHour()); + assertFalse(wildcardHourEntry.hasWildcardDayOfMonth()); + assertFalse(wildcardHourEntry.hasWildcardMonth()); + assertTrue(wildcardHourEntry.hasWildcardDayOfWeek()); + + CrontabEntry wildcardDayOfMonth = CrontabEntry.parse("1 1 * 1 *"); + assertEquals("*", wildcardDayOfMonth.getDayOfMonthAsString()); + assertFalse(wildcardDayOfMonth.hasWildcardMinute()); + assertFalse(wildcardDayOfMonth.hasWildcardHour()); + assertTrue(wildcardDayOfMonth.hasWildcardDayOfMonth()); + assertFalse(wildcardDayOfMonth.hasWildcardMonth()); + assertTrue(wildcardDayOfMonth.hasWildcardDayOfWeek()); + + CrontabEntry wildcardMonth = CrontabEntry.parse("1 1 1 * *"); + assertEquals("*", wildcardMonth.getMonthAsString()); + assertFalse(wildcardMonth.hasWildcardMinute()); + assertFalse(wildcardMonth.hasWildcardHour()); + assertFalse(wildcardMonth.hasWildcardDayOfMonth()); + assertTrue(wildcardMonth.hasWildcardMonth()); + assertTrue(wildcardMonth.hasWildcardDayOfWeek()); + + CrontabEntry wildcardDayOfWeek = CrontabEntry.parse("1 1 1 1 *"); + assertEquals("*", wildcardDayOfWeek.getDayOfWeekAsString()); + assertFalse(wildcardDayOfWeek.hasWildcardMinute()); + assertFalse(wildcardDayOfWeek.hasWildcardHour()); + assertFalse(wildcardDayOfWeek.hasWildcardDayOfMonth()); + assertFalse(wildcardDayOfWeek.hasWildcardMonth()); + assertTrue(wildcardDayOfWeek.hasWildcardDayOfWeek()); + } + + @Test + public void testEqualsIsCanonical() { + String rawEntry = "* * */3 * *"; + CrontabEntry input = CrontabEntry.parse(rawEntry); + assertNotEquals( + rawEntry + " is not the canonical form of " + input, + rawEntry, + input.toString()); + assertEquals( + "The form returned by toString is canonical", + input.toString(), + CrontabEntry.parse(input.toString()).toString()); + } + + @Test + public void testBadEntries() { + List badPatterns = ImmutableList.of( + "* * * * MON-SUN", + "* * **", + "0-59 0-59 * * *", + "1/1 * * * *", + "5 5 * MAR-JAN *", + "*/0 * * * *", + "0-59/0 * * * *", + "0-59/60 * * * *", + "* * * *, *", + "* * 1 * 1" + ); + + for (String pattern : badPatterns) { + assertNull(CrontabEntry.tryParse(pattern).orNull()); + } + } +}