diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5035cc..8ef97daf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,26 @@ + # Changelog ## Unreleased +## v0.22.0 + +### Highlights + +- When removing a project, Pakku will always ask whether you want to remove each of its dependencies. +- Added glob pattern support for specifying overrides, server_overrides and client_overrides, in [#70](https://github.com/juraj-hrivnak/Pakku/pull/70). + - This new implementation is optimized to work with large modpacks. + - For more info see [v0.20.0](https://github.com/juraj-hrivnak/Pakku/releases/tag/v0.20.0). + +### Fixes + +- Removed hyperlink from exported modpack file path messages. They didn't work properly on all platforms. + +### API + +- Breaking change: Refactored action errors to separate data classes. +- Created a mini framework for simplifying tests. + ## v0.21.0 - Improved projects updating, by @SettingDust in [#49](https://github.com/juraj-hrivnak/Pakku/pull/49). diff --git a/build.gradle.kts b/build.gradle.kts index c126a61e..eef34339 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,7 +16,7 @@ plugins { } group = "teksturepako.pakku" -version = "0.21.0" +version = "0.22.0" val nativeEnabled = false diff --git a/src/commonMain/kotlin/teksturepako/pakku/api/actions/export/Export.kt b/src/commonMain/kotlin/teksturepako/pakku/api/actions/export/Export.kt index db96fd05..bd2dc50b 100644 --- a/src/commonMain/kotlin/teksturepako/pakku/api/actions/export/Export.kt +++ b/src/commonMain/kotlin/teksturepako/pakku/api/actions/export/Export.kt @@ -1,5 +1,6 @@ package teksturepako.pakku.api.actions.export +import com.github.michaelbull.result.Result import com.github.michaelbull.result.get import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.runCatching @@ -46,12 +47,24 @@ suspend fun export( configFile: ConfigFile, platforms: List ): List = coroutineScope { + val overrides: Deferred>> = async { + configFile.getAllOverrides() + } + + val serverOverrides: Deferred>> = async { + configFile.getAllServerOverrides() + } + + val clientOverrides: Deferred>> = async { + configFile.getAllClientOverrides() + } + profiles.map { profile -> launch { profile.export( onError = { profile, error -> onError(profile, error) }, onSuccess = { profile, path, duration -> onSuccess(profile, path, duration) }, - lockFile, configFile, platforms + lockFile, configFile, platforms, overrides, serverOverrides, clientOverrides ) } } @@ -62,7 +75,10 @@ suspend fun ExportProfile.export( onSuccess: suspend (profile: ExportProfile, path: Path, duration: Duration) -> Unit, lockFile: LockFile, configFile: ConfigFile, - platforms: List + platforms: List, + overrides: Deferred>>, + serverOverrides: Deferred>>, + clientOverrides: Deferred>>, ) { if (this.dependsOn != null && this.dependsOn !in platforms) return @@ -88,8 +104,10 @@ suspend fun ExportProfile.export( val inputDirectory = Path(cacheDir.pathString, this.name) - val results: List = this.rules.filterNotNull() - .produceRuleResults({ onError(this, it) }, lockFile, configFile, this.name) + val results: List = this.rules.filterNotNull().produceRuleResults( + onError = { onError(this, it) }, + lockFile, configFile, this.name, overrides, serverOverrides, clientOverrides + ) // Run export rules val cachedPaths: List = results.resolveResults { onError(this, it) }.awaitAll().filterNotNull() + @@ -281,44 +299,71 @@ suspend fun List.finishResults( */ suspend fun List.produceRuleResults( onError: suspend (error: ActionError) -> Unit, - lockFile: LockFile, configFile: ConfigFile, workingSubDir: String -): List -{ - val results = this.fold(listOf>()) { acc, rule -> - acc + lockFile.getAllProjects().map { - rule to RuleContext.ExportingProject(it, lockFile, configFile, workingSubDir) - } + configFile.getAllOverrides().mapNotNull { result -> + lockFile: LockFile, configFile: ConfigFile, workingSubDir: String, + overrides: Deferred>>, + serverOverrides: Deferred>>, + clientOverrides: Deferred>>, +): List = coroutineScope { + val projects: Deferred> = async { + lockFile.getAllProjects().map { + RuleContext.ExportingProject(it, lockFile, configFile, workingSubDir) + } + } + + val overridesR: Deferred> = async { + overrides.await().mapNotNull { result -> result.resultFold( success = { - rule to RuleContext.ExportingOverride(it, OverrideType.OVERRIDE, lockFile, configFile, workingSubDir) + RuleContext.ExportingOverride(it, OverrideType.OVERRIDE, lockFile, configFile, workingSubDir) }, failure = { onError(it) null } ) - } + configFile.getAllServerOverrides().mapNotNull { result -> + } + } + + val serverOverridesR: Deferred> = async { + serverOverrides.await().mapNotNull { result -> result.resultFold( success = { - rule to RuleContext.ExportingOverride(it, OverrideType.SERVER_OVERRIDE, lockFile, configFile, workingSubDir) + RuleContext.ExportingOverride(it, OverrideType.SERVER_OVERRIDE, lockFile, configFile, workingSubDir) }, failure = { onError(it) null } ) - } + configFile.getAllClientOverrides().mapNotNull { result -> + } + } + + val clientOverridesR: Deferred> = async { + clientOverrides.await().mapNotNull { result -> result.resultFold( success = { - rule to RuleContext.ExportingOverride(it, OverrideType.CLIENT_OVERRIDE, lockFile, configFile, workingSubDir) + RuleContext.ExportingOverride(it, OverrideType.CLIENT_OVERRIDE, lockFile, configFile, workingSubDir) }, failure = { onError(it) null } ) - } + readProjectOverrides(configFile).map { - rule to RuleContext.ExportingProjectOverride(it, lockFile, configFile, workingSubDir) + } + } + + val projectOverrides: Deferred> = async { + readProjectOverrides(configFile).map { + RuleContext.ExportingProjectOverride(it, lockFile, configFile, workingSubDir) + } + } + + val deferredContexts = listOf(projects, overridesR, serverOverridesR, clientOverridesR, projectOverrides) + val contexts = deferredContexts.awaitAll().flatten() + + val results = this@produceRuleResults.fold(listOf>()) { acc, rule -> + acc + contexts.map { context -> + rule to context } }.map { (exportRule, ruleContext) -> exportRule.getResult(ruleContext) @@ -327,15 +372,15 @@ suspend fun List.produceRuleResults( val missing = results.filter { it.ruleContext is RuleContext.MissingProject }.flatMap { ruleResult -> - this.map { rule -> + this@produceRuleResults.map { rule -> val project = (ruleResult.ruleContext as RuleContext.MissingProject).project rule.getResult(RuleContext.MissingProject(project, lockFile, configFile, workingSubDir)) } } - val finished = this.map { rule -> + val finished = this@produceRuleResults.map { rule -> rule.getResult(Finished(lockFile, configFile, workingSubDir)) } - return results + missing + finished + return@coroutineScope results + missing + finished } diff --git a/src/commonMain/kotlin/teksturepako/pakku/api/data/ConfigFile.kt b/src/commonMain/kotlin/teksturepako/pakku/api/data/ConfigFile.kt index 383a6ab7..daa14ef7 100644 --- a/src/commonMain/kotlin/teksturepako/pakku/api/data/ConfigFile.kt +++ b/src/commonMain/kotlin/teksturepako/pakku/api/data/ConfigFile.kt @@ -101,14 +101,14 @@ data class ConfigFile( this.overrides.clear() } - fun getAllOverrides(): List> = this.overrides - .map { filterPath(it) } + suspend fun getAllOverrides(): List> = + this.overrides.expandWithGlob(Path(workingPath)).map { filterPath(it) } - fun getAllServerOverrides(): List> = this.serverOverrides - .map { filterPath(it) } + suspend fun getAllServerOverrides(): List> = + this.serverOverrides.expandWithGlob(Path(workingPath)).map { filterPath(it) } - fun getAllClientOverrides(): List> = this.clientOverrides - .map { filterPath(it) } + suspend fun getAllClientOverrides(): List> = + this.clientOverrides.expandWithGlob(Path(workingPath)).map { filterPath(it) } // -- PROJECTS -- diff --git a/src/commonMain/kotlin/teksturepako/pakku/cli/cmd/Export.kt b/src/commonMain/kotlin/teksturepako/pakku/cli/cmd/Export.kt index 8a6452bc..5e7c9e41 100644 --- a/src/commonMain/kotlin/teksturepako/pakku/cli/cmd/Export.kt +++ b/src/commonMain/kotlin/teksturepako/pakku/cli/cmd/Export.kt @@ -19,8 +19,6 @@ import teksturepako.pakku.cli.ui.pError import teksturepako.pakku.cli.ui.pSuccess import teksturepako.pakku.cli.ui.shortForm import teksturepako.pakku.io.toHumanReadableSize -import teksturepako.pakku.io.tryOrNull -import kotlin.io.path.absolutePathString import kotlin.io.path.fileSize class Export : CliktCommand() @@ -60,9 +58,8 @@ class Export : CliktCommand() }, onSuccess = { profile, file, duration -> val fileSize = file.fileSize().toHumanReadableSize() - val filePath = file.tryOrNull { it.absolutePathString() } ?: file.toString() - terminal.pSuccess("[${profile.name} profile] exported to '$filePath' ($fileSize) in ${duration.shortForm()}") + terminal.pSuccess("[${profile.name} profile] exported to '$file' ($fileSize) in ${duration.shortForm()}") }, lockFile, configFile, platforms ).joinAll() diff --git a/src/commonMain/kotlin/teksturepako/pakku/io/Glob.kt b/src/commonMain/kotlin/teksturepako/pakku/io/Glob.kt index 9a5fac80..ff6ceb5b 100644 --- a/src/commonMain/kotlin/teksturepako/pakku/io/Glob.kt +++ b/src/commonMain/kotlin/teksturepako/pakku/io/Glob.kt @@ -1,29 +1,63 @@ package teksturepako.pakku.io +import com.github.michaelbull.result.get +import com.github.michaelbull.result.runCatching +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import teksturepako.pakku.debug import teksturepako.pakku.debugIf import java.io.File import java.nio.file.FileSystems import java.nio.file.Path +import java.nio.file.PathMatcher import kotlin.io.path.* @OptIn(ExperimentalPathApi::class) -fun Path.listDirectoryEntriesRecursive(glob: String): Sequence -{ - val globPattern = "glob:${this.invariantSeparatorsPathString}/${glob.removePrefix("./")}" - val matcher = FileSystems.getDefault().getPathMatcher(globPattern) +suspend fun Path.walk(globPatterns: List): Sequence> = withContext(Dispatchers.IO) { + val matchers: List> = globPatterns.mapNotNull { input -> + val negating = input.startsWith("!") + val glob = (if (negating) input.removePrefix("!") else input).removePrefix("./") + + val globPattern = "glob:${this@walk.invariantSeparatorsPathString}/$glob" + runCatching { FileSystems.getDefault().getPathMatcher(globPattern) to negating }.get() + } + + return@withContext this@walk.walk(PathWalkOption.INCLUDE_DIRECTORIES) + .mapNotNull { path -> + val matcher = matchers.filter { (matcher) -> matcher.matches(path) } - return this.walk(PathWalkOption.INCLUDE_DIRECTORIES) - .filter { matcher.matches(it) } - .map { Path(it.absolutePathString().removePrefix(this.absolutePathString()).removePrefix(File.separator)) } + if (matcher.isEmpty()) return@mapNotNull null + + matcher to path + }.map { (matchers, path) -> + val resultPath = Path(path.absolutePathString().removePrefix(this@walk.absolutePathString()).removePrefix(File.separator)) + val negating = matchers.any { it.second } + + resultPath to negating + } } -fun List.expandWithGlob(inputPath: Path) = fold(listOf()) { acc, glob -> - if (glob.startsWith("!")) +suspend fun List.expandWithGlob(inputPath: Path): List +{ + val paths = mutableSetOf() + + val walk = inputPath.walk(this).toList().debug { + for ((path, negating) in it) + { + println("${if (negating) "!" else ""}$path") + } + } + + for ((path, _) in walk) { - acc - inputPath.listDirectoryEntriesRecursive(glob.removePrefix("!")).map { it.toString() }.toSet() + paths += path.toString() } - else + + for ((path, negating) in walk) { - acc + inputPath.listDirectoryEntriesRecursive(glob).map { it.toString() } + if (negating) paths -= path.toString() } -}.debugIf({ it.isNotEmpty() }) { println("expandWithGlob = $it") } + + return paths.toList().debugIf({ it.isNotEmpty() }) { println("expandWithGlob = $it") } +} + diff --git a/src/commonTest/kotlin/teksturepako/pakku/PakkuTest.kt b/src/commonTest/kotlin/teksturepako/pakku/PakkuTest.kt new file mode 100644 index 00000000..64d20854 --- /dev/null +++ b/src/commonTest/kotlin/teksturepako/pakku/PakkuTest.kt @@ -0,0 +1,60 @@ +package teksturepako.pakku + +import kotlinx.coroutines.runBlocking +import teksturepako.pakku.api.data.generatePakkuId +import teksturepako.pakku.api.data.workingPath +import kotlin.io.path.* +import kotlin.test.AfterTest +import kotlin.test.BeforeTest + +open class PakkuTest +{ + private var testName: String = "" + + protected open suspend fun `on-set-up`() + { + } + + protected open val teardown = true + + protected fun createTestFile(vararg path: String) + { + val file = Path(workingPath, *path) + runCatching { file.createParentDirectories() } + runCatching { file.createFile() } + } + + protected fun createTestDir(vararg path: String) + { + val dir = Path(workingPath, *path) + runCatching { dir.createParentDirectories() } + runCatching { dir.createDirectory() } + } + + @BeforeTest + fun `set-up-test`() + { + testName = this::class.simpleName ?: generatePakkuId() + + println("Setting up test: $testName") + + workingPath = "./build/test/$testName" + runCatching { Path("./build/test/$testName").createParentDirectories() } + runCatching { Path("./build/test/$testName").createDirectory() } + + runBlocking { this@PakkuTest.`on-set-up`() } + } + + @AfterTest + @OptIn(ExperimentalPathApi::class) + fun `tear-down-test`() + { + if (!teardown) return + + workingPath = "." + + println("Tearing down test: $testName") + + runCatching { Path("./build/test/$testName").deleteRecursively() } + } +} \ No newline at end of file diff --git a/src/commonTest/kotlin/teksturepako/pakku/api/actions/export/TestExport.kt b/src/commonTest/kotlin/teksturepako/pakku/api/actions/export/TestExport.kt deleted file mode 100644 index ad60638d..00000000 --- a/src/commonTest/kotlin/teksturepako/pakku/api/actions/export/TestExport.kt +++ /dev/null @@ -1,6 +0,0 @@ -package teksturepako.pakku.api.actions.export - -class TestExport -{ - -} \ No newline at end of file diff --git a/src/commonTest/kotlin/teksturepako/pakku/api/platforms/CurseForgeTest.kt b/src/commonTest/kotlin/teksturepako/pakku/api/platforms/CurseForgeTest.kt index 32a32275..9be86166 100644 --- a/src/commonTest/kotlin/teksturepako/pakku/api/platforms/CurseForgeTest.kt +++ b/src/commonTest/kotlin/teksturepako/pakku/api/platforms/CurseForgeTest.kt @@ -11,11 +11,6 @@ import kotlin.test.assertEquals class CurseForgeTest { - @Test - fun requestProject() - { - } - @Test fun sortByLoaders_WithValidLoaders_ShouldCompareCorrectly() { val files = listOf( diff --git a/src/commonTest/kotlin/teksturepako/pakku/api/projects/ProjectTest.kt b/src/commonTest/kotlin/teksturepako/pakku/api/projects/ProjectTest.kt index bb5723ff..d4d3db81 100644 --- a/src/commonTest/kotlin/teksturepako/pakku/api/projects/ProjectTest.kt +++ b/src/commonTest/kotlin/teksturepako/pakku/api/projects/ProjectTest.kt @@ -10,21 +10,21 @@ class ProjectTest @Test fun hasAliasOf_whenProjectHasAlias_returnsTrue() { - val project1 = mockk() { + val project1 = mockk { every { id } returns mutableMapOf("id1" to "id1") every { name } returns mutableMapOf("name1" to "name1") every { slug } returns mutableMapOf("slug1" to "slug1") every { aliases } returns mutableSetOf("id3") every { hasAliasOf(any()) } answers { callOriginal() } } - val project2 = mockk() { + val project2 = mockk { every { id } returns mutableMapOf("id2" to "id2") every { name } returns mutableMapOf("name2" to "name2") every { slug } returns mutableMapOf("slug2" to "slug2") every { aliases } returns mutableSetOf("name1") every { hasAliasOf(any()) } answers { callOriginal() } } - val project3 = mockk() { + val project3 = mockk { every { id } returns mutableMapOf("id3" to "id3") every { name } returns mutableMapOf("name3" to "name3") every { slug } returns mutableMapOf("slug3" to "slug3") diff --git a/src/commonTest/kotlin/teksturepako/pakku/cli/cmd/CfgPrjTest.kt b/src/commonTest/kotlin/teksturepako/pakku/cli/cmd/CfgPrjTest.kt index 5058d9a0..bcfc5546 100644 --- a/src/commonTest/kotlin/teksturepako/pakku/cli/cmd/CfgPrjTest.kt +++ b/src/commonTest/kotlin/teksturepako/pakku/cli/cmd/CfgPrjTest.kt @@ -3,6 +3,7 @@ package teksturepako.pakku.cli.cmd import com.github.ajalt.clikt.testing.test import com.github.michaelbull.result.runCatching import kotlinx.coroutines.runBlocking +import teksturepako.pakku.PakkuTest import teksturepako.pakku.api.data.ConfigFile import teksturepako.pakku.api.data.LockFile import teksturepako.pakku.api.data.workingPath @@ -16,21 +17,13 @@ import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertNotNull -@OptIn(ExperimentalPathApi::class) -class CfgPrjTest +class CfgPrjTest : PakkuTest() { - init - { - workingPath = "./build/test" - runCatching { Path("./build/test").deleteRecursively() } - runCatching { Path("./build/test").createDirectory() } - } - @Test fun `should fail without lock file`() { val cmd = CfgPrj() - val output = cmd.test("test -p test").output + val output = cmd.test("test --subpath test-subpath").output assertContains(output, "Could not read '$workingPath/${LockFile.FILE_NAME}'") } @@ -54,9 +47,12 @@ class CfgPrjTest val cmd = CfgPrj() val output = cmd.test("test -p test -s both -u latest -r true") + assertEquals("", output.stderr, "Command failed to execute") assertNotNull(ConfigFile.readOrNull(), "Config file should be created") + val config = ConfigFile.readOrNull()!!.projects["test"] + assertNotNull(config, "Project config should be created") assertEquals(UpdateStrategy.LATEST, config.updateStrategy) assertEquals(true, config.redistributable) diff --git a/src/commonTest/kotlin/teksturepako/pakku/cli/cmd/CfgTest.kt b/src/commonTest/kotlin/teksturepako/pakku/cli/cmd/CfgTest.kt index e602a432..9a0ad861 100644 --- a/src/commonTest/kotlin/teksturepako/pakku/cli/cmd/CfgTest.kt +++ b/src/commonTest/kotlin/teksturepako/pakku/cli/cmd/CfgTest.kt @@ -2,6 +2,7 @@ package teksturepako.pakku.cli.cmd import com.github.ajalt.clikt.testing.test import com.github.michaelbull.result.runCatching +import teksturepako.pakku.PakkuTest import teksturepako.pakku.api.data.ConfigFile import teksturepako.pakku.api.data.workingPath import teksturepako.pakku.api.projects.ProjectType @@ -10,22 +11,13 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull -@OptIn(ExperimentalPathApi::class) -class CfgTest +class CfgTest : PakkuTest() { - init - { - workingPath = "./build/test" - runCatching { Path("./build/test").deleteRecursively() } - runCatching { Path("./build/test").createDirectory() } - } - @Test fun `should success with options`() { val cmd = Cfg() - val output = - cmd.test("-n foo -v 1.20.1 -d bar -a test --mods-path ./dummy-mods --resource-packs-path ./dummy-resourcepacks --data-packs-path ./datapacks --worlds-path ./worlds --shaders-path ./shaders") + val output = cmd.test("-n foo -v 1.20.1 -d bar -a test --mods-path ./dummy-mods --resource-packs-path ./dummy-resourcepacks --data-packs-path ./datapacks --worlds-path ./worlds --shaders-path ./shaders") assertEquals("", output.stderr, "Command failed to execute") val config = ConfigFile.readOrNull() assertNotNull(config, "Config file should be created") diff --git a/src/commonTest/kotlin/teksturepako/pakku/cli/cmd/ExportTest.kt b/src/commonTest/kotlin/teksturepako/pakku/cli/cmd/ExportTest.kt index 7c5d59e8..20cbd077 100644 --- a/src/commonTest/kotlin/teksturepako/pakku/cli/cmd/ExportTest.kt +++ b/src/commonTest/kotlin/teksturepako/pakku/cli/cmd/ExportTest.kt @@ -2,27 +2,20 @@ package teksturepako.pakku.cli.cmd import com.github.ajalt.clikt.testing.test import com.github.michaelbull.result.runCatching +import teksturepako.pakku.PakkuTest import teksturepako.pakku.api.data.LockFile import teksturepako.pakku.api.data.workingPath import kotlin.io.path.* import kotlin.test.Test import kotlin.test.assertContains -@OptIn(ExperimentalPathApi::class) -class ExportTest +class ExportTest : PakkuTest() { private val testName = "TestModpack" private val testVersion = "1.0.0" private val testDescription = "This is a test modpack." private val testAuthor = "TestAuthor" - init - { - workingPath = "./build/test" - runCatching { Path("./build/test").deleteRecursively() } - runCatching { Path("./build/test").createDirectory() } - } - @Test fun `try without lock file & config file`() { diff --git a/src/commonTest/kotlin/teksturepako/pakku/io/GlobTest.kt b/src/commonTest/kotlin/teksturepako/pakku/io/GlobTest.kt new file mode 100644 index 00000000..3cdd745f --- /dev/null +++ b/src/commonTest/kotlin/teksturepako/pakku/io/GlobTest.kt @@ -0,0 +1,40 @@ +package teksturepako.pakku.io + +import kotlinx.coroutines.runBlocking +import teksturepako.pakku.PakkuTest +import teksturepako.pakku.api.data.workingPath +import kotlin.io.path.Path +import kotlin.test.Test +import kotlin.test.assertContains + +class GlobTest : PakkuTest() +{ + private val testFileName = "test_file.txt" + private val testDirName = "test_dir" + + override suspend fun `on-set-up`() + { + createTestFile(testFileName) + createTestDir(testDirName) + createTestFile(testDirName, testFileName) + } + + @Test + fun `test with basic file name`() = runBlocking { + val expandedGlob = listOf(testFileName).expandWithGlob(Path(workingPath)) + assertContains(expandedGlob, testFileName) + } + + @Test + fun `test with negating pattern`() = runBlocking { + val expandedGlob = listOf("!$testFileName").expandWithGlob(Path(workingPath)) + assert(testFileName !in expandedGlob) + } + + @Test + fun `test with dir pattern`() = runBlocking { + val expandedGlob = listOf("$testDirName/**").expandWithGlob(Path(workingPath)) + assert(testDirName !in expandedGlob) { "$expandedGlob should not contain $testDirName" } + assertContains(expandedGlob, "$testDirName/$testFileName") + } +} \ No newline at end of file