diff --git a/pom.xml b/pom.xml
index af01991..22b05e6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
4.0.0
jre-utils
- 0.1.10
+ 0.1.11
JreUtils
jar
diff --git a/src/main/java/info/unterrainer/commons/jreutils/DateUtils.java b/src/main/java/info/unterrainer/commons/jreutils/DateUtils.java
index 00b1c51..765dd7d 100644
--- a/src/main/java/info/unterrainer/commons/jreutils/DateUtils.java
+++ b/src/main/java/info/unterrainer/commons/jreutils/DateUtils.java
@@ -1,5 +1,6 @@
package info.unterrainer.commons.jreutils;
+import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
@@ -27,6 +28,10 @@ public static LocalDateTime epochToUtcLocalDateTime(final Long epoch) {
return Instant.ofEpochMilli(epoch).atZone(ZoneId.of("UTC")).toLocalDateTime();
}
+ public static LocalDateTime fileTimeToUtcLocalDateTime(final FileTime time) {
+ return time.toInstant().atZone(ZoneId.of("UTC")).toLocalDateTime();
+ }
+
public static int getWeekOf(final LocalDateTime dateTime) {
return dateTime.get(WeekFields.ISO.weekOfWeekBasedYear());
}
diff --git a/src/main/java/info/unterrainer/commons/jreutils/DoubleBufferedFile.java b/src/main/java/info/unterrainer/commons/jreutils/DoubleBufferedFile.java
new file mode 100644
index 0000000..a710aab
--- /dev/null
+++ b/src/main/java/info/unterrainer/commons/jreutils/DoubleBufferedFile.java
@@ -0,0 +1,225 @@
+package info.unterrainer.commons.jreutils;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.time.LocalDateTime;
+import java.util.Objects;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * Allows to access (read/write) a 'doubleBuffered file', meaning that there are
+ * two files which get written to alternately.
+ * The files are named pathToFile/file1.fileExtension and
+ * pathToFile/file2.fileExtension
+ *
+ * This allows for some fault-tolerance when it comes to corrupted files, since
+ * you always have, at least, the other, albeit older file at your disposal.
+ *
+ * You create this with a path to a virtual file that should not exist, since it
+ * misses the numbers.
+ * Use the methods to retrieve a write-handle to the correct (older) file and a
+ * read-handle as well (newer).
+ *
+ * Throws an IOException if something happens it cannot handle any longer (both
+ * files are locked for write-access and you're requesting write-access for
+ * example).
+ */
+@Accessors(fluent = true)
+public class DoubleBufferedFile {
+
+ @FunctionalInterface
+ public interface ConsumerWithIoException {
+ /**
+ * Performs this operation on the given argument.
+ *
+ * @param t the input argument
+ * @throws IOException if one occurs
+ */
+ void accept(T t) throws IOException;
+
+ /**
+ * Returns a composed {@code Consumer} that performs, in sequence, this
+ * operation followed by the {@code after} operation. If performing either
+ * operation throws an exception, it is relayed to the caller of the composed
+ * operation. If performing this operation throws an exception, the
+ * {@code after} operation will not be performed.
+ *
+ * @param after the operation to perform after this operation
+ * @return a composed {@code Consumer} that performs in sequence this operation
+ * followed by the {@code after} operation
+ * @throws NullPointerException if {@code after} is null
+ * @throws IOException if one occurs
+ */
+ default ConsumerWithIoException andThen(final ConsumerWithIoException super T> after) throws IOException {
+ Objects.requireNonNull(after);
+ return (final T t) -> {
+ accept(t);
+ after.accept(t);
+ };
+ }
+ }
+
+ @Data
+ class DoubleBufferedFileData {
+ private final Path path;
+ private boolean exists;
+ private LocalDateTime modified;
+ private boolean readable;
+ private boolean writable;
+
+ DoubleBufferedFileData(final Path path) {
+ super();
+ this.path = path;
+ probe();
+ }
+
+ void delete() throws IOException {
+ if (Files.exists(path, LinkOption.NOFOLLOW_LINKS))
+ Files.delete(path);
+ }
+
+ void probe() {
+ exists = Files.exists(path, LinkOption.NOFOLLOW_LINKS);
+
+ writable = Files.isWritable(path);
+ readable = Files.isReadable(path);
+
+ modified = null;
+ if (exists)
+ try {
+ modified = DateUtils.fileTimeToUtcLocalDateTime(
+ Files.readAttributes(path, BasicFileAttributes.class).lastModifiedTime());
+ } catch (IOException e) {
+ modified = null;
+ readable = false;
+ writable = false;
+ }
+ }
+
+ DoubleBufferedFileData withCheckedWrite() throws IOException {
+ if (!writable)
+ throw new IOException(String.format("There is no write-access for the given path [%s].", path));
+ return this;
+ }
+
+ DoubleBufferedFileData withCheckedRead() throws IOException {
+ if (!readable)
+ throw new IOException(String.format("There is no read-access for the given path [%s].", path));
+ return this;
+ }
+
+ BufferedWriter getBufferedWriter() throws IOException {
+ return Files.newBufferedWriter(path, Charset.forName("UTF-8"));
+ }
+ }
+
+ protected DoubleBufferedFileData path1;
+ protected DoubleBufferedFileData path2;
+
+ public DoubleBufferedFile(final Path pathWithoutNumber, final String fileExtension) {
+ path1 = new DoubleBufferedFileData(Path.of(pathWithoutNumber.toString() + "1." + fileExtension));
+ path2 = new DoubleBufferedFileData(Path.of(pathWithoutNumber.toString() + "2." + fileExtension));
+ }
+
+ public void delete() throws IOException {
+ path1.delete();
+ path2.delete();
+ }
+
+ public LocalDateTime getNewestModifiedTime() {
+ if (path1.modified() == null && path2.modified() == null)
+ return null;
+ if (path1.modified() == null)
+ return path2.modified();
+ if (path2.modified() == null)
+ return path1.modified();
+
+ if (path1.modified().compareTo(path2.modified()) > 0)
+ return path1.modified();
+ return path2.modified();
+ }
+
+ public LocalDateTime getOldestModifiedTime() {
+ if (path1.modified() == null && path2.modified() == null)
+ return null;
+ if (path1.modified() == null)
+ return path2.modified();
+ if (path2.modified() == null)
+ return path1.modified();
+
+ if (path1.modified().compareTo(path2.modified()) <= 0)
+ return path1.modified();
+ return path2.modified();
+ }
+
+ private DoubleBufferedFileData getOldestForWriteAccess() throws IOException {
+ path1.probe();
+ path2.probe();
+ if (!path1.exists() && !path2.exists())
+ return path1;
+ if (!path1.exists())
+ return path1;
+ if (!path2.exists())
+ return path2;
+
+ if (!path1.writable() && !path2.writable())
+ throw new IOException("Both files are locked for write-access.");
+ if (!path1.writable())
+ throw new IOException("File1 is locked for write-access.");
+ if (!path2.writable())
+ throw new IOException("File2 is locked for write-access.");
+
+ if (path1.modified() == null || path2.modified() == null)
+ throw new IOException("Could not read the modified-date from one of the files.");
+ if (path1.modified().compareTo(path2.modified()) <= 0)
+ return path1;
+ return path2;
+ }
+
+ private DoubleBufferedFileData getNewestForReadAccess() throws IOException {
+ path1.probe();
+ path2.probe();
+ if (!path1.exists() && !path2.exists())
+ throw new IOException("There is no file to read from, because both files are missing.");
+ if (!path1.exists())
+ return path2.withCheckedRead();
+ if (!path2.exists())
+ return path1.withCheckedRead();
+
+ if (!path1.readable() && !path2.readable())
+ throw new IOException("Both files are locked for read-access.");
+ if (!path1.readable())
+ throw new IOException("File1 is locked for read-access.");
+ if (!path2.readable())
+ throw new IOException("File2 is locked for read-access.");
+
+ if (path1.modified() == null || path2.modified() == null)
+ throw new IOException("Could not read the modified-date from one of the files.");
+ if (path1.modified().compareTo(path2.modified()) > 0)
+ return path1;
+ return path2;
+ }
+
+ public void write(final ConsumerWithIoException writeContentDelegate) throws IOException {
+ DoubleBufferedFileData p = getOldestForWriteAccess();
+ if (p.exists())
+ Files.delete(p.path());
+
+ try (BufferedWriter writer = p.getBufferedWriter()) {
+ writeContentDelegate.accept(writer);
+ }
+ p.probe();
+ }
+
+ public String read() throws IOException {
+ DoubleBufferedFileData p = getNewestForReadAccess();
+ return Files.readString(p.path());
+ }
+}
diff --git a/src/test/java/info/unterrainer/commons/jreutils/DoubleBufferedFileTests.java b/src/test/java/info/unterrainer/commons/jreutils/DoubleBufferedFileTests.java
new file mode 100644
index 0000000..e9a018a
--- /dev/null
+++ b/src/test/java/info/unterrainer/commons/jreutils/DoubleBufferedFileTests.java
@@ -0,0 +1,110 @@
+package info.unterrainer.commons.jreutils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIOException;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+
+import org.junit.jupiter.api.Test;
+
+public class DoubleBufferedFileTests {
+
+ @Test
+ public void readFromPreparedFileWorks() throws IOException {
+ DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("test"), "txt");
+ assertThat(dbf.read()).isEqualTo("test");
+ }
+
+ @Test
+ public void readThrowsExceptionWhenNoFilePresent() throws IOException {
+ DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt");
+ try {
+ dbf.delete();
+ assertThatIOException().isThrownBy(() -> dbf.read());
+ } finally {
+ dbf.delete();
+ }
+ }
+
+ @Test
+ public void readAfterWriteReturnsSameValue() throws IOException {
+ DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt");
+ try {
+ dbf.write(w -> w.write("test"));
+ assertThat(dbf.read()).isEqualTo("test");
+ } finally {
+ dbf.delete();
+ }
+ }
+
+ @Test
+ public void readAfterWriteAfterWriteReturnsNewValue() throws IOException {
+ DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt");
+ try {
+ dbf.write(w -> w.write("test_old"));
+ dbf.write(w -> w.write("test_new"));
+ assertThat(dbf.read()).isEqualTo("test_new");
+ } finally {
+ dbf.delete();
+ }
+ }
+
+ @Test
+ public void modifiedNewIsNullWithoutAnyValueWritten() throws IOException {
+ DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt");
+ LocalDateTime newest = dbf.getNewestModifiedTime();
+ assertThat(newest).isNull();
+ }
+
+ @Test
+ public void modifiedOldIsNullWithoutAnyValueWritten() throws IOException {
+ DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt");
+ LocalDateTime oldest = dbf.getOldestModifiedTime();
+ assertThat(oldest).isNull();
+ }
+
+ @Test
+ public void modifiedNewAndOldAreEqualWithOneValueWritten() throws IOException {
+ DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt");
+ try {
+ dbf.write(w -> w.write("test_old"));
+ LocalDateTime newest = dbf.getNewestModifiedTime();
+ LocalDateTime oldest = dbf.getOldestModifiedTime();
+ assertThat(newest).isEqualTo(oldest);
+ } finally {
+ dbf.delete();
+ }
+ }
+
+ @Test
+ public void modifiedNewAndOldDifferWithTwoValuesWritten() throws IOException {
+ DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt");
+ try {
+ dbf.write(w -> w.write("test_old"));
+ dbf.write(w -> w.write("test_new"));
+ LocalDateTime newest = dbf.getNewestModifiedTime();
+ LocalDateTime oldest = dbf.getOldestModifiedTime();
+ assertThat(newest).isAfter(oldest);
+ } finally {
+ dbf.delete();
+ }
+ }
+
+ @Test
+ public void modifiedAfterThreeWritesReturnsNewestValue() throws IOException {
+ DoubleBufferedFile dbf = new DoubleBufferedFile(Path.of("new"), "txt");
+ try {
+ dbf.write(w -> w.write("test_oldest"));
+ LocalDateTime oldest = dbf.getNewestModifiedTime();
+ dbf.write(w -> w.write("test_older"));
+ LocalDateTime older = dbf.getNewestModifiedTime();
+ dbf.write(w -> w.write("test_new"));
+ assertThat(dbf.getNewestModifiedTime()).isAfter(older);
+ assertThat(dbf.getNewestModifiedTime()).isAfter(oldest);
+ } finally {
+ dbf.delete();
+ }
+ }
+}
diff --git a/test1.txt b/test1.txt
new file mode 100644
index 0000000..30d74d2
--- /dev/null
+++ b/test1.txt
@@ -0,0 +1 @@
+test
\ No newline at end of file