Skip to content

About

Project that holds utility class for file operations

Resources

Code of conduct

Contributing

Stars

6 stars

Watchers

3 watching

Forks

Repository files navigation

file-worker

Java operations on files and directory trees: creating, writing and reading, copying, renaming, moving and deleting, searching, comparing and merging files, and changing a file line by line.

Java CI with Gradle Coverage Status Mutation Score Open Issues Maven Central Javadoc MIT license Java

file-worker collects the operations on files and directories that otherwise get written again in every project. It builds on silly-io, which holds the smaller helpers it uses: streams, file names, and file and filename filters.

Why file-worker

With the JDK alone each of these is code of your own: copying a directory tree without some of its files, changing the suffix of every file in a tree, merging text files by a strategy or directory trees by the time their files were modified, comparing two files property by property, changing a file line by line. file-worker has a method for each, and its tests cover 99.3% of the lines and 96.7% of the branches of 21.0, and detect 96% of the mutants PIT makes; the build fails below 98%, 93% and 94% (Quality).

It is not small: it needs Java 25 and brings 13 jars at runtime, Apache Commons IO among them. If java.nio.file.Files covers what you need, such as reading and writing a file, copying, moving or deleting one, walking a tree or comparing two files with Files.mismatch, the JDK does it without a dependency. If you need a JDK older than 25, one jar instead of 13, or what file-worker does not have, such as watching or tailing files, Apache Commons IO is the better choice: it runs on Java 8, has no runtime dependency of its own, and its FileUtils copies, moves and deletes directory trees too.

Table of contents

Requirements

JDK 25 or newer, at build and at runtime: the published jar holds Java 25 bytecode and does not load on an older JVM. On the module path its name is io.github.astrapisixtynine.file.worker.

file-worker has eight runtime dependencies, which Gradle and Maven fetch with it: commons-io and commons-lang3 of Apache Commons, and six libraries of the same maintainer: silly-io, silly-strings, silly-collection, throwable, checksum-up and crypt-api. With their own dependencies they put 13 jars on the runtime class path.

Installation

Latest release on Maven Central: 21.0

Maven Central

Gradle (version catalog, gradle/libs.versions.toml):

[versions]
file-worker-version = "21.0"

[libraries]
file-worker = { module = "io.github.astrapi69:file-worker", version.ref = "file-worker-version" }
dependencies {
    implementation libs.file.worker
}

Gradle (plain):

dependencies {
    implementation "io.github.astrapi69:file-worker:21.0"
}

Maven:

<dependency>
    <groupId>io.github.astrapi69</groupId>
    <artifactId>file-worker</artifactId>
    <version>21.0</version>
</dependency>

21.0 has three breaking changes against 20.1, which the CHANGELOG lists under BREAKING, each with its migration: FileContentInfo entries of different files are no longer equal when their content is; the copy, write, move and create methods whose result could only be true return nothing, which needs a recompile; and every method that decodes a file throws a MalformedInputException for bytes that are not valid in its charset, where most of them replaced the bytes. A build of the same commit now gives the same bytes. From 20.0, the fixes of 20.1 apply as well, which the CHANGELOG names under FIXED.

Quick start

The examples are checked: each block runs in ReadmeExamplesTest, in a temporary directory, directory, with the values its comments name as assertions.

Creating files and directories. FileFactory.newFile creates a missing file, with the directories above it, and leaves an existing one as it is; the state it returns says which.

File summary = new File(directory, "reports/2026/summary.txt");
FileCreationState created = FileFactory.newFile(summary); // CREATED, with reports/2026
FileCreationState again = FileFactory.newFile(summary);   // ALREADY_EXISTS
File archive = new File(directory, "archive");
FileCreationState made = DirectoryFactory.newDirectory(archive); // CREATED

DirectoryFactory.newDirectory(File) creates one directory, and answers FAILED when its parent is missing; DirectoryFactory.newDirectories(Path) creates the parents too, as Files.createDirectories does. The ...Quietly variants of both factories throw a RuntimeException instead of an IOException.

Writing and reading.

File names = new File(directory, "names.txt");
StoreFileExtensions.toFile(names, List.of("Ada", "Grace"), StandardCharsets.UTF_8);
LineAppender.appendLines(names, "Linus");
List<String> lines = ReadFileExtensions.readLinesInList(names); // [Ada, Grace, Linus]
String second = ReadFileExtensions.readLine(names, 1);          // "Grace"
String text = ReadFileExtensions.fromFile(names);               // the whole file

Each line is written with the line end of the platform, and LineAppender creates a missing file. The readers in the example read UTF-8; fromFile and readLinesInList also take a charset. Every method of file-worker that decodes a file does so strictly: bytes that are not valid in the charset give a MalformedInputException, they are not replaced. StoreFileExtensions without a charset writes the JVM's default charset, which is UTF-8 unless the JVM is configured otherwise.

Copying files and directory trees.

// project holds README.md, src/Main.java and src/Main.class
File copy = new File(directory, "copy");
boolean copied = CopyDirectoryExtensions.copyDirectoryWithFileFilter(project, copy, null,
    SuffixFileFilter.of(".class", false), true); // true: README.md and src/Main.java
Path backup = CopyFileExtensions.newBackupOf(project.toPath().resolve("README.md"));

The arguments after the source and the destination are the filter of the files to copy, null for all of them, the filter of the files to leave out, here silly-io's SuffixFileFilter, and whether a copy takes the last-modified time of its original. The filter of the files to copy also decides which directories the copy enters: SuffixFileFilter.of(".java", true) accepts every directory as well. A copy without charsets copies the bytes, one with a source and a destination charset converts the text, and a copy of a File onto itself is refused with an IllegalArgumentException. newBackupOf copies a file to its name with .bak appended, next to it.

Renaming, moving and deleting.

// notes holds a.txt and old/b.txt
List<File> failed = RenameFileExtensions.changeAllFilenameSuffix(notes, ".txt", ".md"); // []
// notes holds a.md and old/b.md
File a = new File(notes, "a.md");
RenameFileExtensions.moveFile(a, new File(notes, "old/a.md"));         // old/a.md
boolean deleted = DeleteFileExtensions.delete(new File(notes, "old")); // true

changeAllFilenameSuffix goes through the whole tree and returns the files it could not rename. moveFile takes the new path of the file, not a directory to move it into: a move onto an existing directory is refused with an IllegalArgumentException, and the file stays where it is. delete deletes a file, or a directory with everything in it.

Searching.

// directory holds report.txt, report.log and archive/report-2025.txt

// report.txt and archive/report-2025.txt
List<File> texts = FileSearchExtensions.findFilesRecursive(directory, "*.txt");
// report.txt and report.log: findFiles stays in the directory
List<File> reports = FileSearchExtensions.findFiles(directory, "report.*");
// report.log
Set<File> logs = FileSearchExtensions.findFilesRecursive(directory,
    file -> file.getName().endsWith(".log"));

In a pattern * and ? are wildcards, and the rest is a regular expression: its dot stands for any character. The variants with a Predicate offer it every entry, directories included: findFiles those of the directory, findFilesRecursive those of the whole tree, the directory itself among them. findFilesWithPrefixAndExtension and its recursive variant take the prefix and the extension as text.

Comparing.

// a.txt and b.txt hold the same lines
IFileContentResultBean result = CompareFileExtensions.compareFileContentByBytes(a, b);
boolean sameContent = result.getContentEquality(); // true
boolean sameName = result.getNameEquality();       // false
boolean equal = CompareFileExtensions.compareFiles(a, b, true); // true

The result says for each property whether the two files agree: the absolute path, the name, the extension, the length, the last-modified time and the content. compareFileContentByLines takes \n and \r\n line ends as equal, compareFileContentByBytes does not; compareFiles(a, b, true) compares the length, then the lines. Every comparison refuses a directory with an IllegalArgumentException. SimpleCompareFileExtensions compares one property at a time, or the checksums: CRC32, Adler32, or an Algorithm of crypt-api.

Merging.

// en.txt holds yes and hello, de.txt yes and hallo
File words = new File(directory, "words.txt");
MergeFileExtensions.merge(List.of(en, de), words, MergeFileStrategy.SORTED_UNIQUE);
// words.txt: hallo, hello, yes

// 2025.csv holds id,name and 1,Ada; 2026.csv holds id,name and 2,Grace
File table = new File(directory, "all.csv");
MergeFileExtensions.merge(List.of(y2025, y2026), table, MergeFileStrategy.CSV_HEADER_MERGE);
// all.csv: id,name, 1,Ada and 2,Grace

The strategies are APPEND; UNIQUE_LINES, each line once, in the order it first appears; SORTED and SORTED_UNIQUE; CSV_HEADER_MERGE, the header of the first file once; MARKDOWN_SECTIONS, a --- line with an empty line on each side between the files; and BY_KEY, one line for each value of the first comma-separated column, the last one read, which mergeByKey does for another column or delimiter. The files are read and written as UTF-8 unless a charset is given. MergeDirectoryExtensions.merge(target, directories...) merges directory trees: for each relative path it copies the file of the given directories that was modified last into target, over the file that is there.

Changing a file line by line.

// todo, a Path, holds one, two and three
ModifyFileExtensions.modifyFile(todo, new DeleteLinesByIndexInFile(List.of(1))); // one, three
ModifyFileExtensions.modifyFile(todo,
    (index, line) -> line.toUpperCase() + System.lineSeparator()); // ONE, THREE

The FileChangeable gets the index and the text of each line and returns what is written for it, its line end included, or null to leave the line out. With a second Path, modifyFile writes there and leaves the file it reads as it is. It reads and writes UTF-8 unless it gets a charset.

Features

Package io.github.astrapi69.file... What it holds
(root) FileExtensions: the content type of a file, its absolute path without the name
create FileFactory and DirectoryFactory (files, directories and temporary directories; newFile(File) and newDirectory(File) answer with a FileCreationState), DirectoryStructureFactory and DirectoryStructureExtensions. The models FileInfo, FileContentInfo and FileCreationState are in create.model
write StoreFileExtensions (strings, lines and bytes into a file), LineAppender (lines appended to a file). Every method of WriteFileExtensions is deprecated and names its replacement
read ReadFileExtensions: a file as a string, as lines or as bytes, one line of it, the number of its lines, properties
copy CopyFileExtensions (files, byte for byte or from one charset to another, backups), CopyDirectoryExtensions (directory trees, with filters for the files to copy and to leave out)
rename RenameFileExtensions: rename and move files, change the suffix of one file or of every file in a tree, add the time of day to a name
delete DeleteFileExtensions: files and directory trees, or the files of a directory by suffix, prefix or filter
search FileSearchExtensions (files by pattern, suffix, prefix and extension or predicate, in a directory or its whole tree; the number of files; the first line that starts with a text), PathFinder (the source folders of a project in the Maven layout)
compare CompareFileExtensions and SimpleCompareFileExtensions: two files by path, name, extension, length, last-modified time, content or checksum; the equal files in one or two trees
merge MergeFileExtensions (text files, with a MergeFileStrategy), MergeDirectoryExtensions (directory trees: the file modified last wins, or a MergeStrategy decides)
modify ModifyFileExtensions (a file line by line through a FileChangeable; files concatenated into one), DeleteLinesByIndexInFile, FileTimeExtensions (the time of creation, of the last modification and of the last access)
sort SortFileExtensions: the lines of a file, by a Comparator
system SystemFileExtensions (the user's home, downloads, configuration and temporary directories, the working directory, the root, the Java home), SystemPropertiesExtensions (the common system properties)
exception the checked exceptions of the operations, such as FileDoesNotExistException and FileIsNotADirectoryException
tools MergeWithTemplate, a command line that copies or merges chosen directories of a template into a project. The module does not export this package

The api packages hold interfaces with default methods for most of these operations, for a class that wants them as its own: Copyable, Createable, Erasable, Readable, Renameable, Searchable and Storable; compare.api holds the result types of the comparisons, modify.api the FileChangeable. The Javadoc has the details.

Quality

As of 21.0, measured on the tree of RELEASE-21.0 with JDK 25, by ./gradlew test --rerun, ./gradlew build and ./gradlew pitest --rerun-tasks:

tests 1487 in 64 test classes, none failing; 36 are skipped: 34 disabled with @Disabled, 2 that run on Windows only
line and branch coverage (JaCoCo) 2168 of 2183 lines (99.3%), 945 of 977 branches (96.7%); the build fails below 98% of the lines or 93% of the branches
mutation score (PIT 1.30.1) 1131 of 1175 mutants detected (96%); the build fails below 94%, and build.gradle says what decides the 44 left

The CI build runs the tests, the coverage check and the mutation testing on every push to develop and master and on every pull request into them. It uploads the coverage to codecov.io and publishes the mutation score; the two badges above show develop.

Verifying the release signatures

The releases on Maven Central are signed with the OpenPGP key

5B2F A6B1 1E29 8BC0 9287  F423 D8C4 0351 8C49 CA75

Since 19.1 they are signed by its signing subkey

6D67 F844 2A6F CC96 BD7C  1B5E 9FCF 7C97 10E2 BD8D

valid until 2028-10-09; up to 19.0 they were signed by the primary key itself. The key, with the subkey, is on keys.openpgp.org. Fetch it once, then verify the signature next to each file:

gpg --keyserver hkps://keys.openpgp.org --recv-keys 5B2FA6B11E298BC09287F423D8C403518C49CA75
gpg --verify file-worker-21.0.jar.asc file-worker-21.0.jar

gpg answers Good signature and prints the Primary key fingerprint, and for a release signed by the subkey the Subkey fingerprint; both have to be the ones above. The warning that the key "is not certified with a trusted signature" only says that you have not marked it as trusted in your own keyring.

Releasing

What a release consists of, in the order it is run:

  1. One pull request with the CHANGELOG entry and projectVersion without -SNAPSHOT (docs: changelog for X.Y, and the version that carries it), the full build green on it, and scripts/check-reproducible.sh on the JDK .sdkmanrc names reporting the five artifacts identical.
  2. Tag the merge commit with ./gradlew tagRelease, which names it RELEASE-X.Y, and push the tag. publish.yml uploads to the Central Portal, where the maintainer releases it. A pushed tag is never moved or deleted; a failed run is fixed on the branch and re-run.
  3. Verify on Central: the jar, pom, .module, -sources.jar and -javadoc.jar, their sha1, and gpg --verify on the .asc.
  4. GitHub release from the CHANGELOG entry.
  5. master follows the release by a merge of the tag: on master, git merge --no-ff "RELEASE-X.Y^{commit}" -m "Merge tag RELEASE-X.Y into master". Before master is pushed, git diff RELEASE-X.Y master has to be empty; if it is not, nothing is pushed. Never a force push: master is always the latest release, nothing else.
  6. Open the next cycle: projectVersion to the next -SNAPSHOT.

Contributing

Pull requests are welcome: fork astrapi69/file-worker and open a pull request against develop.

Please add tests for your change; the coverage floor above holds for it too. The Gradle wrapper runs the usual tasks:

./gradlew build          # tests, coverage check, formatting check
./gradlew spotlessApply  # format, and write the license headers

The versions follow the Semantic Versioning guidelines, <major>.<minor>.<patch>. Questions, bug reports and feature requests belong on the issues page.

How this project is made

file-worker was written by its maintainer over many years; since 2026 he uses AI agents as a tool to maintain it. What the history shows of that, and what the maintainer decides: AI_USAGE.md.

License

MIT - see LICENSE.

Support this project

If file-worker is useful to you, a star on GitHub helps others find it. Sharing it or reporting a bug helps just as much.

If you would like to contribute financially, the channels are on the repository's Sponsor button, which reads .github/FUNDING.yml: GitHub Sponsors, Liberapay, Ko-fi and PayPal.

Credits

Maven Central for hosting the artifacts of this open source project
codecov.io for free code coverage reporting
javadoc.io for free javadoc hosting

No animals were harmed in the making of this library.

About

Project that holds utility class for file operations

Resources

Code of conduct

Contributing

Stars

6 stars

Watchers

3 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages