LocalDateRange.java

/*
 * Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos
 *
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 *  * Redistributions of source code must retain the above copyright notice,
 *    this list of conditions and the following disclaimer.
 *
 *  * Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 *
 *  * Neither the name of JSR-310 nor the names of its contributors
 *    may be used to endorse or promote products derived from this software
 *    without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package org.threeten.extra;

import java.io.Serializable;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.Period;
import java.time.format.DateTimeParseException;
import java.time.temporal.TemporalAdjuster;
import java.util.Comparator;
import java.util.Objects;
import java.util.Spliterator;
import java.util.Spliterators;
import java.util.function.Consumer;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import org.joda.convert.FromString;
import org.joda.convert.ToString;

/**
 * A range of local dates.
 * <p>
 * A {@code LocalDateRange} represents a range of dates, from a start date to an end date.
 * Instances can be constructed from either a half-open or a closed range of dates.
 * Internally, the class stores the start and end dates, with the start inclusive and the end exclusive.
 * The end date is always greater than or equal to the start date.
 * <p>
 * The constants {@code LocalDate.MIN} and {@code LocalDate.MAX} can be used
 * to indicate an unbounded far-past or far-future. Note that there is no difference
 * between a half-open and a closed range when the end is {@code LocalDate.MAX}.
 * Empty ranges are allowed.
 * <p>
 * No range can end at {@code LocalDate.MIN} or {@code LocalDate.MIN.plusDays(1)}.
 * No range can start at {@code LocalDate.MAX} or {@code LocalDate.MAX.minusDays(1)}.
 * No empty range can exist at {@code LocalDate.MIN} or {@code LocalDate.MAX}.
 * <p>
 * Date ranges are not comparable. To compare the length of two ranges, it is
 * generally recommended to compare the number of days they contain.
 *
 * <h3>Implementation Requirements:</h3>
 * This class is immutable and thread-safe.
 * <p>
 * This class must be treated as a value type. Do not synchronize, rely on the
 * identity hash code or use the distinction between equals() and ==.
 */
public final class LocalDateRange
        implements Serializable {

    /**
     * The day after the MIN date.
     */
    private static final LocalDate MINP1 = LocalDate.MIN.plusDays(1);
    /**
     * The day before the MAX date.
     */
    private static final LocalDate MAXM1 = LocalDate.MAX.minusDays(1);
    /**
     * A range over the whole time-line.
     */
    public static final LocalDateRange ALL = new LocalDateRange(LocalDate.MIN, LocalDate.MAX);

    /**
     * Serialization version.
     */
    private static final long serialVersionUID = 3358656715467L;

    /**
     * The start date (inclusive).
     */
    private final LocalDate start;
    /**
     * The end date (exclusive).
     */
    private final LocalDate end;

    //-----------------------------------------------------------------------
    /**
     * Obtains a half-open range of dates, including the start and excluding the end.
     * <p>
     * The range includes the start date and excludes the end date, unless the end is {@code LocalDate.MAX}.
     * The end date must be equal to or after the start date.
     * This definition permits an empty range located at a specific date.
     * <p>
     * The constants {@code LocalDate.MIN} and {@code LocalDate.MAX} can be used
     * to indicate an unbounded far-past or far-future.
     * <p>
     * The start inclusive date must not be {@code LocalDate.MAX} or {@code LocalDate.MAX.minusDays(1)}.
     * The end inclusive date must not be {@code LocalDate.MIN} or {@code LocalDate.MIN.plusDays(1)}.
     * No empty range can exist at {@code LocalDate.MIN} or {@code LocalDate.MAX}.
     *
     * @param startInclusive  the inclusive start date, not null
     * @param endExclusive  the exclusive end date, not null
     * @return the half-open range, not null
     * @throws DateTimeException if the end is before the start,
     *   or the start date is {@code LocalDate.MAX} or {@code LocalDate.MAX.minusDays(1)},
     *   or the end date is {@code LocalDate.MIN} or {@code LocalDate.MIN.plusDays(1)}
     */
    public static LocalDateRange of(LocalDate startInclusive, LocalDate endExclusive) {
        Objects.requireNonNull(startInclusive, "startInclusive");
        Objects.requireNonNull(endExclusive, "endExclusive");
        return new LocalDateRange(startInclusive, endExclusive);
    }

    /**
     * Obtains a closed range of dates, including the start and end.
     * <p>
     * The range includes the start date and the end date.
     * The end date must be equal to or after the start date.
     * <p>
     * The constants {@code LocalDate.MIN} and {@code LocalDate.MAX} can be used
     * to indicate an unbounded far-past or far-future. In addition, an end date of
     * {@code LocalDate.MAX.minusDays(1)} will also create an unbounded far-future range.
     * <p>
     * The start inclusive date must not be {@code LocalDate.MAX} or {@code LocalDate.MAX.minusDays(1)}.
     * The end inclusive date must not be {@code LocalDate.MIN}.
     * 
     * @param startInclusive  the inclusive start date, not null
     * @param endInclusive  the inclusive end date, not null
     * @return the closed range
     * @throws DateTimeException if the end is before the start,
     *   or the start date is {@code LocalDate.MAX} or {@code LocalDate.MAX.minusDays(1)},
     *   or the end date is {@code LocalDate.MIN}
     */
    public static LocalDateRange ofClosed(LocalDate startInclusive, LocalDate endInclusive) {
        Objects.requireNonNull(startInclusive, "startInclusive");
        Objects.requireNonNull(endInclusive, "endInclusive");
        if (endInclusive.isBefore(startInclusive)) {
            throw new DateTimeException("Start date must be on or before end date");
        }
        LocalDate end = (endInclusive.equals(LocalDate.MAX) ? LocalDate.MAX : endInclusive.plusDays(1));
        return new LocalDateRange(startInclusive, end);
    }

    /**
     * Obtains an instance of {@code LocalDateRange} from the start and a period.
     * <p>
     * The end date is calculated as the start plus the duration.
     * The period must not be negative.
     * <p>
     * The constant {@code LocalDate.MIN} can be used to indicate an unbounded far-past.
     * <p>
     * The period must not be zero or one day when the start date is {@code LocalDate.MIN}.
     *
     * @param startInclusive  the inclusive start date, not null
     * @param period  the period from the start to the end, not null
     * @return the range, not null
     * @throws DateTimeException if the end is before the start,
     *  or if the period addition cannot be made
     * @throws ArithmeticException if numeric overflow occurs when adding the period
     */
    public static LocalDateRange of(LocalDate startInclusive, Period period) {
        Objects.requireNonNull(startInclusive, "startInclusive");
        Objects.requireNonNull(period, "period");
        if (period.isNegative()) {
            throw new DateTimeException("Period must not be zero or negative");
        }
        return new LocalDateRange(startInclusive, startInclusive.plus(period));
    }

    /**
     * Obtains an empty date range located at the specified date.
     * <p>
     * The empty range has zero length and contains no other dates or ranges.
     * An empty range cannot be located at {@code LocalDate.MIN}, {@code LocalDate.MIN.plusDays(1)},
     * {@code LocalDate.MAX} or {@code LocalDate.MAX.minusDays(1)}.
     *
     * @param date  the date where the empty range is located, not null
     * @return the empty range, not null
     * @throws DateTimeException if the date is {@code LocalDate.MIN}, {@code LocalDate.MIN.plusDays(1)},
     *   {@code LocalDate.MAX} or {@code LocalDate.MAX.minusDays(1)}
     */
    public static LocalDateRange ofEmpty(LocalDate date) {
        Objects.requireNonNull(date, "date");
        return new LocalDateRange(date, date);
    }

    /**
     * Obtains a range that is unbounded at the start and end.
     * 
     * @return the range, with an unbounded start and unbounded end
     */
    public static LocalDateRange ofUnbounded() {
        return ALL;
    }

    /**
     * Obtains a range up to, but not including, the specified end date.
     * <p>
     * The range includes all dates from the unbounded start, denoted by {@code LocalDate.MIN}, to the end date.
     * The end date is exclusive and cannot be {@code LocalDate.MIN} or {@code LocalDate.MIN.plusDays(1)}.
     * 
     * @param endExclusive  the exclusive end date, {@code LocalDate.MAX} treated as unbounded, not null
     * @return the range, with an unbounded start
     * @throws DateTimeException if the end date is {@code LocalDate.MIN} or  {@code LocalDate.MIN.plusDays(1)}
     */
    public static LocalDateRange ofUnboundedStart(LocalDate endExclusive) {
        return LocalDateRange.of(LocalDate.MIN, endExclusive);
    }

    /**
     * Obtains a range from and including the specified start date.
     * <p>
     * The range includes all dates from the start date to the unbounded end, denoted by {@code LocalDate.MAX}.
     * The start date is inclusive and cannot be {@code LocalDate.MAX} or {@code LocalDate.MAX.minusDays(1)}.
     * 
     * @param startInclusive  the inclusive start date, {@code LocalDate.MIN} treated as unbounded, not null
     * @return the range, with an unbounded end
     * @throws DateTimeException if the start date is {@code LocalDate.MAX} or {@code LocalDate.MAX.minusDays(1)}
     */
    public static LocalDateRange ofUnboundedEnd(LocalDate startInclusive) {
        return LocalDateRange.of(startInclusive, LocalDate.MAX);
    }

    //-----------------------------------------------------------------------
    /**
     * Obtains an instance of {@code LocalDateRange} from a text string such as
     * {@code 2007-12-03/2007-12-04}, where the end date is exclusive.
     * <p>
     * The string must consist of one of the following three formats:
     * <ul>
     * <li>a representations of an {@link LocalDate}, followed by a forward slash,
     *  followed by a representation of a {@link LocalDate}
     * <li>a representation of an {@link LocalDate}, followed by a forward slash,
     *  followed by a representation of a {@link Period}
     * <li>a representation of a {@link Period}, followed by a forward slash,
     *  followed by a representation of an {@link LocalDate}
     * </ul>
     *
     * @param text  the text to parse, not null
     * @return the parsed range, not null
     * @throws DateTimeParseException if the text cannot be parsed
     */
    @FromString
    public static LocalDateRange parse(CharSequence text) {
        Objects.requireNonNull(text, "text");
        for (int i = 0; i < text.length(); i++) {
            if (text.charAt(i) == '/') {
                char firstChar = text.charAt(0);
                if (firstChar == 'P' || firstChar == 'p') {
                    // period followed by date
                    Period duration = Period.parse(text.subSequence(0, i));
                    LocalDate end = LocalDate.parse(text.subSequence(i + 1, text.length()));
                    return LocalDateRange.of(end.minus(duration), end);
                } else {
                    // date followed by date or period
                    LocalDate start = LocalDate.parse(text.subSequence(0, i));
                    if (i + 1 < text.length()) {
                        char c = text.charAt(i + 1);
                        if (c == 'P' || c == 'p') {
                            Period duration = Period.parse(text.subSequence(i + 1, text.length()));
                            return LocalDateRange.of(start, start.plus(duration));
                        }
                    }
                    LocalDate end = LocalDate.parse(text.subSequence(i + 1, text.length()));
                    return LocalDateRange.of(start, end);
                }
            }
        }
        throw new DateTimeParseException("LocalDateRange cannot be parsed, no forward slash found", text, 0);
    }

    //-----------------------------------------------------------------------
    /**
     * Constructor.
     *
     * @param startInclusive  the start date, inclusive, validated not null
     * @param endExclusive  the end date, exclusive, validated not null
     */
    private LocalDateRange(LocalDate startInclusive, LocalDate endExclusive) {
        if (endExclusive.isBefore(startInclusive)) {
            throw new DateTimeException("End date must be on or after start date");
        }
        if (startInclusive.equals(MAXM1)) {
            throw new DateTimeException("Range must not start at LocalDate.MAX.minusDays(1)");
        }
        if (endExclusive.equals(MINP1)) {
            throw new DateTimeException("Range must not end at LocalDate.MIN.plusDays(1)");
        }
        if (endExclusive.equals(LocalDate.MIN) || startInclusive.equals(LocalDate.MAX)) {
            throw new DateTimeException("Empty range must not be at LocalDate.MIN or LocalDate.MAX");
        }
        this.start = startInclusive;
        this.end = endExclusive;
    }

    //-----------------------------------------------------------------------
    /**
     * Gets the start date of this range, inclusive.
     * <p>
     * This will return {@code LocalDate#MIN} if the range is unbounded at the start.
     * In this case, the range includes all dates into the far-past.
     * <p>
     * This never returns {@code LocalDate.MAX} or {@code LocalDate.MAX.minusDays(1)}.
     *
     * @return the start date
     */
    public LocalDate getStart() {
        return start;
    }

    /**
     * Gets the end date of this range, exclusive.
     * <p>
     * This will return {@code LocalDate.MAX} if the range is unbounded at the end.
     * In this case, the range includes all dates into the far-future.
     * <p>
     * This never returns {@code LocalDate.MIN} or {@code LocalDate.MIN.plusDays(1)}.
     *
     * @return the end date, exclusive
     */
    public LocalDate getEnd() {
        return end;
    }

    /**
     * Gets the end date of this range, inclusive.
     * <p>
     * This will return {@code LocalDate.MAX} if the range is unbounded at the end.
     * In this case, the range includes all dates into the far-future.
     * <p>
     * This returns the date before the end date.
     * <p>
     * This never returns {@code LocalDate.MIN}.
     * 
     * @return the end date, inclusive
     */
    public LocalDate getEndInclusive() {
        if (isUnboundedEnd()) {
            return LocalDate.MAX;
        }
        return end.minusDays(1);
    }

    //-----------------------------------------------------------------------
    /**
     * Checks if the range is empty.
     * <p>
     * An empty range occurs when the start date equals the end date.
     * <p>
     * An empty range is never unbounded.
     * 
     * @return true if the range is empty
     */
    public boolean isEmpty() {
        return start.equals(end);
    }

    /**
     * Checks if the start of the range is unbounded.
     * <p>
     * An unbounded range is never empty.
     * 
     * @return true if start is unbounded
     */
    public boolean isUnboundedStart() {
        return start.equals(LocalDate.MIN);
    }

    /**
     * Checks if the end of the range is unbounded.
     * <p>
     * An unbounded range is never empty.
     * 
     * @return true if end is unbounded
     */
    public boolean isUnboundedEnd() {
        return end.equals(LocalDate.MAX);
    }

    //-----------------------------------------------------------------------
    /**
     * Returns a copy of this range with the start date adjusted.
     * <p>
     * This returns a new instance with the start date altered.
     * Since {@code LocalDate} implements {@code TemporalAdjuster} any
     * local date can simply be passed in.
     * <p>
     * For example, to adjust the start to one week earlier:
     * <pre>
     *  range = range.withStart(date -&gt; date.minus(1, ChronoUnit.WEEKS));
     * </pre>
     * 
     * @param adjuster  the adjuster to use, not null
     * @return a copy of this range with the start date adjusted
     * @throws DateTimeException if the new start date is after the current end date
     */
    public LocalDateRange withStart(TemporalAdjuster adjuster) {
        return LocalDateRange.of(start.with(adjuster), end);
    }

    /**
     * Returns a copy of this range with the end date adjusted.
     * <p>
     * This returns a new instance with the exclusive end date altered.
     * Since {@code LocalDate} implements {@code TemporalAdjuster} any
     * local date can simply be passed in.
     * <p>
     * For example, to adjust the end to one week later:
     * <pre>
     *  range = range.withEnd(date -&gt; date.plus(1, ChronoUnit.WEEKS));
     * </pre>
     * 
     * @param adjuster  the adjuster to use, not null
     * @return a copy of this range with the end date adjusted
     * @throws DateTimeException if the new end date is before the current start date
     */
    public LocalDateRange withEnd(TemporalAdjuster adjuster) {
        return LocalDateRange.of(start, end.with(adjuster));
    }

    //-----------------------------------------------------------------------
    /**
     * Checks if this range contains the specified date.
     * <p>
     * This checks if the specified date is within the bounds of this range.
     * If this range is empty then this method always returns false.
     * Else if this range has an unbounded start then {@code contains(LocalDate#MIN)} returns true.
     * Else if this range has an unbounded end then {@code contains(LocalDate#MAX)} returns true.
     * 
     * @param date  the date to check for, not null
     * @return true if this range contains the date
     */
    public boolean contains(LocalDate date) {
        Objects.requireNonNull(date, "date");
        return start.compareTo(date) <= 0 && (date.compareTo(end) < 0 || isUnboundedEnd());
    }

    /**
     * Checks if this range encloses the specified range.
     * <p>
     * This checks if the bounds of the specified range are within the bounds of this range.
     * An empty range encloses itself.
     * 
     * @param other  the other range to check for, not null
     * @return true if this range contains all dates in the other range
     */
    public boolean encloses(LocalDateRange other) {
        Objects.requireNonNull(other, "other");
        return start.compareTo(other.start) <= 0 && other.end.compareTo(end) <= 0;
    }

    /**
     * Checks if this range abuts the specified range.
     * <p>
     * The result is true if the end of this range is the start of the other, or vice versa.
     * An empty range does not abut itself.
     *
     * @param other  the other range, not null
     * @return true if this range abuts the other range
     */
    public boolean abuts(LocalDateRange other) {
        Objects.requireNonNull(other, "other");
        return end.equals(other.start) ^ start.equals(other.end);
    }

    /**
     * Checks if this range is connected to the specified range.
     * <p>
     * The result is true if the two ranges have an enclosed range in common, even if that range is empty.
     * An empty range is connected to itself.
     * <p>
     * This is equivalent to {@code (overlaps(other) || abuts(other))}.
     *
     * @param other  the other range, not null
     * @return true if this range is connected to the other range
     */
    public boolean isConnected(LocalDateRange other) {
        Objects.requireNonNull(other, "other");
        return this.equals(other) || (start.compareTo(other.end) <= 0 && other.start.compareTo(end) <= 0);
    }

    /**
     * Checks if this range overlaps the specified range.
     * <p>
     * The result is true if the two ranges share some part of the time-line.
     * An empty range overlaps itself.
     * <p>
     * This is equivalent to {@code (isConnected(other) && !abuts(other))}.
     *
     * @param other  the time range to compare to, null means a zero length range now
     * @return true if the time ranges overlap
     */
    public boolean overlaps(LocalDateRange other) {
        Objects.requireNonNull(other, "other");
        return other.equals(this) || (start.compareTo(other.end) < 0 && other.start.compareTo(end) < 0);
    }

    //-----------------------------------------------------------------------
    /**
     * Calculates the range that is the intersection of this range and the specified range.
     * <p>
     * This finds the intersection of two ranges.
     * This throws an exception if the two ranges are not {@linkplain #isConnected(LocalDateRange) connected}.
     * 
     * @param other  the other range to check for, not null
     * @return the range that is the intersection of the two ranges
     * @throws DateTimeException if the ranges do not connect
     */
    public LocalDateRange intersection(LocalDateRange other) {
        Objects.requireNonNull(other, "other");
        if (isConnected(other) == false) {
            throw new DateTimeException("Ranges do not connect: " + this + " and " + other);
        }
        int cmpStart = start.compareTo(other.start);
        int cmpEnd = end.compareTo(other.end);
        if (cmpStart >= 0 && cmpEnd <= 0) {
            return this;
        } else if (cmpStart <= 0 && cmpEnd >= 0) {
            return other;
        } else {
            LocalDate newStart = (cmpStart >= 0 ? start : other.start);
            LocalDate newEnd = (cmpEnd <= 0 ? end : other.end);
            return LocalDateRange.of(newStart, newEnd);
        }
    }

    /**
     * Calculates the range that is the union of this range and the specified range.
     * <p>
     * This finds the union of two ranges.
     * This throws an exception if the two ranges are not {@linkplain #isConnected(LocalDateRange) connected}.
     * 
     * @param other  the other range to check for, not null
     * @return the range that is the union of the two ranges
     * @throws DateTimeException if the ranges do not connect
     */
    public LocalDateRange union(LocalDateRange other) {
        Objects.requireNonNull(other, "other");
        if (isConnected(other) == false) {
            throw new DateTimeException("Ranges do not connect: " + this + " and " + other);
        }
        int cmpStart = start.compareTo(other.start);
        int cmpEnd = end.compareTo(other.end);
        if (cmpStart >= 0 && cmpEnd <= 0) {
            return other;
        } else if (cmpStart <= 0 && cmpEnd >= 0) {
            return this;
        } else {
            LocalDate newStart = (cmpStart >= 0 ? other.start : start);
            LocalDate newEnd = (cmpEnd <= 0 ? other.end : end);
            return LocalDateRange.of(newStart, newEnd);
        }
    }

    /**
     * Calculates the smallest range that encloses this range and the specified range.
     * <p>
     * The result of this method will {@linkplain #encloses(LocalDateRange) enclose}
     * this range and the specified range.
     * 
     * @param other  the other range to check for, not null
     * @return the range that spans the two ranges
     */
    public LocalDateRange span(LocalDateRange other) {
        Objects.requireNonNull(other, "other");
        int cmpStart = start.compareTo(other.start);
        int cmpEnd = end.compareTo(other.end);
        LocalDate newStart = (cmpStart >= 0 ? other.start : start);
        LocalDate newEnd = (cmpEnd <= 0 ? other.end : end);
        return LocalDateRange.of(newStart, newEnd);
    }

    //-----------------------------------------------------------------------
    /**
     * Streams the set of dates included in the range.
     * <p>
     * This returns a stream consisting of each date in the range.
     * The stream is ordered.
     * 
     * @return the stream of dates from the start to the end
     */
    public Stream<LocalDate> stream() {
        long count = end.toEpochDay() - start.toEpochDay() + (isUnboundedEnd() ? 1 : 0);
        Spliterator<LocalDate> spliterator = new Spliterators.AbstractSpliterator<LocalDate>(
                count,
                Spliterator.IMMUTABLE | Spliterator.NONNULL | Spliterator.DISTINCT | Spliterator.ORDERED |
                        Spliterator.SORTED | Spliterator.SIZED | Spliterator.SUBSIZED) {

            private LocalDate current = start;
            
            @Override
            public boolean tryAdvance(Consumer<? super LocalDate> action) {
                if (current != null) {
                    if (current.isBefore(end)) {
                        action.accept(current);
                        current = current.plusDays(1);
                        return true;
                    }
                    if (current.equals(LocalDate.MAX)) {
                        action.accept(LocalDate.MAX);
                        current = null;
                        return true;
                    }
                }
                return false;
            }
            
            @Override
            public Comparator<? super LocalDate> getComparator() {
                return null;
            }
        };
        return StreamSupport.stream(spliterator, false);
    }

    //-----------------------------------------------------------------------
    /**
     * Checks if this range is after the specified date.
     * <p>
     * The result is true if every date in this range is after the specified date.
     * An empty range behaves as though it is a date for comparison purposes.
     *
     * @param date  the other date to compare to, not null
     * @return true if the start of this range is after the specified date
     */
    public boolean isAfter(LocalDate date) {
        return start.compareTo(date) > 0;
    }

    /**
     * Checks if this range is before the specified date.
     * <p>
     * The result is true if every date in this range is before the specified date.
     * An empty range behaves as though it is a date for comparison purposes.
     *
     * @param date  the other date to compare to, not null
     * @return true if the start of this range is before the specified date
     */
    public boolean isBefore(LocalDate date) {
        return end.compareTo(date) <= 0 && start.compareTo(date) < 0;
    }

    //-----------------------------------------------------------------------
    /**
     * Checks if this range is after the specified range.
     * <p>
     * The result is true if every date in this range is after every date in the specified range.
     * An empty range behaves as though it is a date for comparison purposes.
     *
     * @param other  the other range to compare to, not null
     * @return true if every date in this range is after every date in the other range
     */
    public boolean isAfter(LocalDateRange other) {
        return start.compareTo(other.end) >= 0 && !other.equals(this);
    }

    /**
     * Checks if this range is before the specified range.
     * <p>
     * The result is true if every date in this range is before every date in the specified range.
     * An empty range behaves as though it is a date for comparison purposes.
     *
     * @param range  the other range to compare to, not null
     * @return true if every date in this range is before every date in the other range
     */
    public boolean isBefore(LocalDateRange range) {
        return end.compareTo(range.start) <= 0 && !range.equals(this);
    }

    //-----------------------------------------------------------------------
    /**
     * Obtains the length of this range in days.
     * <p>
     * This returns the number of days between the start and end dates.
     * If the range is too large, the length will be {@code Integer.MAX_VALUE}.
     * Unbounded ranges return {@code Integer.MAX_VALUE}.
     *
     * @return the length in days, Integer.MAX_VALUE if unbounded or too large
     */
    public int lengthInDays() {
        if (isUnboundedStart() || isUnboundedEnd()) {
            return Integer.MAX_VALUE;
        }
        long length = end.toEpochDay() - start.toEpochDay();
        return length > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) length;
    }

    /**
     * Obtains the length of this range as a period.
     * <p>
     * This returns the {@link Period} between the start and end dates.
     * Unbounded ranges throw {@link ArithmeticException}.
     *
     * @return the period of the range
     * @throws ArithmeticException if the calculation exceeds the capacity of {@code Period},
     *   or the range is unbounded
     */
    public Period toPeriod() {
        if (isUnboundedStart() || isUnboundedEnd()) {
            throw new ArithmeticException("Unbounded range cannot be converted to a Period");
        }
        return Period.between(start, end);
    }

    //-----------------------------------------------------------------------
    /**
     * Checks if this range is equal to another range.
     * <p>
     * Compares this {@code LocalDateRange} with another ensuring that the two dates are the same.
     * Only objects of type {@code LocalDateRange} are compared, other types return false.
     *
     * @param obj  the object to check, null returns false
     * @return true if this is equal to the other range
     */
    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj instanceof LocalDateRange) {
            LocalDateRange other = (LocalDateRange) obj;
            return start.equals(other.start) && end.equals(other.end);
        }
        return false;
    }

    /**
     * A hash code for this range.
     *
     * @return a suitable hash code
     */
    @Override
    public int hashCode() {
        return start.hashCode() ^ end.hashCode();
    }

    //-----------------------------------------------------------------------
    /**
     * Outputs this range as a {@code String}, such as {@code 2007-12-03/2007-12-04}.
     * <p>
     * The output will be the ISO-8601 format formed by combining the
     * {@code toString()} methods of the two dates, separated by a forward slash.
     *
     * @return a string representation of this date, not null
     */
    @Override
    @ToString
    public String toString() {
        return start.toString() + '/' + end.toString();
    }

}