Compare commits

..

6 Commits

Author SHA1 Message Date
8426dff044 Improve tests & validate FSEntry names (#4)
All checks were successful
Test Workflow / Lint and test library (push) Successful in 1m34s
Publish Workflow / Publish library (push) Successful in 2m7s
Contributes to #2 .

- Add some additional tests to existing functionality.
- Cleanup code to use more of `kotlin.io.*` instead of `java.nio.*`.
- Validate FSEntry names on object construction (and tests).
- Validate folders do not contain entries with duplicate names on `FSCreator.create()` (and tests).

Reviewed-on: #4
2024-01-07 16:09:23 +00:00
8aa21dff54 Deny cyclic FSFolders (#3)
All checks were successful
Test Workflow / Lint and test library (push) Successful in 3m56s
Contributes to #2 .

Handle cyclic folders explicitly, instead of relying on the filesystem.

Reviewed-on: #3
2024-01-07 14:56:16 +00:00
ca221f1907 Fix publish CI
All checks were successful
Publish Workflow / Publish library (push) Successful in 8m54s
Test Workflow / Lint and test library (push) Successful in 17m35s
2023-12-20 17:01:16 +00:00
e83b313766 README update
All checks were successful
Publish Workflow / Publish library (push) Successful in 9m4s
Test Workflow / Lint and test library (push) Successful in 17m32s
2023-12-20 15:57:35 +00:00
1c5cafb04d Improve documentation
Some checks failed
Test Workflow / Lint and test library (push) Has been cancelled
Publish Workflow / Publish library (push) Has been cancelled
2023-12-20 15:42:31 +00:00
71d98e540b Steup CI (#1)
Some checks failed
Test Workflow / Lint and test library (push) Has been cancelled
Setup Github actions CI to test and publish the library.

Reviewed-on: #1
2023-12-20 15:34:40 +00:00
7 changed files with 320 additions and 47 deletions

View File

@@ -1,11 +1,8 @@
name: Publish Workflow name: Publish Workflow
on: on:
pull_request:
branches:
- main
push: push:
branches: tags:
- main - v*
jobs: jobs:
publish: publish:
name: Publish library name: Publish library
@@ -33,11 +30,11 @@ jobs:
run: | run: |
export VERSION="$(echo ${{ github.ref_name }} | cut -c2-)" export VERSION="$(echo ${{ github.ref_name }} | cut -c2-)"
echo "Parsed version: '$VERSION'" echo "Parsed version: '$VERSION'"
echo "tinyvm_version=$VERSION" >> "$GITHUB_OUTPUT" echo "filesystem_version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Publish to Gitea package repository - name: Publish to Gitea package repository
env: env:
TINYVM_VERSION: ${{ steps.parse.outputs.tinyvm_version }} FILESYSTEM_VERSION: ${{ steps.parse.outputs.filesystem_version }}
GITEA_USERNAME: ${{ github.repository_owner }} GITEA_USERNAME: ${{ github.repository_owner }}
GITEA_TOKEN: ${{ secrets.deploy_token }} GITEA_TOKEN: ${{ secrets.deploy_token }}
run: ./gradlew publishAllPublicationsToGiteaRepository run: ./gradlew publishAllPublicationsToGiteaRepository

View File

@@ -3,6 +3,28 @@
This is a small project to make a very basic filesystem library in Kotlin and was created using the instructions below This is a small project to make a very basic filesystem library in Kotlin and was created using the instructions below
as part of my application to the JetBrains internship project "TeamCity Kotlin Script build step extension library". as part of my application to the JetBrains internship project "TeamCity Kotlin Script build step extension library".
The package is (very creatively) named `filesystem`.
## Usage
### Gradle
```kotlin
repositories {
// other repositories
maven { url "https://git.koval.net/api/packages/cyclane/maven" }
}
dependencies {
// other dependencies
implementation("net.koval.teamcity-build-step-extension-test-task:filesystem:0.1.0")
}
```
### Documentation
Use autocompletion and hover menus in your IDE, or download the
[generated HTML documentation](https://git.koval.net/cyclane/teamcity-build-step-extension-test-task/releases/download/v0.1.0/filesystem-0.1.0-javadoc.zip)
from the [latest release](https://git.koval.net/cyclane/teamcity-build-step-extension-test-task/releases).
## Instructions ## Instructions
Create a library implementing four classes: Create a library implementing four classes:

View File

@@ -6,7 +6,7 @@ plugins {
} }
group = "net.koval.teamcity-build-step-extension-test-task" group = "net.koval.teamcity-build-step-extension-test-task"
version = "0.0.0" version = System.getenv("FILESYSTEM_VERSION")
repositories { repositories {
mavenCentral() mavenCentral()

View File

@@ -1,34 +1,57 @@
package filesystem package filesystem
import java.nio.file.FileAlreadyExistsException import java.nio.file.FileAlreadyExistsException
import java.nio.file.FileSystemException import kotlin.io.path.Path
import java.nio.file.Files import kotlin.io.path.createDirectory
import java.nio.file.Path import kotlin.io.path.createFile
import kotlin.io.path.writeText
class FSCreator { class FSCreator {
// Create entry, leaving existing folders' contents, but overwriting existing files. /**
@Throws(FileSystemException::class) * Create entry, leaving existing folders' contents, but overwriting existing files.
* @throws CyclicFolderException Cyclic folders cannot be created.
* @throws DuplicateEntryNameException A folder or sub-folder contains entries with duplicate names.
*/
@Throws(CyclicFolderException::class, DuplicateEntryNameException::class)
fun create( fun create(
entryToCreate: FSEntry, entryToCreate: FSEntry,
destination: String, destination: String,
) { ) {
val queue = ArrayDeque<Pair<FSEntry, Path>>() // No point in running anything if we know the input is invalid.
queue.add(entryToCreate to Path.of(destination)) if (entryToCreate is FSFolder) {
if (entryToCreate.isCyclic()) {
throw CyclicFolderException()
}
if (entryToCreate.deepHasDuplicateNames()) {
throw DuplicateEntryNameException()
}
}
val queue =
ArrayDeque(
listOf(
entryToCreate to Path(destination),
),
)
while (queue.isNotEmpty()) { while (queue.isNotEmpty()) {
val (entry, dest) = queue.removeFirst() val (entry, dest) = queue.removeFirst()
val path = dest.resolve(entry.name) val path = dest.resolve(entry.name)
try { try {
when (entry) { when (entry) {
is FSFile -> Files.createFile(path) is FSFile -> path.createFile()
is FSFolder -> Files.createDirectory(path) is FSFolder -> path.createDirectory()
} }
} catch (_: FileAlreadyExistsException) { } catch (_: FileAlreadyExistsException) {
} // Allow files/folders to already exist. } // Allow files/folders to already exist.
when (entry) { when (entry) {
is FSFile -> Files.write(path, entry.content.toByteArray()) is FSFile -> path.writeText(entry.content)
is FSFolder -> queue.addAll(entry.entries.map { it to path }) is FSFolder -> queue.addAll(entry.entries.map { it to path })
} }
} }
} }
} }
class CyclicFolderException : Exception("Cyclic FSFolders are not supported")
class DuplicateEntryNameException : Exception("Folder contains entries with duplicate names")

View File

@@ -1,9 +1,59 @@
package filesystem package filesystem
import kotlin.io.path.Path
// Note sealed allows for simpler logic in FSCreator by guaranteeing FSFile and FSFolder are the only possible FSEntries // Note sealed allows for simpler logic in FSCreator by guaranteeing FSFile and FSFolder are the only possible FSEntries
// (as we expect), and it also makes the class abstract as required. // (as we expect), and it also implicitly makes the class abstract as required.
sealed class FSEntry(val name: String) sealed class FSEntry(val name: String) {
init {
val p = Path(name)
// Only allow single filenames (no paths or relative references (e.g. ".."))
if (p.toList().size != 1 || p.fileName != p.toFile().canonicalFile.toPath().fileName) {
throw InvalidEntryNameException(name)
}
}
}
class FSFile(name: String, val content: String) : FSEntry(name) class FSFile(name: String, val content: String) : FSEntry(name)
class FSFolder(name: String, val entries: List<FSEntry>) : FSEntry(name) class FSFolder(name: String, val entries: List<FSEntry>) : FSEntry(name) {
/**
* Check whether a folder is cyclic.
*/
fun isCyclic(): Boolean {
val seen = listOf(this).toHashSet<FSEntry>()
val queue = ArrayDeque(entries)
while (queue.isNotEmpty()) {
val entry = queue.removeFirst()
if (!seen.add(entry)) {
return true
}
if (entry is FSFolder) {
queue.addAll(entry.entries)
}
}
return false
}
/**
* Check whether a folder contains multiple entries with the same name.
*/
fun hasDuplicateNames(): Boolean {
val seen = HashSet<String>()
return entries.any { !seen.add(it.name) }
}
internal fun deepHasDuplicateNames(): Boolean {
val queue = ArrayDeque(listOf(this))
while (queue.isNotEmpty()) {
val entry = queue.removeFirst()
if (entry.hasDuplicateNames()) {
return true
}
queue.addAll(entry.entries.mapNotNull { it as? FSFolder })
}
return false
}
}
class InvalidEntryNameException(name: String) : Exception("Invalid FSEntry name: '$name'")

View File

@@ -1,10 +1,10 @@
package filesystem package filesystem
import org.junit.jupiter.api.* import org.junit.jupiter.api.*
import java.nio.file.FileSystemException import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.io.path.Path
import kotlin.io.path.createDirectory
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -14,17 +14,26 @@ class FSCreatorTest {
@BeforeEach @BeforeEach
fun `before each`() { fun `before each`() {
assertDoesNotThrow("should create _tmp directory") { assertDoesNotThrow("should create _tmp directory") {
Files.createDirectory(Path.of("_tmp")) Path("_tmp").createDirectory()
} }
} }
@AfterEach @AfterEach
fun `after each`() { fun `after each`() {
assertDoesNotThrow("should delete _tmp directory") { assertDoesNotThrow("should delete _tmp directory") {
deleteRecursive(Path.of("_tmp")) File("_tmp").deleteRecursively()
} }
} }
@Test
fun `create file`() {
val file = FSFile("test.txt", "This is a file")
assertDoesNotThrow("should create file") {
creator.create(file, "_tmp")
}
assertEquals(file.content, File("_tmp/", file.name).readText())
}
@Test @Test
fun `create entries`() { fun `create entries`() {
val readme = FSFile("README", "Hello World!") val readme = FSFile("README", "Hello World!")
@@ -62,10 +71,10 @@ class FSCreatorTest {
} }
// If objects don't exist, these functions will throw anyway, so don't explicitly check for existence. // If objects don't exist, these functions will throw anyway, so don't explicitly check for existence.
// Similarly, don't explicitly check if an object is a directory. // Similarly, don't explicitly check if an object is a directory.
assertEquals(readme.content, Files.readString(Path.of("_tmp/folder", readme.name))) assertEquals(readme.content, File("_tmp/folder", readme.name).readText())
assertEquals(gomod.content, Files.readString(Path.of("_tmp/folder", gomod.name))) assertEquals(gomod.content, File("_tmp/folder", gomod.name).readText())
assertEquals(maingo.content, Files.readString(Path.of("_tmp/folder", maingo.name))) assertEquals(maingo.content, File("_tmp/folder", maingo.name).readText())
assertEquals(helloworldgo.content, Files.readString(Path.of("_tmp/folder/utils", helloworldgo.name))) assertEquals(helloworldgo.content, File("_tmp/folder/utils", helloworldgo.name).readText())
} }
@Test @Test
@@ -81,7 +90,7 @@ class FSCreatorTest {
FSFile("hi", "hi"), FSFile("hi", "hi"),
), ),
), ),
FSFolder("another-folder", listOf()), FSFolder("folder", listOf()),
FSFile("1.txt", "One!"), FSFile("1.txt", "One!"),
FSFile("2.txt", "Two!"), FSFile("2.txt", "Two!"),
), ),
@@ -95,7 +104,8 @@ class FSCreatorTest {
"folder", "folder",
listOf( listOf(
FSFolder( FSFolder(
"another-folder", // Repeated name should be fine here (not throw)
"folder",
listOf( listOf(
FSFolder( FSFolder(
"secrets", "secrets",
@@ -112,30 +122,65 @@ class FSCreatorTest {
"_tmp", "_tmp",
) )
} }
assertEquals("hi", Files.readString(Path.of("_tmp/folder/sub-folder/hi"))) assertEquals("hi", File("_tmp/folder/sub-folder/hi").readText())
assertEquals("P4ssW0rd", Files.readString(Path.of("_tmp/folder/another-folder/secrets/secret"))) assertEquals("P4ssW0rd", File("_tmp/folder/folder/secrets/secret").readText())
assertEquals("One is a good number", Files.readString(Path.of("_tmp/folder/1.txt"))) assertEquals("One is a good number", File("_tmp/folder/1.txt").readText())
assertEquals("Two!", Files.readString(Path.of("_tmp/folder/2.txt"))) assertEquals("Two!", File("_tmp/folder/2.txt").readText())
assertEquals("Three!", Files.readString(Path.of("_tmp/folder/3.txt"))) assertEquals("Three!", File("_tmp/folder/3.txt").readText())
} }
@Test @Test
@Timeout(500, unit = TimeUnit.MILLISECONDS) // in case implementation starts trying to handle recursion @Timeout(500, unit = TimeUnit.MILLISECONDS) // in case implementation starts trying to handle cyclic folders
fun `create throws on recursive folder`() { fun `create throws on cyclic folder`() {
val files = mutableListOf<FSEntry>() val files = mutableListOf<FSEntry>()
val folder = FSFolder("folder", files) val folder = FSFolder("folder", files)
files.add(folder) files.add(folder)
assertThrows<FileSystemException> { assertThrows<CyclicFolderException> {
creator.create(folder, "_tmp")
}
}
@Test
@Timeout(500, unit = TimeUnit.MILLISECONDS)
fun `create throws on long cyclic folder`() {
val files = mutableListOf<FSEntry>()
val folder1 = FSFolder("folder", files)
val folder2 = FSFolder("folder2", listOf(folder1))
val folder3 = FSFolder("folder3", listOf(folder2))
val folder4 = FSFolder("folder4", listOf(folder3))
files.add(folder4)
assertThrows<CyclicFolderException> {
creator.create(folder4, "_tmp")
}
}
@Test
fun `create throws on folder with duplicate names`() {
val folder =
FSFolder(
"folder",
listOf(
FSFile("README.md", "# Test File"),
FSFile("hello-world.txt", "Hello World!"),
FSFolder(
"src",
listOf(
FSFile("README.md", "# Source files"),
FSFolder(
"solution",
listOf(
FSFile("solution.py", "print('1 + 1 = 1')"),
FSFile("tmp", "A temporary file"),
FSFolder("tmp", listOf()),
),
),
),
),
FSFolder("tmp", listOf()),
),
)
assertThrows<DuplicateEntryNameException> {
creator.create(folder, "_tmp") creator.create(folder, "_tmp")
} }
} }
} }
fun deleteRecursive(path: Path) {
if (Files.isDirectory(path)) {
for (child in Files.list(path)) {
deleteRecursive(child)
}
}
Files.delete(path)
}

View File

@@ -0,0 +1,136 @@
package filesystem
import org.junit.jupiter.api.*
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FSEntryTest {
@Test
fun `valid name entries`() {
assertDoesNotThrow("should construct FSFile and FSFolder without throwing") {
FSFile("A file with a name.tar.xz", "Contents")
FSFolder(".a folder with a name", listOf())
}
}
@Test
fun `invalid name entries`() {
assertThrows<InvalidEntryNameException> {
FSFile("File/here", "Contents")
}
assertThrows<InvalidEntryNameException> {
FSFolder("Folder/here", listOf())
}
assertThrows<InvalidEntryNameException> {
FSFolder(".", listOf())
}
assertThrows<InvalidEntryNameException> {
FSFolder("/", listOf())
}
}
@Test
fun `non-cyclic folder`() {
val folder =
FSFolder(
"folder",
listOf(
FSFolder("folder", listOf()),
FSFile("text.txt", "Hello!"),
),
)
assertFalse(folder.isCyclic())
}
@Test
fun `cyclic folder`() {
val files = mutableListOf<FSEntry>()
val folder = FSFolder("folder", files)
files.add(folder)
assertTrue(folder.isCyclic())
}
@Test
fun `long cyclic folder`() {
val files = mutableListOf<FSEntry>()
val folder1 = FSFolder("folder", files)
val folder2 = FSFolder("folder2", listOf(folder1))
val folder3 = FSFolder("folder3", listOf(folder2))
val folder4 = FSFolder("folder4", listOf(folder3))
files.add(folder4)
assertTrue(folder1.isCyclic())
assertTrue(folder2.isCyclic())
assertTrue(folder3.isCyclic())
assertTrue(folder4.isCyclic())
}
@Test
fun `no duplicate names folder`() {
val folder =
FSFolder(
"folder",
listOf(
FSFile("README.md", "# Test File"),
FSFile("hello-world.txt", "Hello World!"),
FSFolder(
"src",
listOf(
FSFile("README.md", "# Source files"),
FSFile("solution-1.py", "print('1 + 1 = 1')"),
FSFile("solution-2.py", "print('1 + 1 = 1')"),
),
),
FSFolder("tmp", listOf()),
),
)
assertFalse(folder.hasDuplicateNames())
assertFalse(folder.deepHasDuplicateNames())
}
@Test
fun `shallow duplicate names folder`() {
val folder =
FSFolder(
"folder",
listOf(
FSFile("README.md", "# Test File"),
FSFile("hello-world.txt", "Hello World!"),
FSFolder(
"src",
listOf(
FSFile("README.md", "# Source files"),
FSFile("solution-1.py", "print('1 + 1 = 1')"),
FSFile("solution-2.py", "print('1 + 1 = 1')"),
),
),
FSFolder("tmp", listOf()),
FSFile("tmp", "A temporary file"),
),
)
assertTrue(folder.hasDuplicateNames())
assertTrue(folder.deepHasDuplicateNames())
}
@Test
fun `deep duplicate names folder`() {
val folder =
FSFolder(
"folder",
listOf(
FSFile("README.md", "# Test File"),
FSFile("hello-world.txt", "Hello World!"),
FSFolder(
"src",
listOf(
FSFile("README.md", "# Source files"),
FSFile("solution-1.py", "print('1 + 1 = 1')"),
FSFile("solution-1.py", "print('1 + 1 = 1')"),
),
),
FSFolder("tmp", listOf()),
),
)
assertFalse(folder.hasDuplicateNames())
assertTrue(folder.deepHasDuplicateNames())
}
}