diff --git a/build.gradle b/build.gradle index 931c4f2..b642c3b 100644 --- a/build.gradle +++ b/build.gradle @@ -140,6 +140,10 @@ dependencies { annotationProcessor group: 'com.google.auto.service', name: 'auto-service', version: '1.0.1' compileOnly 'org.jetbrains:annotations:24.0.1' + + testImplementation("org.junit.jupiter:junit-jupiter-api:$junit_version") + testImplementation("org.junit.jupiter:junit-jupiter-engine:$junit_version") + testImplementation('org.assertj:assertj-core:3.25.1') } jar { @@ -175,6 +179,10 @@ shadowJar { from(tasks.gitLog.output) } +test { + useJUnitPlatform() +} + publishing { publications { mavenJava(MavenPublication) { diff --git a/docs/docs/.vitepress/config.js b/docs/docs/.vitepress/config.js index d47b4a8..ba42be8 100644 --- a/docs/docs/.vitepress/config.js +++ b/docs/docs/.vitepress/config.js @@ -18,6 +18,10 @@ export default { text: 'Setting up Camelot', link: '/get-started' }, + { + text: 'Formats', + link: '/formats' + }, { text: 'Modules', link: '/modules/', diff --git a/docs/docs/formats.md b/docs/docs/formats.md new file mode 100644 index 0000000..b7f8e4a --- /dev/null +++ b/docs/docs/formats.md @@ -0,0 +1,34 @@ +# Formats used by Camelot +This page explains various formats used by Camelot when displaying or parsing inputs (i.e. durations). + +## Durations + +Durations are composed of sequences of numbers and unit specifiers without a space in between. The number will multiply the specified unit. +Camelot supports the following units: +- `n`: nanoseconds +- `l`: milliseconds (1000000 nanoseconds) +- `s`: seconds (1000 milliseconds) +- `m`: minutes (60 seconds) +- `h`: hours (60 minutes) +- `d`: days (24 hours) +- `w`: weeks (7 days) +- `M`: months (30 days) +- `y`: years (365 days) + +Unit specifiers are **case-sensitive** and unrecognized specifiers will be considered _minutes_. +::: info +While nanoseconds and milliseconds are supported by the parser, most commands will not support that level of precision and will round them to the next second. +::: + +If a component of a duration is prefixed by a minus (`-`), it will be subtracted from the duration instead of added. +::: info +The format is not a maths equation. Parenthesis are not supported and the minus only applies to the first component after it. +`-12m4s` subtracts 12 minutes and *adds* 4 seconds, while `-12m-4s` subtracts 12 minutes and 4 seconds. +::: + +### Example durations +- `12m45s`: 12 minutes and 45 seconds +- `2y4M`: 2 years (730 days) and 4 months (120 days); 850 days in total +- `1w2d`: one week and 2 days +- `1d4h25m2s`: 1 day, 4 hours, 25 minutes and 2 seconds +- `2w-4d`: 2 weeks minus 4 days; 10 days in total diff --git a/gradle.properties b/gradle.properties index 322703c..a9a84a5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,3 +26,5 @@ j2html_version=1.6.0 angus_mail_version=2.0.1 json_version=20231013 groovy_version=4.0.21 + +junit_version=5.10.0 diff --git a/src/main/java/net/neoforged/camelot/util/DateUtils.java b/src/main/java/net/neoforged/camelot/util/DateUtils.java index 59243e2..f190e78 100644 --- a/src/main/java/net/neoforged/camelot/util/DateUtils.java +++ b/src/main/java/net/neoforged/camelot/util/DateUtils.java @@ -3,6 +3,7 @@ import javax.annotation.Nonnull; import java.time.Duration; import java.time.temporal.ChronoUnit; +import java.time.temporal.Temporal; import java.time.temporal.TemporalUnit; import java.util.ArrayList; import java.util.List; @@ -11,6 +12,9 @@ * Some utility methods for working with time. */ public class DateUtils { + private static final TemporalUnit MONTHS = exactDays(30); + private static final TemporalUnit YEARS = exactDays(365); + /** * Formats the given {@code duration} to a human-readable string.
* e.g. for 176893282 seconds, this method returns {@code 5 years 7 months 8 days 8 hours 31 minutes 40 seconds}. @@ -56,7 +60,7 @@ private static List splitInput(String str) { var builder = new StringBuilder(); for (final var ch : str.toCharArray()) { builder.append(ch); - if (!Character.isDigit(ch)) { + if (ch != '-' && !Character.isDigit(ch)) { list.add(builder.toString()); builder = new StringBuilder(); } @@ -71,7 +75,11 @@ public static Duration getDurationFromInput(String input) { final List data = splitInput(input); Duration duration = Duration.ofSeconds(0); for (final String dt : data) { - duration = duration.plusSeconds(decode(dt).toSeconds()); + if (dt.charAt(0) == '-') { + duration = duration.minusSeconds(decode(dt.substring(1)).toSeconds()); + } else { + duration = duration.plusSeconds(decode(dt).toSeconds()); + } } return duration; } @@ -90,8 +98,8 @@ public static Duration decode(@Nonnull final String time) { case 'h' -> ChronoUnit.HOURS; case 'd' -> ChronoUnit.DAYS; case 'w' -> ChronoUnit.WEEKS; - case 'M' -> ChronoUnit.MONTHS; - case 'y' -> ChronoUnit.YEARS; + case 'M' -> MONTHS; + case 'y' -> YEARS; default -> ChronoUnit.MINUTES; }; final long tm = Long.parseLong(time.substring(0, time.length() - 1)); @@ -105,4 +113,45 @@ public static Duration decode(@Nonnull final String time) { public static Duration of(long time, TemporalUnit unit) { return unit.isDurationEstimated() ? Duration.ofSeconds(time * unit.getDuration().getSeconds()) : Duration.of(time, unit); } + + private static TemporalUnit exactDays(long amount) { + var dur = Duration.ofDays(amount); + return new TemporalUnit() { + @Override + public Duration getDuration() { + return dur; + } + + @Override + public boolean isDurationEstimated() { + return false; + } + + @Override + public boolean isDateBased() { + return false; + } + + @Override + public boolean isTimeBased() { + return false; + } + + @Override + public boolean isSupportedBy(Temporal temporal) { + return temporal.isSupported(this); + } + + @SuppressWarnings("unchecked") + @Override + public R addTo(R temporal, long amount) { + return (R) temporal.plus(amount, this); + } + + @Override + public long between(Temporal temporal1Inclusive, Temporal temporal2Exclusive) { + return temporal1Inclusive.until(temporal2Exclusive, this); + } + }; + } } diff --git a/src/test/java/net/neoforged/camelot/test/DateUtilsTest.java b/src/test/java/net/neoforged/camelot/test/DateUtilsTest.java new file mode 100644 index 0000000..122fac2 --- /dev/null +++ b/src/test/java/net/neoforged/camelot/test/DateUtilsTest.java @@ -0,0 +1,41 @@ +package net.neoforged.camelot.test; + +import net.neoforged.camelot.util.DateUtils; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +public class DateUtilsTest { + @Test + void testSimpleParse() { + Assertions.assertThat(DateUtils.decode("45m")) + .hasMinutes(45); + Assertions.assertThat(DateUtils.decode("2h")) + .hasHours(2); + Assertions.assertThat(DateUtils.decode("2M")) + .hasDays(2 * 30); + Assertions.assertThat(DateUtils.decode("1y")) + .hasDays(365); + } + + @Test + void testMinuteFallback() { + Assertions.assertThat(DateUtils.decode("2K")) + .hasMinutes(2); + } + + @Test + void testMultipleParse() { + Assertions.assertThat(DateUtils.getDurationFromInput("12m45s")) + .hasSeconds(12 * 60 + 45); + Assertions.assertThat(DateUtils.getDurationFromInput("12y4M2w")) + .hasDays(12 * 365 + 4 * 30 + 2 * 7); + } + + @Test + void testNegativeParse() { + Assertions.assertThat(DateUtils.getDurationFromInput("2w-4d")) + .hasDays(10); + Assertions.assertThat(DateUtils.getDurationFromInput("1h4s-4m-12s")) + .hasSeconds(60 * 60 + 4 - 4 * 60 - 12); + } +}