diff --git a/build.gradle.kts b/build.gradle.kts index 68e118c..5861697 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } group = "org.kargs" -version = "1.0.0" +version = "1.0.2" repositories { mavenCentral() @@ -13,7 +13,27 @@ repositories { dependencies { testImplementation(kotlin("test")) implementation(kotlin("stdlib")) - implementation("org.jetbrains.kotlin:kotlin-reflect:2.2.21") + implementation(kotlin("reflect")) + + // JUnit 5 testing dependencies + testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0") + testImplementation("org.junit.jupiter:junit-jupiter-engine:5.10.0") + testImplementation("org.junit.jupiter:junit-jupiter-params:5.10.0") + testImplementation(kotlin("test")) + + // For assertions + testImplementation("org.assertj:assertj-core:3.24.2") +} + +tasks.test { + useJUnitPlatform() + + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = true + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + } mavenPublishing { diff --git a/settings.gradle b/settings.gradle index 614d813..e64d0d7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = "karg" +rootProject.name = "kargs" diff --git a/src/main/kotlin/org/kargs/ArgType.kt b/src/main/kotlin/org/kargs/ArgType.kt index 1cfb6f6..e5ca732 100644 --- a/src/main/kotlin/org/kargs/ArgType.kt +++ b/src/main/kotlin/org/kargs/ArgType.kt @@ -1,23 +1,94 @@ package org.kargs +/** + * Sealed class representing different argument types with conversion and validation logic + */ sealed class ArgType(val typeName: String) { + /** + * Convert a string value to the target type + * @throws ArgumentParseException if conversion fails + */ abstract fun convert(value: String): T + /** + * Validate that a value is acceptable for this type + * @return true if valid, false otherwise + */ + open fun validate(value: T): Boolean = true + + /** + * Get a description of valid values for this type + */ + open fun getValidationDescription(): String = "any $typeName" + object StringType : ArgType("String") { override fun convert(value: String) = value } object IntType : ArgType("Int") { - override fun convert(value: String) = value.toInt() + override fun convert(value: String): kotlin.Int = value.toIntOrNull() ?: throw ArgumentParseException("`$value` is not a valid integer") } object BooleanType : ArgType("Boolean") { - override fun convert(value: String) = value.toBoolean() + override fun convert(value: String): kotlin.Boolean { + return when (value.lowercase()) { + "true", "yes", "1", "on" -> true + "false", "no", "0", "off" -> false + else -> throw ArgumentParseException("'$value' is not a valid boolean (true/false, yes/no, 1/0, on/off)") + } + } + } + + object DoubleType : ArgType("Double") { + override fun convert(value: String): kotlin.Double { + return value.toDoubleOrNull() + ?: throw ArgumentParseException("'$value' is not a valid number") + } + } + + /** + * Create a constrained integer type with min/max bounds + */ + class IntRange(private val min: kotlin.Int, private val max: kotlin.Int) : ArgType("Int") { + override fun convert(value: String): kotlin.Int { + val intValue = value.toIntOrNull() ?: throw ArgumentParseException("`$value` is not a valid integer") + if (intValue !in min..max) { + throw ArgumentParseException("`$value` must be between $min and $max") + } + return intValue + } + + override fun getValidationDescription(): String = "integer between $min and $max" + } + + /** + * Create an enum type from list of valid choices + */ + class Choice(private val choices: List) : ArgType("Choice") { + override fun convert(value: String): kotlin.String { + if (value !in choices) { + throw ArgumentParseException("`$value` is not a valid choice. Valid options: ${choices.joinToString(", ")}") + } + return value + } + + override fun getValidationDescription(): String = "one of: ${choices.joinToString(", ")}" } companion object { val String: ArgType = StringType val Int: ArgType = IntType val Boolean: ArgType = BooleanType + val Double: ArgType = DoubleType + + /** + * Create an integer type with bounds + */ + fun intRange(min: kotlin.Int, max: kotlin.Int) = IntRange(min, max) + + /** + * Create a choice type from valid options + */ + fun choice(vararg options: kotlin.String) = Choice(options.toList()) } } diff --git a/src/main/kotlin/org/kargs/Argument.kt b/src/main/kotlin/org/kargs/Argument.kt index 9ccbea4..b54d4ef 100644 --- a/src/main/kotlin/org/kargs/Argument.kt +++ b/src/main/kotlin/org/kargs/Argument.kt @@ -1,26 +1,40 @@ package org.kargs -import kotlin.properties.ReadWriteProperty -import kotlin.reflect.KProperty - +/** + * Represents a positional command-line argument + * + * @param type The type converter for this argument's value + * @param name The name of this argument (used in help) + * @param description Help text for this argument + * @param required Whether this argument must be provided + */ class Argument( val type: ArgType, val name: String, - val description: String = "", + description: String? = null, val required: Boolean = true -) : ReadWriteProperty { +) : KargsProperty(description) { + init { + require(name.isNotBlank()) { "Argument name cannot be blank" } + } - private var _value: T? = null + override fun parseValue(str: String) { + value = type.convert(str) + } - var value: T? - get() = _value - set(v) { _value = v } + override fun isValid(): Boolean { + return if (required) { + value != null && type.validate(value!!) + } else { + value?.let { type.validate(it) } ?: true + } + } - override fun getValue(thisRef: Any?, property: KProperty<*>): T? = _value - override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { _value = value } - - fun parseValue(input: String) { - _value = type.convert(input) + override fun getValidationError(): String? { + return when { + required && value == null -> "Argument '$name' is required" + value != null && !type.validate(value!!) -> "Invalid value for argument '$name': expected ${type.getValidationDescription()}" + else -> null + } } } - diff --git a/src/main/kotlin/org/kargs/Flag.kt b/src/main/kotlin/org/kargs/Flag.kt index 91f2d2f..122cd34 100644 --- a/src/main/kotlin/org/kargs/Flag.kt +++ b/src/main/kotlin/org/kargs/Flag.kt @@ -1,24 +1,53 @@ package org.kargs -import kotlin.properties.ReadWriteProperty -import kotlin.reflect.KProperty - +/** + * Represents a boolean flag that doesn't take a value (e.g. --verbose, -v) + * + * @param longName The long form name (used with --) + * @param shortName The short form name (used with -) + * @param description Help text for this flag + * @param defaultValue Default value (typically false) + */ class Flag( val longName: String, val shortName: String? = null, - val description: String = "", - val default: Boolean = false -) : ReadWriteProperty { + description: String? = null, + private val defaultValue: Boolean = false, +) : KargsProperty(description) { - private var _value = default + private var wasExplicitlySet = false - var value: Boolean - get() = _value - set(v) { _value = v } + init { + value = defaultValue - override fun getValue(thisRef: Any?, property: KProperty<*>): Boolean = _value - override fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) { _value = value } + // Validate names + require(longName.isNotBlank()) { "Long name cannot be blank" } + require(!longName.startsWith("-")) { "Long name should not start with dashes" } + shortName?.let { + require(it.length == 1) { "Short name must be exactly one character" } + require(!it.startsWith("-")) { "Short name should not start with dashes" } + } + } - fun setFlag() { _value = true } + override fun parseValue(str: String) { + value = when (str.lowercase()) { + "true", "yes", "1", "on" -> true + "false", "no", "0", "off" -> false + else -> throw ArgumentParseException("Invalid flag value: $str") + } + wasExplicitlySet = true + } + + /** + * Set the flag to true (called when flag is present) + */ + fun setFlag() { + value = true + wasExplicitlySet = true + } + + /** + * Check if this flag was explicitly set + */ + fun isSet(): Boolean = wasExplicitlySet } - diff --git a/src/main/kotlin/org/kargs/KargsProperty.kt b/src/main/kotlin/org/kargs/KargsProperty.kt new file mode 100644 index 0000000..765347d --- /dev/null +++ b/src/main/kotlin/org/kargs/KargsProperty.kt @@ -0,0 +1,51 @@ +package org.kargs + +import kotlin.reflect.KProperty + +/** + * Base class for all command-line argument properties (options, flags, arguments) + */ +abstract class KargsProperty(val description: String? = null) { + var value: T? = null + protected set + + internal lateinit var parent: Subcommand + + /** + * Parse a string value into the appropiate type + * @throws ArgumentParseException if parsing fails + */ + abstract fun parseValue(str: String) + + /** + * Validate the current value + * @return true if valid, false otherwise + */ + open fun isValid(): Boolean = true + + /** + * Get validation error message if value is invalid + */ + open fun getValidationError(): String? = null + + /** + * Property delagate setup - registers this property with its parent subcommand + */ + operator fun provideDelegate(thisRef: Subcommand, prop: KProperty<*>): KargsProperty { + parent = thisRef + parent.registerProperty(this) + return this + } + + /** + * Property delagate getter + */ + operator fun getValue(thisRef: Subcommand, property: KProperty<*>): T? = value + + /* + * Property delagate setter + */ + operator fun setValue(thisRef: Subcommand, property: KProperty<*>, value: T?) { + this.value = value + } +} diff --git a/src/main/kotlin/org/kargs/Option.kt b/src/main/kotlin/org/kargs/Option.kt index 73740e4..9c3108e 100644 --- a/src/main/kotlin/org/kargs/Option.kt +++ b/src/main/kotlin/org/kargs/Option.kt @@ -1,28 +1,67 @@ package org.kargs -import kotlin.properties.ReadWriteProperty -import kotlin.reflect.KProperty - +/** + * Represents a command-line option that takes a value (e.g. --output file.txt) + * + * @param type The type converter for this options's value + * @param longName The long form name (used with --) + * @param shortName The short form name (used with -) + * @param description Help text for this option + * @param required Whether this option must be provided + * @param defaultValue Default value if not provided + */ class Option( val type: ArgType, val longName: String, val shortName: String? = null, - val description: String = "", + description: String? = null, val required: Boolean = false, - val default: T? = null -) : ReadWriteProperty { + private val defaultValue: T? = null +) : KargsProperty(description) { - private var _value: T? = default + private var wasExplicitlySet = false - var value: T? - get() = _value - set(v) { _value = v } + init { + // Set the default value if provided + defaultValue?.let { value = it } - override fun getValue(thisRef: Any?, property: KProperty<*>): T? = _value - override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { _value = value } - - fun parseValue(input: String) { - _value = type.convert(input) + // Validate names + require(longName.isNotBlank()) { "Long name cannot be blank" } + require(!longName.startsWith("-")) { "Long name should not start with dashes" } + shortName?.let { + require(it.length == 1) { "Short name must be exactly one character" } + require(!it.startsWith("-")) { "Short name should not start with dashes" } + } } -} + override fun parseValue(str: String) { + value = type.convert(str) + wasExplicitlySet = true + } + + override fun isValid(): Boolean { + return if (required) { + value != null && type.validate(value!!) + } else { + value?.let { type.validate(it) } ?: true + } + } + + override fun getValidationError(): String? { + return when { + required && value == null -> "Option --$longName is required" + value != null && !type.validate(value!!) -> "Invalid value for --$longName: expected ${type.getValidationDescription()}" + else -> null + } + } + + /** + * Check if the option has been set (different from default) + */ + fun isSet(): Boolean = wasExplicitlySet + + /** + * Get the value or default + */ + fun getValueOrDefault(): T? = value ?: defaultValue +} diff --git a/src/main/kotlin/org/kargs/Parser.kt b/src/main/kotlin/org/kargs/Parser.kt index 2fed578..508ab78 100644 --- a/src/main/kotlin/org/kargs/Parser.kt +++ b/src/main/kotlin/org/kargs/Parser.kt @@ -1,98 +1,292 @@ package org.kargs -class Parser(val programName: String) { +/** + * Main argument parser that handles command-line argument parsing and routing to subcommands. + * + * @param programName The name of the program (used in help messages) + * @param config Configuration options for the parser behavior + */ +class Parser( + val programName: String, + private val config: ParserConfig = ParserConfig.DEFAULT +) { private val commands = mutableListOf() + /** + * Register one or more subcommands with this parser + */ fun subcommands(vararg cmds: Subcommand) { commands.addAll(cmds) } + /** + * Parse the provided command-line arguments and execute the appropiate command + * + * @param args Array of command-line arguments + */ fun parse(args: Array) { if (args.isEmpty()) { + if (config.helpOnEmpty) { + printGlobalHelp() + } + return + } + + // Check for global help + if (args.contains("--help") || args.contains("-h")) { printGlobalHelp() return } val cmdName = args[0] - val cmd = commands.firstOrNull { it.name == cmdName || it.aliases.contains(cmdName) } + val cmd = findCommand(cmdName) if (cmd == null) { - println("Unknown command: $cmdName") - printGlobalHelp() + printError("Unknown command: $cmdName") + if (!config.strictMode) { + printGlobalHelp() + } return } + // Check for command specific help if (args.contains("--help") || args.contains("-h")) { cmd.printHelp() return } - // Reflection: find all Option, Flag, Argument properties - val props = cmd::class.members.filterIsInstance>() - val options = props.mapNotNull { it.getter.call(cmd) as? Option<*> } - val flags = props.mapNotNull { it.getter.call(cmd) as? Flag } - val arguments = props.mapNotNull { it.getter.call(cmd) as? Argument<*> } + try { + parseCommandArgs(cmd, args.sliceArray(1 until args.size)) + validateRequiredOptions(cmd) + cmd.execute() + } catch (e: ArgumentParseException) { + printError(e.message ?: "Parse error") + cmd.printHelp() + throw e + } + } + + /** + * Find a command by name or alias + */ + private fun findCommand(name: String): Subcommand? { + val searchName = if (config.caseSensitive) name else name.lowercase() + return commands.firstOrNull { cmd -> + val cmdName = if (config.caseSensitive) cmd.name else cmd.name.lowercase() + val aliases = if (config.caseSensitive) cmd.aliases else cmd.aliases.map { it.lowercase() } + cmdName == searchName || aliases.contains(searchName) + } + } + + /** + * Parse arguments for a specific command + */ + private fun parseCommandArgs(cmd: Subcommand, args: Array) { + var i = 0 + val positionalArgs = mutableListOf() - // Parse args starting at index 1 - var i = 1 while (i < args.size) { val arg = args[i] + when { arg.startsWith("--") -> { val key = arg.removePrefix("--") - val option = options.firstOrNull { it.longName == key } - val flag = flags.firstOrNull { it.longName == key } - - when { - option != null -> { - i++ - if (i >= args.size) throw IllegalArgumentException("Missing value for option --$key") - option.parseValue(args[i]) - } - flag != null -> flag.setFlag() - else -> println("Unknown option --$key") - } + i = parseLongOption(cmd, key, args, i) } - arg.startsWith("-") -> { + arg.startsWith("-") && arg.length > 1 -> { val key = arg.removePrefix("-") - val option = options.firstOrNull { it.shortName == key } - val flag = flags.firstOrNull { it.shortName == key } - - when { - option != null -> { - i++ - if (i >= args.size) throw IllegalArgumentException("Missing value for option -$key") - option.parseValue(args[i]) - } - flag != null -> flag.setFlag() - else -> println("Unknown option -$key") - } + i = parseShortOption(cmd, key, args, i) } else -> { - // Positional arguments - val nextArg = arguments.firstOrNull { it.value == null } - if (nextArg != null) { - nextArg.parseValue(arg) - } else { - println("Unexpected argument: $arg") - } + positionalArgs.add(arg) } } i++ } - - // Execute the command - cmd.execute() + // Handle positional arguments + parsePositionalArguments(cmd, positionalArgs) } - private fun printGlobalHelp() { - println("Usage: $programName [options]") - println("\nCommands:") - commands.forEach { - println(" ${it.name}\t${it.description}") + /** + * Parse a long option (--option) + */ + private fun parseLongOption(cmd: Subcommand, key: String, args: Array, index: Int): Int { + val option = cmd.options.firstOrNull { it.longName == key } + val flag = cmd.flags.firstOrNull { it.longName == key } + + return when { + option != null -> { + if (index + 1 >= args.size) { + throw ArgumentParseException("Missing value for option --$key") + } + try { + option.parseValue(args[index + 1]) + index + 1 + } catch (e: Exception) { + throw ArgumentParseException("Invalid value for option --$key: ${e.message}") + } + } + + flag != null -> { + flag.setFlag() + index + } + + else -> { + if (config.strictMode) { + throw ArgumentParseException("Unknown option --$key") + } else { + printWarning("Unknown option --$key") + index + } + } } } + + /** + * Parse a short option (-o) + */ + private fun parseShortOption(cmd: Subcommand, key: String, args: Array, index: Int): Int { + // Handle combined short flags like -acc + if (key.length > 1) { + key.forEach { char -> + val flag = cmd.flags.firstOrNull { it.shortName == char.toString() } + if (flag != null) { + flag.setFlag() + } else if (config.strictMode) { + throw ArgumentParseException("Unknown flag -$char") + } + } + return index + } + + val option = cmd.options.firstOrNull { it.shortName == key } + val flag = cmd.flags.firstOrNull { it.shortName == key } + + return when { + option != null -> { + if (index + 1 >= args.size) { + throw ArgumentParseException("Missing value for option -$key") + } + try { + option.parseValue(args[index + 1]) + index + 1 + } catch (e: Exception) { + throw ArgumentParseException("Invalid value for option -$key: ${e.message}") + } + } + + flag != null -> { + flag.setFlag() + index + } + + else -> { + if (config.strictMode) { + throw ArgumentParseException("Unknown option -$key") + } else { + printWarning("Unknown option -$key") + index + } + } + } + } + + /** + * Parse positional arguments + */ + private fun parsePositionalArguments(cmd: Subcommand, args: List) { + val arguments = cmd.arguments + + if (args.size > arguments.size) { + val extra = args.drop(arguments.size) + if (config.strictMode) { + throw ArgumentParseException("Too many arguments: ${extra.joinToString(", ")}") + } else { + printWarning("Ignoring extra arguments: ${extra.joinToString(", ")}") + } + } + + args.forEachIndexed { index, value -> + if (index < arguments.size) { + try { + arguments[index].parseValue(value) + } catch (e: Exception) { + throw ArgumentParseException("Invalid value for argument ${arguments[index].name}: ${e.message}") + } + } + } + } + + /** + * Validate that all required options have been provided + */ + private fun validateRequiredOptions(cmd: Subcommand) { + val missingRequired = cmd.options.filter { it.required && it.value == null } + if (missingRequired.isNotEmpty()) { + val missing = missingRequired.joinToString(", ") { "--${it.longName}" } + throw ArgumentParseException("Missing required options: $missing") + } + } + + /** + * Print global help menu + */ + private fun printGlobalHelp() { + println(colorize("Usage: $programName [options]", Color.BOLD)) + println() + println(colorize("Commands:", Color.BOLD)) + commands.forEach { cmd -> + val aliases = if (cmd.aliases.isNotEmpty()) " (${cmd.aliases.joinToString(", ")})" else "" + println(" ${colorize(cmd.name, Color.GREEN)}$aliases") + if (cmd.description.isNotEmpty()) { + println(" ${cmd.description}") + } + } + println() + println("Use `$programName --help` for more information about a command.") + } + + /** + * Print error message with optional coloring + */ + private fun printError(message: String) { + println(colorize("Error: $message", Color.RED)) + } + + /** + * Print warning message with optional coloring + */ + private fun printWarning(message: String) { + println(colorize("Warning: $message", Color.YELLOW)) + } + + /** + * Apply color to text if colors are enabled + */ + private fun colorize(text: String, color: Color): String { + return if (config.colorsEnabled) { + "${color.code}$text${Color.RESET.code}" + } else { + text + } + } + + /** + * ANSI color codes for terminal output + */ + private enum class Color(val code: String) { + RESET("\u001B[0m"), + RED("\u001B[31m"), + GREEN("\u001B[32m"), + YELLOW("\u001B[33m"), + BOLD("\u001B[1m") + } } +/** + * Custom exception for argument parsing errors + */ +class ArgumentParseException(message: String) : Exception(message) diff --git a/src/main/kotlin/org/kargs/ParserConfig.kt b/src/main/kotlin/org/kargs/ParserConfig.kt new file mode 100644 index 0000000..1d6763b --- /dev/null +++ b/src/main/kotlin/org/kargs/ParserConfig.kt @@ -0,0 +1,17 @@ +package org.kargs + +/** + * Configuration class for customizing parser behavior + */ +data class ParserConfig( + val colorsEnabled: Boolean = true, + val strictMode: Boolean = false, // Whether to fail on unknown options + 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 +) { + companion object { + val DEFAULT = ParserConfig() + } +} diff --git a/src/main/kotlin/org/kargs/Subcommand.kt b/src/main/kotlin/org/kargs/Subcommand.kt index 92cb2ee..9686866 100644 --- a/src/main/kotlin/org/kargs/Subcommand.kt +++ b/src/main/kotlin/org/kargs/Subcommand.kt @@ -1,41 +1,110 @@ package org.kargs + +/** + * Base class for defining subcommands with their options, flags, and arguments + * + * @param name The name for this subcommand + * @param description Help description for this subcommand + * @param aliases Alternative names for this subcommand + */ abstract class Subcommand( val name: String, val description: String = "", val aliases: List = emptyList() ) { + private val _options = mutableListOf>() + private val _flags = mutableListOf() + private val _arguments = mutableListOf>() + + init { + require(name.isNotBlank()) { "Subcommand name cannot be blank" } + } + + /** + * Register a property (option, flag, argument) with this subcommand + * Called automatically by property delagates + */ + fun registerProperty(prop: KargsProperty<*>) { + when (prop) { + is Option<*> -> _options += prop + is Flag -> _flags += prop + is Argument<*> -> _arguments += prop + } + } + + // Public read-only access to registered properties + val options: List> get() = _options + val flags: List get() = _flags + val arguments: List> get() = _arguments + + /** + * Execute this subcommand - must be implemented by subclasses + */ abstract fun execute() - open fun printHelp() { - println("Usage: $name [options] [arguments]") - if (description.isNotEmpty()) println(description) - - val props = this::class.members.filterIsInstance>() - val options = props.mapNotNull { it.getter.call(this) as? Option<*> } - val flags = props.mapNotNull { it.getter.call(this) as? Flag } - val arguments = props.mapNotNull { it.getter.call(this) as? Argument<*> } + /** + * Print help information for this subcommand + */ + fun printHelp() { + println("Usage: $name [options]${if (arguments.isNotEmpty()) " ${arguments.joinToString(" ") { "<${it.name}>" }}" else ""}") + if (description.isNotEmpty()) { + println() + println(description) + } if (options.isNotEmpty()) { - println("\nOptions:") - options.forEach { o -> - println(" ${o.shortName?.let { "-$it, " } ?: ""}--${o.longName}\t${o.description}") + println() + println("Options:") + options.forEach { option -> + val shortName = option.shortName?.let { "-$it, " } ?: " " + val required = if (option.required) " (required)" else "" + val defaultVal = option.getValueOrDefault()?.let { " [default: $it]" } ?: "" + println(" $shortName--${option.longName}") + option.description?.let { desc -> + println(" $desc$required$defaultVal") + } } } if (flags.isNotEmpty()) { - println("\nFlags:") - flags.forEach { f -> - println(" ${f.shortName?.let { "-$it, " } ?: ""}--${f.longName}\t${f.description}") + println() + println("Flags:") + flags.forEach { flag -> + val shortName = flag.shortName?.let { "-$it, " } ?: " " + println(" $shortName--${flag.longName}") + flag.description?.let { desc -> + println(" $desc") + } } } if (arguments.isNotEmpty()) { - println("\nArguments:") - arguments.forEach { a -> - println(" ${a.name}\t${a.description}") + println() + println("Arguments:") + arguments.forEach { arg -> + val required = if (arg.required) " (required)" else " (optional)" + println(" ${arg.name}$required") + arg.description?.let { desc -> + println(" $desc") + } } } } -} + /** + * Validate all properties in this subcommand + * @return list of validation errors, empty if all valid + */ + fun validate(): List { + val errors = mutableListOf() + + (options + flags + arguments).forEach { prop -> + prop.getValidationError()?.let { error -> + errors.add(error) + } + } + + return errors + } +} diff --git a/src/test/kotlin/org/kargs/tests/ArgTypeTest.kt b/src/test/kotlin/org/kargs/tests/ArgTypeTest.kt new file mode 100644 index 0000000..b4dda83 --- /dev/null +++ b/src/test/kotlin/org/kargs/tests/ArgTypeTest.kt @@ -0,0 +1,107 @@ +package org.kargs.tests + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.kargs.ArgType +import org.kargs.ArgumentParseException +import kotlin.test.assertEquals + +class ArgTypeTest { + + @Test + fun `String type converts correctly`() { + val type = ArgType.String + assertEquals("hello", type.convert("hello")) + assertEquals("", type.convert("")) + assertEquals("123", type.convert("123")) + } + + @Test + fun `Int type converts valid integers`() { + val type = ArgType.Int + assertEquals(42, type.convert("42")) + assertEquals(-10, type.convert("-10")) + assertEquals(0, type.convert("0")) + } + + @Test + fun `Int type throws on invalid input`() { + val type = ArgType.Int + assertThrows { type.convert("not-a-number") } + 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 + assertEquals(3.14, type.convert("3.14")) + 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()) + } +} + diff --git a/src/test/kotlin/org/kargs/tests/ArgumentTest.kt b/src/test/kotlin/org/kargs/tests/ArgumentTest.kt new file mode 100644 index 0000000..c7afd48 --- /dev/null +++ b/src/test/kotlin/org/kargs/tests/ArgumentTest.kt @@ -0,0 +1,78 @@ +package org.kargs.tests + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.kargs.Argument +import org.kargs.ArgType +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class ArgumentTest { + + @Test + fun `argument creation`() { + val arg = Argument(ArgType.String, "input", "Input file") + assertEquals("input", arg.name) + assertEquals("Input file", arg.description) + assertTrue(arg.required) + } + + @Test + fun `optional argument`() { + val arg = Argument(ArgType.String, "output", "Output file", required = false) + assertFalse(arg.required) + assertTrue(arg.isValid()) // optional and null is valid + } + + @Test + fun `argument parsing`() { + val arg = Argument(ArgType.Int, "count", "Item count") + assertNull(arg.value) + + arg.parseValue("42") + assertEquals(42, arg.value) + } + + @Test + fun `required argument validation`() { + val arg = Argument(ArgType.String, "required", "Required arg", required = true) + assertFalse(arg.isValid()) + assertEquals("Argument 'required' is required", arg.getValidationError()) + + arg.parseValue("value") + assertTrue(arg.isValid()) + assertNull(arg.getValidationError()) + } + + @Test + fun `argument throws on blank name`() { + assertThrows { + Argument(ArgType.String, "", "Description") + } + + assertThrows { + Argument(ArgType.String, " ", "Description") // whitespace only + } + } + + @Test + fun `argument with choice type`() { + val arg = Argument(ArgType.choice("red", "green", "blue"), "color", "Color choice") + + arg.parseValue("red") + assertEquals("red", arg.value) + assertTrue(arg.isValid()) + } + + @Test + fun `argument with range type`() { + val arg = Argument(ArgType.intRange(1, 100), "percentage", "Percentage value") + + arg.parseValue("50") + assertEquals(50, arg.value) + assertTrue(arg.isValid()) + } +} + diff --git a/src/test/kotlin/org/kargs/tests/FlagTest.kt b/src/test/kotlin/org/kargs/tests/FlagTest.kt new file mode 100644 index 0000000..53989ff --- /dev/null +++ b/src/test/kotlin/org/kargs/tests/FlagTest.kt @@ -0,0 +1,103 @@ +package org.kargs.tests + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.kargs.ArgumentParseException +import org.kargs.Flag +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertFalse + +class FlagTest { + + @Test + fun `flag creation with default false`() { + val flag = Flag("verbose", "v", "Verbose output") + assertEquals("verbose", flag.longName) + assertEquals("v", flag.shortName) + assertEquals("Verbose output", flag.description) + assertEquals(false, flag.value) + assertFalse(flag.isSet()) + } + + @Test + fun `flag creation with custom default`() { + val flag = Flag("enabled", defaultValue = true) + assertEquals(true, flag.value) + assertFalse(flag.isSet()) // not explicitly set, just default + } + + @Test + fun `setFlag updates value`() { + val flag = Flag("debug", "d") + assertEquals(false, flag.value) + + flag.setFlag() + assertEquals(true, flag.value) + assertTrue(flag.isSet()) + } + + @Test + fun `parseValue handles boolean strings`() { + val flag = Flag("test") + + flag.parseValue("true") + assertEquals(true, flag.value) + + flag.parseValue("false") + assertEquals(false, flag.value) + + flag.parseValue("yes") + assertEquals(true, flag.value) + + flag.parseValue("no") + assertEquals(false, flag.value) + + flag.parseValue("1") + assertEquals(true, flag.value) + + flag.parseValue("0") + assertEquals(false, flag.value) + } + + @Test + fun `parseValue throws on invalid input`() { + val flag = Flag("test") + assertThrows { + flag.parseValue("maybe") + } + + assertThrows { + flag.parseValue("2") + } + } + + @Test + fun `flag throws on invalid names`() { + assertThrows { + Flag("", "v") // blank long name + } + + assertThrows { + Flag("--invalid") // long name with dashes + } + + assertThrows { + Flag("valid", "ab") // short name too long + } + + assertThrows { + Flag("valid", "-v") // short name with dash + } + } + + @Test + fun `flag isSet works correctly with defaults`() { + val flag = Flag("test", defaultValue = true) + assertFalse(flag.isSet()) // has default but not explicitly set + + flag.setFlag() + assertTrue(flag.isSet()) // now explicitly set + } +} + diff --git a/src/test/kotlin/org/kargs/tests/IntegrationTest.kt b/src/test/kotlin/org/kargs/tests/IntegrationTest.kt new file mode 100644 index 0000000..df6c2a6 --- /dev/null +++ b/src/test/kotlin/org/kargs/tests/IntegrationTest.kt @@ -0,0 +1,128 @@ +package org.kargs.tests + +import org.junit.jupiter.api.Test +import org.kargs.* +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertFalse + +class IntegrationTest { + + class ComplexCommand : Subcommand("complex", "Complex test command", listOf("c")) { + val input by Argument(ArgType.String, "input", "Input file") + val output by Argument(ArgType.String, "output", "Output file", required = false) + + val format by Option(ArgType.choice("json", "xml", "yaml"), "format", "f", "Output format", defaultValue = "json") + val threads by Option(ArgType.intRange(1, 32), "threads", "t", "Thread count", defaultValue = 4) + val timeout by Option(ArgType.Double, "timeout", description = "Timeout in seconds") + val config by Option(ArgType.String, "config", "c", "Config file", required = true) + + val verbose by Flag("verbose", "v", "Verbose output") + val dryRun by Flag("dry-run", "n", "Dry run mode") + val force by Flag("force", "f", "Force operation") + + var executed = false + + override fun execute() { + executed = true + } + } + + @Test + fun `complex command with all features`() { + val parser = Parser("myapp") + val cmd = ComplexCommand() + parser.subcommands(cmd) + + parser.parse(arrayOf( + "complex", + "--config", "/path/to/config.yml", + "--format", "xml", + "--threads", "8", + "--timeout", "30.5", + "--verbose", + "--dry-run", + "input.txt", + "output.xml" + )) + + assertTrue(cmd.executed) + assertEquals("input.txt", cmd.input) + assertEquals("output.xml", cmd.output) + assertEquals("/path/to/config.yml", cmd.config) + assertEquals("xml", cmd.format) + assertEquals(8, cmd.threads) + assertEquals(30.5, cmd.timeout) + assertEquals(true, cmd.verbose) + assertEquals(true, cmd.dryRun) + assertEquals(false, cmd.force) + } + + @Test + fun `complex command with short options and combined flags`() { + val parser = Parser("myapp") + val cmd = ComplexCommand() + parser.subcommands(cmd) + + parser.parse(arrayOf( + "c", // using alias + "-c", "/config.yml", + "-f", "yaml", + "-t", "16", + "-vnf", // combined flags: verbose, dry-run (n), force + "input.txt" + )) + + assertTrue(cmd.executed) + assertEquals("input.txt", cmd.input) + assertEquals("/config.yml", cmd.config) + assertEquals("yaml", cmd.format) + assertEquals(16, cmd.threads) + assertEquals(true, cmd.verbose) + assertEquals(true, cmd.dryRun) + assertEquals(true, cmd.force) + } + + @Test + fun `multiple commands in same parser`() { + val parser = Parser("tool") + val buildCmd = TestUtils.TestSubcommand("build", "Build project") + val testCmd = TestUtils.TestSubcommand("test", "Run tests") + val deployCmd = TestUtils.TestSubcommand("deploy", "Deploy project") + + parser.subcommands(buildCmd, testCmd, deployCmd) + + // Test build command + parser.parse(arrayOf("build", "--required", "value", "src/")) + assertTrue(buildCmd.executed) + + // Reset and test different command + buildCmd.executed = false + parser.parse(arrayOf("test", "--required", "value", "tests/")) + assertTrue(testCmd.executed) + assertFalse(buildCmd.executed) // should not be executed again + } + + @Test + fun `validation works end-to-end`() { + val parser = Parser("app") + val cmd = ComplexCommand() + parser.subcommands(cmd) + + // Should validate all constraints + parser.parse(arrayOf( + "complex", + "--config", "config.yml", + "--format", "json", // valid choice + "--threads", "8", // valid range + "--timeout", "15.5", // valid double + "input.txt" + )) + + assertTrue(cmd.executed) + assertEquals("json", cmd.format) + assertEquals(8, cmd.threads) + assertEquals(15.5, cmd.timeout) + } +} + diff --git a/src/test/kotlin/org/kargs/tests/OptionTest.kt b/src/test/kotlin/org/kargs/tests/OptionTest.kt new file mode 100644 index 0000000..e95065e --- /dev/null +++ b/src/test/kotlin/org/kargs/tests/OptionTest.kt @@ -0,0 +1,95 @@ +package org.kargs.tests + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.kargs.ArgType +import org.kargs.Option +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class OptionTest { + + @Test + fun `option creation with valid parameters`() { + val option = Option(ArgType.String, "output", "o", "Output file", required = true) + assertEquals("output", option.longName) + assertEquals("o", option.shortName) + assertEquals("Output file", option.description) + assertTrue(option.required) + } + + @Test + fun `option with default value`() { + val option = Option(ArgType.Int, "threads", "t", "Thread count", defaultValue = 4) + assertEquals(4, option.value) + assertEquals(4, option.getValueOrDefault()) + assertFalse(option.isSet()) + } + + @Test + fun `option parsing updates value`() { + val option = Option(ArgType.String, "name", "n") + assertNull(option.value) + + option.parseValue("test") + assertEquals("test", option.value) + assertTrue(option.isSet()) + } + + @Test + fun `option validation for required field`() { + val option = Option(ArgType.String, "required", required = true) + assertFalse(option.isValid()) + assertEquals("Option --required is required", option.getValidationError()) + + option.parseValue("value") + assertTrue(option.isValid()) + assertNull(option.getValidationError()) + } + + @Test + fun `option throws on invalid names`() { + assertThrows { + Option(ArgType.String, "", "o") // blank long name + } + + assertThrows { + Option(ArgType.String, "--invalid", "o") // long name with dashes + } + + assertThrows { + Option(ArgType.String, "valid", "ab") // short name too long + } + + assertThrows { + Option(ArgType.String, "valid", "-o") // short name with dash + } + } + + @Test + fun `option with range type validation`() { + val option = Option(ArgType.intRange(1, 10), "count", "c", "Item count") + + option.parseValue("5") + assertEquals(5, option.value) + assertTrue(option.isValid()) + + // Test that the option itself doesn't validate the range (ArgType does) + // The validation happens during parsing + } + + @Test + fun `option isSet works correctly with defaults`() { + val option = Option(ArgType.String, "mode", defaultValue = "default") + assertFalse(option.isSet()) // has default but not explicitly set + + option.parseValue("custom") + assertTrue(option.isSet()) // now explicitly set + + option.parseValue("default") // set to same as default + assertTrue(option.isSet()) // still considered "set" + } +} + diff --git a/src/test/kotlin/org/kargs/tests/ParserConfigTest.kt b/src/test/kotlin/org/kargs/tests/ParserConfigTest.kt new file mode 100644 index 0000000..63d5b80 --- /dev/null +++ b/src/test/kotlin/org/kargs/tests/ParserConfigTest.kt @@ -0,0 +1,107 @@ +package org.kargs.tests + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.kargs.ArgumentParseException +import org.kargs.Parser +import org.kargs.ParserConfig +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertFalse + +class ParserConfigTest { + + @Test + fun `default config values`() { + val config = ParserConfig.DEFAULT + assertTrue(config.colorsEnabled) + assertFalse(config.strictMode) + assertTrue(config.helpOnEmpty) + assertTrue(config.caseSensitive) + assertFalse(config.allowAbbreviations) + } + + @Test + fun `custom config values`() { + val config = ParserConfig( + colorsEnabled = false, + strictMode = true, + helpOnEmpty = false, + caseSensitive = false, + allowAbbreviations = true + ) + + assertFalse(config.colorsEnabled) + assertTrue(config.strictMode) + assertFalse(config.helpOnEmpty) + assertFalse(config.caseSensitive) + assertTrue(config.allowAbbreviations) + } + + @Test + fun `strict mode affects unknown options`() { + val strictParser = Parser("test", ParserConfig(strictMode = true)) + val lenientParser = Parser("test", ParserConfig(strictMode = false)) + + val testCmd = TestUtils.TestSubcommand() + strictParser.subcommands(testCmd) + lenientParser.subcommands(testCmd) + + // Strict mode should throw + assertThrows { + strictParser.parse(arrayOf("test", "--unknown", "value", "--required", "req", "input.txt")) + } + + // Lenient mode should warn but continue + val output = TestUtils.captureOutput { + lenientParser.parse(arrayOf("test", "--unknown", "value", "--required", "req", "input.txt")) + } + assertTrue(output.contains("Warning")) + assertTrue(testCmd.executed) + } + + @Test + fun `helpOnEmpty config affects empty args behavior`() { + val helpParser = Parser("test", ParserConfig(helpOnEmpty = true)) + val noHelpParser = Parser("test", ParserConfig(helpOnEmpty = false)) + + val testCmd = TestUtils.TestSubcommand() + helpParser.subcommands(testCmd) + noHelpParser.subcommands(testCmd) + + // With helpOnEmpty = true, should show help + val helpOutput = TestUtils.captureOutput { + helpParser.parse(arrayOf()) + } + assertTrue(helpOutput.contains("Usage:")) + + // With helpOnEmpty = false, should do nothing + val noHelpOutput = TestUtils.captureOutput { + noHelpParser.parse(arrayOf()) + } + assertEquals("", noHelpOutput) + } + + @Test + fun `case sensitivity affects command matching`() { + val caseSensitiveParser = Parser("test", ParserConfig(caseSensitive = true)) + val caseInsensitiveParser = Parser("test", ParserConfig(caseSensitive = false)) + + val testCmd = TestUtils.TestSubcommand("Test") // capital T + caseSensitiveParser.subcommands(testCmd) + caseInsensitiveParser.subcommands(testCmd) + + // Case sensitive should not match "test" to "Test" + val sensitiveOutput = TestUtils.captureOutput { + caseSensitiveParser.parse(arrayOf("test", "--required", "value", "input.txt")) + } + assertTrue(sensitiveOutput.contains("Unknown command")) + assertFalse(testCmd.executed) + + // Case insensitive should match + testCmd.executed = false // reset + caseInsensitiveParser.parse(arrayOf("test", "--required", "value", "input.txt")) + assertTrue(testCmd.executed) + } +} + diff --git a/src/test/kotlin/org/kargs/tests/ParserTest.kt b/src/test/kotlin/org/kargs/tests/ParserTest.kt new file mode 100644 index 0000000..18e1567 --- /dev/null +++ b/src/test/kotlin/org/kargs/tests/ParserTest.kt @@ -0,0 +1,175 @@ +package org.kargs.tests + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import org.kargs.ArgumentParseException +import org.kargs.Parser +import org.kargs.ParserConfig +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertFalse + +class ParserTest { + + private lateinit var parser: Parser + private lateinit var testCmd: TestUtils.TestSubcommand + + @BeforeEach + fun setup() { + parser = Parser("testapp") + testCmd = TestUtils.TestSubcommand() + parser.subcommands(testCmd) + } + + @Test + fun `parse basic command with required option`() { + parser.parse(arrayOf("test", "--required", "value", "input.txt")) + + assertTrue(testCmd.executed) + assertEquals("value", testCmd.requiredOpt) + assertEquals("input.txt", testCmd.inputArg) + } + + @Test + fun `parse command with short options`() { + parser.parse(arrayOf("test", "-r", "required", "-s", "string", "-n", "42", "input.txt")) + + assertTrue(testCmd.executed) + assertEquals("required", testCmd.requiredOpt) + assertEquals("string", testCmd.stringOpt) + assertEquals(42, testCmd.intOpt) + assertEquals("input.txt", testCmd.inputArg) + } + + @Test + fun `parse command with flags`() { + parser.parse(arrayOf("test", "--required", "value", "--verbose", "--debug", "input.txt")) + + assertTrue(testCmd.executed) + assertEquals(true, testCmd.verboseFlag) + assertEquals(true, testCmd.debugFlag) + assertEquals(false, testCmd.forceFlag) // not set, should be default + } + + @Test + fun `parse command with combined short flags`() { + parser.parse(arrayOf("test", "-r", "value", "-vf", "input.txt")) + + assertTrue(testCmd.executed) + assertEquals("value", testCmd.requiredOpt) + assertEquals(true, testCmd.verboseFlag) + assertEquals(true, testCmd.forceFlag) + } + + @Test + fun `parse command with all argument types`() { + parser.parse(arrayOf( + "test", + "--required", "req", + "--string", "hello", + "--number", "123", + "--range", "5", + "--choice", "b", + "--double", "3.14", + "--verbose", + "input.txt", + "output.txt" + )) + + assertTrue(testCmd.executed) + assertEquals("req", testCmd.requiredOpt) + assertEquals("hello", testCmd.stringOpt) + assertEquals(123, testCmd.intOpt) + assertEquals(5, testCmd.rangeOpt) + assertEquals("b", testCmd.choiceOpt) + assertEquals(3.14, testCmd.doubleOpt) + assertEquals(true, testCmd.verboseFlag) + assertEquals("input.txt", testCmd.inputArg) + assertEquals("output.txt", testCmd.outputArg) + } + + @Test + fun `throws exception when required option missing`() { + assertThrows { + parser.parse(arrayOf("test", "input.txt")) + } + assertFalse(testCmd.executed) + } + + @Test + fun `throws exception on invalid integer`() { + assertThrows { + parser.parse(arrayOf("test", "--required", "value", "--number", "not-a-number", "input.txt")) + } + } + + @Test + fun `throws exception on out of range value`() { + assertThrows { + parser.parse(arrayOf("test", "--required", "value", "--range", "15", "input.txt")) + } + } + + @Test + fun `throws exception on invalid choice`() { + assertThrows { + parser.parse(arrayOf("test", "--required", "value", "--choice", "invalid", "input.txt")) + } + } + + @Test + fun `handles unknown command`() { + val output = TestUtils.captureOutput { + parser.parse(arrayOf("unknown", "arg")) + } + assertTrue(output.contains("Unknown command: unknown")) + } + + @Test + fun `shows global help on empty args`() { + val output = TestUtils.captureOutput { + parser.parse(arrayOf()) + } + assertTrue(output.contains("Usage: testapp ")) + assertTrue(output.contains("test")) + } + + @Test + fun `shows global help on --help`() { + val output = TestUtils.captureOutput { + parser.parse(arrayOf("--help")) + } + assertTrue(output.contains("Usage: testapp ")) + } + + @Test + fun `handles command aliases`() { + val aliasCmd = TestUtils.TestSubcommand("build", "Build command", listOf("b", "compile")) + parser.subcommands(aliasCmd) + + // Test main name + parser.parse(arrayOf("build", "--required", "value", "input.txt")) + assertTrue(aliasCmd.executed) + + // Reset and test alias + aliasCmd.executed = false + parser.parse(arrayOf("b", "--required", "value", "input.txt")) + assertTrue(aliasCmd.executed) + } + + @Test + fun `handles missing option value`() { + assertThrows { + parser.parse(arrayOf("test", "--required")) + } + } + + @Test + fun `handles missing short option value`() { + assertThrows { + parser.parse(arrayOf("test", "-r")) + } + } +} + diff --git a/src/test/kotlin/org/kargs/tests/SubcommandTest.kt b/src/test/kotlin/org/kargs/tests/SubcommandTest.kt new file mode 100644 index 0000000..f79d0bd --- /dev/null +++ b/src/test/kotlin/org/kargs/tests/SubcommandTest.kt @@ -0,0 +1,159 @@ +package org.kargs.tests + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.kargs.* +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class SubcommandTest { + + @Test + fun `subcommand creation with valid parameters`() { + val cmd = TestUtils.TestSubcommand("build", "Build the project", listOf("b", "compile")) + assertEquals("build", cmd.name) + assertEquals("Build the project", cmd.description) + assertEquals(listOf("b", "compile"), cmd.aliases) + } + + @Test + fun `subcommand throws on blank name`() { + assertThrows { + TestUtils.TestSubcommand("", "Description") + } + + assertThrows { + TestUtils.TestSubcommand(" ", "Description") // whitespace only + } + } + + @Test + fun `subcommand registers properties correctly`() { + val cmd = TestUtils.TestSubcommand() + + // Properties should be registered automatically via delegates + assertTrue(cmd.options.isNotEmpty()) + assertTrue(cmd.flags.isNotEmpty()) + assertTrue(cmd.arguments.isNotEmpty()) + + // Check specific properties are registered + assertTrue(cmd.options.any { it.longName == "required" }) + assertTrue(cmd.flags.any { it.longName == "verbose" }) + assertTrue(cmd.arguments.any { it.name == "input" }) + } + + @Test + fun `subcommand validation works`() { + val cmd = TestUtils.TestSubcommand() + + // Should have validation errors for required fields + val errors = cmd.validate() + assertTrue(errors.isNotEmpty()) + assertTrue(errors.any { it.contains("required") }) + + // Set required values through parsing (the proper way) + val requiredOption = cmd.options.find { it.longName == "required" } + val inputArgument = cmd.arguments.find { it.name == "input" } + + requiredOption?.parseValue("value") + inputArgument?.parseValue("input.txt") + + // Should now be valid + val errorsAfter = cmd.validate() + assertTrue(errorsAfter.isEmpty()) + } + + @Test + fun `subcommand help generation`() { + val cmd = TestUtils.TestSubcommand("test", "Test command for validation") + + val helpOutput = TestUtils.captureOutput { + cmd.printHelp() + } + + assertTrue(helpOutput.contains("Usage: test [options]")) + assertTrue(helpOutput.contains("Test command for validation")) + assertTrue(helpOutput.contains("Options:")) + assertTrue(helpOutput.contains("--required")) + assertTrue(helpOutput.contains("Flags:")) + assertTrue(helpOutput.contains("--verbose")) + assertTrue(helpOutput.contains("Arguments:")) + assertTrue(helpOutput.contains("input")) + } + + @Test + fun `subcommand property access works`() { + val cmd = TestUtils.TestSubcommand() + + // Initially null/default values + assertNull(cmd.requiredOpt) + assertEquals(false, cmd.verboseFlag) + assertNull(cmd.inputArg) + + // Set values through parsing (simulating real usage) + val requiredOption = cmd.options.find { it.longName == "required" } + val verboseFlag = cmd.flags.find { it.longName == "verbose" } + val inputArgument = cmd.arguments.find { it.name == "input" } + + requiredOption?.parseValue("test") + verboseFlag?.setFlag() + inputArgument?.parseValue("file.txt") + + // Values should be accessible + assertEquals("test", cmd.requiredOpt) + assertEquals(true, cmd.verboseFlag) + assertEquals("file.txt", cmd.inputArg) + } + + @Test + fun `subcommand property types are correct`() { + val cmd = TestUtils.TestSubcommand() + + // Check that we can find properties by their characteristics + val stringOption = cmd.options.find { it.longName == "string" } + val intOption = cmd.options.find { it.longName == "number" } + val rangeOption = cmd.options.find { it.longName == "range" } + val choiceOption = cmd.options.find { it.longName == "choice" } + + assertTrue(stringOption != null) + assertTrue(intOption != null) + assertTrue(rangeOption != null) + assertTrue(choiceOption != null) + + // Test parsing different types + stringOption?.parseValue("hello") + intOption?.parseValue("42") + rangeOption?.parseValue("5") + choiceOption?.parseValue("b") + + assertEquals("hello", cmd.stringOpt) + assertEquals(42, cmd.intOpt) + assertEquals(5, cmd.rangeOpt) + assertEquals("b", cmd.choiceOpt) + } + + @Test + fun `subcommand execution tracking works`() { + val cmd = TestUtils.TestSubcommand() + + assertFalse(cmd.executed) + assertTrue(cmd.executionData.isEmpty()) + + // Set some values and execute + val requiredOption = cmd.options.find { it.longName == "required" } + val inputArgument = cmd.arguments.find { it.name == "input" } + + requiredOption?.parseValue("test") + inputArgument?.parseValue("input.txt") + + cmd.execute() + + assertTrue(cmd.executed) + assertTrue(cmd.executionData.isNotEmpty()) + assertEquals("test", cmd.executionData["requiredOpt"]) + assertEquals("input.txt", cmd.executionData["inputArg"]) + } +} + diff --git a/src/test/kotlin/org/kargs/tests/TestUtils.kt b/src/test/kotlin/org/kargs/tests/TestUtils.kt new file mode 100644 index 0000000..9a353c4 --- /dev/null +++ b/src/test/kotlin/org/kargs/tests/TestUtils.kt @@ -0,0 +1,73 @@ +package org.kargs.tests + +import org.kargs.* +import java.io.ByteArrayOutputStream +import java.io.PrintStream + +/** + * Utility functions for testing + */ +object TestUtils { + + /** + * Capture console output during execution + */ + fun captureOutput(block: () -> Unit): String { + val originalOut = System.out + val outputStream = ByteArrayOutputStream() + System.setOut(PrintStream(outputStream)) + + try { + block() + return outputStream.toString().trim() + } finally { + System.setOut(originalOut) + } + } + + /** + * Create a test subcommand for testing purposes + */ + class TestSubcommand( + name: String = "test", + description: String = "Test command", + aliases: List = emptyList() + ) : Subcommand(name, description, aliases) { + + var executed = false + var executionData: Map = emptyMap() + + // Test properties + val stringOpt by Option(ArgType.String, "string", "s", "String option") + val requiredOpt by Option(ArgType.String, "required", "r", "Required option", required = true) + val intOpt by Option(ArgType.Int, "number", "n", "Integer option") + val rangeOpt by Option(ArgType.intRange(1, 10), "range", description = "Range option") + val choiceOpt by Option(ArgType.choice("a", "b", "c"), "choice", "c", "Choice option") + val doubleOpt by Option(ArgType.Double, "double", "d", "Double option") + + val verboseFlag by Flag("verbose", "v", "Verbose flag") + val debugFlag by Flag("debug", description = "Debug flag") + val forceFlag by Flag("force", "f", "Force flag", defaultValue = false) + + val inputArg by Argument(ArgType.String, "input", "Input argument") + val outputArg by Argument(ArgType.String, "output", "Output argument", required = false) + + override fun execute() { + executed = true + executionData = mapOf( + "stringOpt" to stringOpt, + "requiredOpt" to requiredOpt, + "intOpt" to intOpt, + "rangeOpt" to rangeOpt, + "choiceOpt" to choiceOpt, + "doubleOpt" to doubleOpt, + "verboseFlag" to verboseFlag, + "debugFlag" to debugFlag, + "forceFlag" to forceFlag, + "inputArg" to inputArg, + "outputArg" to outputArg + ) + } + } +} +