From fc5786951d2a64b5c1470ab67d422a2feaa6559e Mon Sep 17 00:00:00 2001 From: darwincereska Date: Wed, 19 Nov 2025 16:59:09 -0500 Subject: [PATCH] feat(features): added file options, fixed some things --- src/main/kotlin/org/kargs/ArgType.kt | 63 +++++ src/main/kotlin/org/kargs/Parser.kt | 32 ++- src/main/kotlin/org/kargs/ParserConfig.kt | 6 +- src/main/kotlin/org/kargs/Subcommand.kt | 1 + .../kotlin/org/kargs/tests/ArgTypeTest.kt | 217 ++++++++++++++++-- 5 files changed, 303 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/org/kargs/ArgType.kt b/src/main/kotlin/org/kargs/ArgType.kt index e1b425f..906110e 100644 --- a/src/main/kotlin/org/kargs/ArgType.kt +++ b/src/main/kotlin/org/kargs/ArgType.kt @@ -1,5 +1,7 @@ package org.kargs +import java.io.File + /** * Sealed class representing different argument types with conversion and validation logic */ @@ -84,6 +86,42 @@ sealed class ArgType(val typeName: String) { override fun getValidationDescription(): String = "optional value or flag" } + /** + * File path type with existence and permision validation + */ + class FilePath( + val mustExist: Boolean = false, + val mustBeFile: Boolean = false, + val mustBeDirectory: Boolean = false, + val mustBeReadable: Boolean = false, + val mustBeWritable: Boolean = false + ) : ArgType("File") { + override fun convert(value: String): File = File(value) + + override fun validate(value: File): Boolean { + return when { + mustExist && !value.exists() -> false + mustBeFile && !value.isFile -> false + mustBeDirectory && !value.isDirectory -> false + mustBeReadable && !value.canRead() -> false + mustBeWritable && !value.canWrite() -> false + else -> true + } + } + + override fun getValidationDescription(): String { + val conditions = mutableListOf() + if (mustExist) conditions.add("must exist") + if (mustBeFile) conditions.add("must be a file") + if (mustBeDirectory) conditions.add("must be a directory") + if (mustBeReadable) conditions.add("must be readable") + if (mustBeWritable) conditions.add("must be writable") + + return if (conditions.isEmpty()) "file path" else "file path (${conditions.joinToString(", ")})" + } + } + + companion object { val String: ArgType = StringType val Int: ArgType = IntType @@ -104,5 +142,30 @@ sealed class ArgType(val typeName: String) { * Create an optional value or flag */ fun optionalValue(defaultWhenPresent: String = "true") = OptionalValue(defaultWhenPresent) + + /** + * File that must exist and be a regular file + */ + fun existingFile() = FilePath(mustExist = true, mustBeFile = true) + + /** + * Directory that must exist + */ + fun existingDirectory() = FilePath(mustExist = true, mustBeDirectory = true) + + /** + * File that must exist and be readable + */ + fun readableFile() = FilePath(mustExist = true, mustBeFile = true, mustBeReadable = true) + + /** + * File that must be writable (can be created if doesn't exist) + */ + fun writableFile() = FilePath(mustBeWritable = true) + + /** + * Any file path (no validation) + */ + fun filePath() = FilePath() } } diff --git a/src/main/kotlin/org/kargs/Parser.kt b/src/main/kotlin/org/kargs/Parser.kt index e94c208..43455e8 100644 --- a/src/main/kotlin/org/kargs/Parser.kt +++ b/src/main/kotlin/org/kargs/Parser.kt @@ -25,6 +25,10 @@ class Parser( * @param args Array of command-line arguments */ fun parse(args: Array) { + if (handleVersionFlag(args)) { + return // Exit after showing version + } + if (args.isEmpty()) { if (config.helpOnEmpty) { printGlobalHelp() @@ -254,7 +258,8 @@ class Parser( * Print global help menu */ private fun printGlobalHelp() { - println(colorize("Usage: $programName [options]", Color.BOLD)) + val versionInfo = config.programVersion?.let { " (v$it)" } ?: "" + println(colorize("Usage: $programName$versionInfo [options]", Color.BOLD)) println() println(colorize("Commands:", Color.BOLD)) commands.forEach { cmd -> @@ -321,6 +326,31 @@ class Parser( // Exit gracefully instead of throwing // kotlin.system.exitProcess(1) } + + /** + * Check if version flag is present and handle it + */ + private fun handleVersionFlag(args: Array): Boolean { + if (config.programVersion == null) return false + + val versionFlags = listOf("--${config.versionLongName}", "-${config.versionShortName}") + + if (args.any { it in versionFlags }) { + printVersion() + return true // Indicates version was shown, should exit + } + + return false + } + + /** + * Print version information + */ + private fun printVersion() { + config.programVersion?.let { version -> + println("$programName version $version") + } + } } /** diff --git a/src/main/kotlin/org/kargs/ParserConfig.kt b/src/main/kotlin/org/kargs/ParserConfig.kt index 1d6763b..d6867df 100644 --- a/src/main/kotlin/org/kargs/ParserConfig.kt +++ b/src/main/kotlin/org/kargs/ParserConfig.kt @@ -9,7 +9,11 @@ data class ParserConfig( val helpOnEmpty: Boolean = true, // Show help when no args provided val caseSensitive: Boolean = true, val allowAbbreviations: Boolean = false, // Allow partial option matching - val programVersion: String? = null // Adds --version and -v flag if set + val programVersion: String? = null, // Adds --version and -v flag if set + + // Optional customize version flag names + val versionLongName: String = "version", + val versionShortName: String = "V" // Capital V to avoid conflicts with -v (verbose) ) { companion object { val DEFAULT = ParserConfig() diff --git a/src/main/kotlin/org/kargs/Subcommand.kt b/src/main/kotlin/org/kargs/Subcommand.kt index d17ff48..188f401 100644 --- a/src/main/kotlin/org/kargs/Subcommand.kt +++ b/src/main/kotlin/org/kargs/Subcommand.kt @@ -139,6 +139,7 @@ abstract class Subcommand( is ArgType.IntRange -> " <${type.min}-${type.max}>" is ArgType.Choice -> " <${type.choices.joinToString("|")}>" is ArgType.OptionalValue -> " [string]" + is ArgType.FilePath -> " " } } } diff --git a/src/test/kotlin/org/kargs/tests/ArgTypeTest.kt b/src/test/kotlin/org/kargs/tests/ArgTypeTest.kt index b4dda83..e36781c 100644 --- a/src/test/kotlin/org/kargs/tests/ArgTypeTest.kt +++ b/src/test/kotlin/org/kargs/tests/ArgTypeTest.kt @@ -7,9 +7,11 @@ import org.junit.jupiter.params.provider.ValueSource import org.kargs.ArgType import org.kargs.ArgumentParseException import kotlin.test.assertEquals +import kotlin.io.path.createTempFile +import kotlin.io.path.createTempDirectory class ArgTypeTest { - + @Test fun `String type converts correctly`() { val type = ArgType.String @@ -17,7 +19,7 @@ class ArgTypeTest { assertEquals("", type.convert("")) assertEquals("123", type.convert("123")) } - + @Test fun `Int type converts valid integers`() { val type = ArgType.Int @@ -25,7 +27,7 @@ class ArgTypeTest { assertEquals(-10, type.convert("-10")) assertEquals(0, type.convert("0")) } - + @Test fun `Int type throws on invalid input`() { val type = ArgType.Int @@ -33,28 +35,28 @@ class ArgTypeTest { assertThrows { type.convert("12.5") } assertThrows { type.convert("") } } - + @ParameterizedTest @ValueSource(strings = ["true", "TRUE", "yes", "YES", "1", "on", "ON"]) fun `Boolean type converts true values`(input: String) { val type = ArgType.Boolean assertEquals(true, type.convert(input)) } - + @ParameterizedTest @ValueSource(strings = ["false", "FALSE", "no", "NO", "0", "off", "OFF"]) fun `Boolean type converts false values`(input: String) { val type = ArgType.Boolean assertEquals(false, type.convert(input)) } - + @Test fun `Boolean type throws on invalid input`() { val type = ArgType.Boolean assertThrows { type.convert("maybe") } assertThrows { type.convert("2") } } - + @Test fun `Double type converts correctly`() { val type = ArgType.Double @@ -62,46 +64,233 @@ class ArgTypeTest { assertEquals(-2.5, type.convert("-2.5")) assertEquals(42.0, type.convert("42")) } - + @Test fun `Double type throws on invalid input`() { val type = ArgType.Double assertThrows { type.convert("not-a-number") } } - + @Test fun `IntRange validates bounds`() { val type = ArgType.intRange(1, 10) assertEquals(5, type.convert("5")) assertEquals(1, type.convert("1")) assertEquals(10, type.convert("10")) - + assertThrows { type.convert("0") } assertThrows { type.convert("11") } assertThrows { type.convert("-5") } } - + @Test fun `Choice validates options`() { val type = ArgType.choice("red", "green", "blue") assertEquals("red", type.convert("red")) assertEquals("green", type.convert("green")) assertEquals("blue", type.convert("blue")) - + assertThrows { type.convert("yellow") } assertThrows { type.convert("RED") } // case sensitive } - + @Test fun `IntRange provides correct validation description`() { val type = ArgType.intRange(5, 15) assertEquals("integer between 5 and 15", type.getValidationDescription()) } - + @Test fun `Choice provides correct validation description`() { val type = ArgType.choice("apple", "banana", "cherry") assertEquals("one of: apple, banana, cherry", type.getValidationDescription()) } + + @Test + fun `FilePath converts string to File`() { + val type = ArgType.filePath() + val result = type.convert("/path/to/file.txt") + assertEquals("/path/to/file.txt", result.path) + } + + @Test + fun `FilePath with no constraints allows any path`() { + val type = ArgType.filePath() + // Should not throw for non-existent files + val result = type.convert("/non/existent/path") + assertEquals("/non/existent/path", result.path) + assertEquals(true, type.validate(result)) + } + + @Test + fun `existingFile validates file exists and is file`() { + val type = ArgType.existingFile() + + // Create a temporary file for testing + val tempFile = kotlin.io.path.createTempFile().toFile() + tempFile.writeText("test content") + + try { + // Should work with existing file + val result = type.convert(tempFile.absolutePath) + assertEquals(true, type.validate(result)) + + // Should fail with non-existent file + val nonExistent = type.convert("/non/existent/file.txt") + assertEquals(false, type.validate(nonExistent)) + + } finally { + tempFile.delete() + } + } + + @Test + fun `existingFile fails validation on directory`() { + val type = ArgType.existingFile() + + // Create a temporary directory + val tempDir = kotlin.io.path.createTempDirectory().toFile() + + try { + val result = type.convert(tempDir.absolutePath) + assertEquals(false, type.validate(result)) // Should fail because it's a directory, not a file + + } finally { + tempDir.delete() + } + } + + @Test + fun `existingDirectory validates directory exists and is directory`() { + val type = ArgType.existingDirectory() + + // Create a temporary directory + val tempDir = kotlin.io.path.createTempDirectory().toFile() + + try { + // Should work with existing directory + val result = type.convert(tempDir.absolutePath) + assertEquals(true, type.validate(result)) + + // Should fail with non-existent directory + val nonExistent = type.convert("/non/existent/directory") + assertEquals(false, type.validate(nonExistent)) + + } finally { + tempDir.delete() + } + } + + @Test + fun `existingDirectory fails validation on file`() { + val type = ArgType.existingDirectory() + + // Create a temporary file + val tempFile = kotlin.io.path.createTempFile().toFile() + tempFile.writeText("test") + + try { + val result = type.convert(tempFile.absolutePath) + assertEquals(false, type.validate(result)) // Should fail because it's a file, not a directory + + } finally { + tempFile.delete() + } + } + + @Test + fun `readableFile validates file is readable`() { + val type = ArgType.readableFile() + + // Create a temporary file + val tempFile = kotlin.io.path.createTempFile().toFile() + tempFile.writeText("test content") + + try { + // Should work with readable file + val result = type.convert(tempFile.absolutePath) + assertEquals(true, type.validate(result)) + + // Make file unreadable (this might not work on all systems) + tempFile.setReadable(false) + assertEquals(false, type.validate(result)) + + } finally { + tempFile.setReadable(true) // Restore permissions + tempFile.delete() + } + } + + @Test + fun `writableFile validates file is writable`() { + val type = ArgType.writableFile() + + // Create a temporary file + val tempFile = kotlin.io.path.createTempFile().toFile() + tempFile.writeText("test content") + + try { + // Should work with writable file + val result = type.convert(tempFile.absolutePath) + assertEquals(true, type.validate(result)) + + // Make file read-only (this might not work on all systems) + tempFile.setWritable(false) + assertEquals(false, type.validate(result)) + + } finally { + tempFile.setWritable(true) // Restore permissions + tempFile.delete() + } + } + + @Test + fun `FilePath validation description includes constraints`() { + assertEquals("file path", ArgType.filePath().getValidationDescription()) + assertEquals("file path (must exist, must be a file)", ArgType.existingFile().getValidationDescription()) + assertEquals("file path (must exist, must be a directory)", ArgType.existingDirectory().getValidationDescription()) + assertEquals("file path (must exist, must be a file, must be readable)", ArgType.readableFile().getValidationDescription()) + assertEquals("file path (must be writable)", ArgType.writableFile().getValidationDescription()) + } + + @Test + fun `FilePath with multiple constraints`() { + val type = ArgType.FilePath( + mustExist = true, + mustBeFile = true, + mustBeReadable = true, + mustBeWritable = true + ) + + val tempFile = kotlin.io.path.createTempFile().toFile() + tempFile.writeText("test") + + try { + val result = type.convert(tempFile.absolutePath) + assertEquals(true, type.validate(result)) + + assertEquals( + "file path (must exist, must be a file, must be readable, must be writable)", + type.getValidationDescription() + ) + + } finally { + tempFile.delete() + } + } + + @Test + fun `FilePath handles non-existent parent directories`() { + val type = ArgType.writableFile() + + // Path with non-existent parent directory + val result = type.convert("/non/existent/parent/file.txt") + + // Should convert fine (validation happens separately) + assertEquals("/non/existent/parent/file.txt", result.path) + + // Validation should fail because parent doesn't exist + assertEquals(false, type.validate(result)) + } }