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);
+ }
+}