6 Commits

Author SHA1 Message Date
darwincereska
8d05aa4ff5 fix(bugs): bugfix 2025-11-24 10:36:57 -05:00
darwincereska
c6470be0df feat(colors): added more colors and --no-color flag 2025-11-24 10:22:43 -05:00
darwincereska
fc5786951d feat(features): added file options, fixed some things 2025-11-19 16:59:09 -05:00
darwincereska
de79b6bcd5 feat(optional-option): added optional option 2025-11-19 16:24:56 -05:00
darwincereska
afcf7f0914 fix(help-and-test): fixed help, and tests 2025-11-19 08:55:42 -05:00
darwincereska
4714a11eef feat(update): large update with new types, fixed bugs 2025-11-18 23:22:34 -05:00
22 changed files with 2414 additions and 121 deletions

View File

@@ -4,7 +4,7 @@ plugins {
}
group = "org.kargs"
version = "1.0.0"
version = "1.0.8"
repositories {
mavenCentral()
@@ -12,8 +12,28 @@ repositories {
dependencies {
testImplementation(kotlin("test"))
implementation(kotlin("stdlib"))
implementation("org.jetbrains.kotlin:kotlin-reflect:2.2.21")
// implementation(kotlin("stdlib"))
// 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 {
@@ -48,3 +68,4 @@ mavenPublishing {
}
}
}

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -1 +1 @@
rootProject.name = "karg"
rootProject.name = "kargs"

View File

@@ -1,23 +1,171 @@
package org.kargs
import java.io.File
/**
* Sealed class representing different argument types with conversion and validation logic
*/
sealed class ArgType<T>(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<kotlin.String>("String") {
override fun convert(value: String) = value
}
object IntType : ArgType<kotlin.Int>("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<kotlin.Boolean>("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<kotlin.Double>("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(val min: kotlin.Int, val max: kotlin.Int) : ArgType<kotlin.Int>("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(val choices: List<kotlin.String>) : ArgType<kotlin.String>("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(", ")}"
}
/**
* Optional value type - can be used as flag or with value
*/
class OptionalValue(val defaultWhenPresent: String = "true") : ArgType<kotlin.String>("OptionalValue") {
override fun convert(value: String): String = value
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<java.io.File>("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<String>()
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<kotlin.String> = StringType
val Int: ArgType<kotlin.Int> = IntType
val Boolean: ArgType<kotlin.Boolean> = BooleanType
val Double: ArgType<kotlin.Double> = 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())
/**
* 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()
}
}

View File

@@ -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<T>(
val type: ArgType<T>,
val name: String,
val description: String = "",
description: String? = null,
val required: Boolean = true
) : ReadWriteProperty<Any?, T?> {
) : KargsProperty<T>(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
}
}
}

View File

@@ -0,0 +1,342 @@
package org.kargs
object Colors {
enum class Color(val code: String) {
RESET("\u001B[0m"),
// Base colors
BLACK("\u001B[30m"),
RED("\u001B[31m"),
GREEN("\u001B[32m"),
YELLOW("\u001B[33m"),
BLUE("\u001B[34m"),
MAGENTA("\u001B[35m"),
CYAN("\u001B[36m"),
WHITE("\u001B[37m"),
GRAY("\u001B[90m"),
// Bright colors
BRIGHT_BLACK("\u001B[90m"),
BRIGHT_RED("\u001B[91m"),
BRIGHT_GREEN("\u001B[92m"),
BRIGHT_YELLOW("\u001B[93m"),
BRIGHT_BLUE("\u001B[94m"),
BRIGHT_MAGENTA("\u001B[95m"),
BRIGHT_CYAN("\u001B[96m"),
BRIGHT_WHITE("\u001B[97m"),
// Extended 256-color palette (some popular ones)
ORANGE("\u001B[38;5;208m"),
PURPLE("\u001B[38;5;129m"),
PINK("\u001B[38;5;205m"),
LIME("\u001B[38;5;154m"),
TEAL("\u001B[38;5;80m"),
NAVY("\u001B[38;5;17m"),
MAROON("\u001B[38;5;88m"),
OLIVE("\u001B[38;5;100m"),
SILVER("\u001B[38;5;248m"),
GOLD("\u001B[38;5;220m"),
CORAL("\u001B[38;5;209m"),
SALMON("\u001B[38;5;210m"),
KHAKI("\u001B[38;5;185m"),
VIOLET("\u001B[38;5;177m"),
INDIGO("\u001B[38;5;54m"),
TURQUOISE("\u001B[38;5;80m"),
CRIMSON("\u001B[38;5;196m"),
FOREST_GREEN("\u001B[38;5;22m"),
ROYAL_BLUE("\u001B[38;5;21m"),
DARK_ORANGE("\u001B[38;5;166m"),
LIGHT_GRAY("\u001B[38;5;250m"),
DARK_GRAY("\u001B[38;5;240m"),
// Pastel colors
PASTEL_PINK("\u001B[38;5;217m"),
PASTEL_BLUE("\u001B[38;5;153m"),
PASTEL_GREEN("\u001B[38;5;157m"),
PASTEL_YELLOW("\u001B[38;5;229m"),
PASTEL_PURPLE("\u001B[38;5;183m"),
PASTEL_ORANGE("\u001B[38;5;223m"),
// Styles
BOLD("\u001B[1m"),
DIM("\u001B[2m"),
ITALIC("\u001B[3m"),
UNDERLINE("\u001B[4m"),
BLINK("\u001B[5m"),
REVERSE("\u001B[7m"),
STRIKETHROUGH("\u001B[9m"),
// Background colors
BG_BLACK("\u001B[40m"),
BG_RED("\u001B[41m"),
BG_GREEN("\u001B[42m"),
BG_YELLOW("\u001B[43m"),
BG_BLUE("\u001B[44m"),
BG_MAGENTA("\u001B[45m"),
BG_CYAN("\u001B[46m"),
BG_WHITE("\u001B[47m"),
// Semantic presets
ERROR("${BRIGHT_RED.code}${BOLD.code}"),
WARN("${YELLOW.code}${BOLD.code}"),
INFO(CYAN.code),
DEBUG("${DIM.code}${GRAY.code}"),
SUCCESS("${BRIGHT_GREEN.code}${BOLD.code}"),
// File/UI semantic colors
FILENAME("${BRIGHT_BLUE.code}${BOLD.code}"),
HEADING("${BRIGHT_MAGENTA.code}${BOLD.code}"),
SUBHEADING("${MAGENTA.code}${BOLD.code}"),
EMPHASIS("${BRIGHT_YELLOW.code}${BOLD.code}"),
HIGHLIGHT("${BG_YELLOW.code}${BLACK.code}"),
LINK("${BRIGHT_CYAN.code}${UNDERLINE.code}"),
CODE(BRIGHT_GREEN.code),
COMMENT("${DIM.code}${GRAY.code}"),
KEYWORD(BRIGHT_MAGENTA.code),
STRING(GREEN.code),
NUMBER(BRIGHT_BLUE.code),
OPERATOR(BRIGHT_RED.code),
// Status colors
ACTIVE(BRIGHT_GREEN.code),
INACTIVE("${DIM.code}${GRAY.code}"),
PENDING(YELLOW.code),
FAILED(BRIGHT_RED.code),
PASSED(BRIGHT_GREEN.code),
SKIPPED(BRIGHT_YELLOW.code),
// UI Elements
BORDER(DARK_GRAY.code),
MENU_ITEM(BRIGHT_WHITE.code),
SELECTED("${BG_BLUE.code}${BRIGHT_WHITE.code}"),
DISABLED("${DIM.code}${GRAY.code}"),
// Log levels
TRACE("${DIM.code}${LIGHT_GRAY.code}"),
VERBOSE(GRAY.code),
FATAL("${BG_RED.code}${BRIGHT_WHITE.code}${BOLD.code}");
fun bold() = DerivedColor("${BOLD.code}$code")
fun dim() = DerivedColor("${DIM.code}$code")
fun italic() = DerivedColor("${ITALIC.code}$code")
fun underline() = DerivedColor("${UNDERLINE.code}$code")
fun strikethrough() = DerivedColor("${STRIKETHROUGH.code}$code")
fun reverse() = DerivedColor("${REVERSE.code}$code")
fun blink() = DerivedColor("${BLINK.code}$code")
// Combine with background
fun onBackground(bgColor: Color) = DerivedColor("${bgColor.code}$code")
}
/** Wrapper for modified colors */
class DerivedColor(val combinedCode: String) {
fun bold() = DerivedColor("${Color.BOLD.code}$combinedCode")
fun dim() = DerivedColor("${Color.DIM.code}$combinedCode")
fun italic() = DerivedColor("${Color.ITALIC.code}$combinedCode")
fun underline() = DerivedColor("${Color.UNDERLINE.code}$combinedCode")
fun strikethrough() = DerivedColor("${Color.STRIKETHROUGH.code}$combinedCode")
fun reverse() = DerivedColor("${Color.REVERSE.code}$combinedCode")
fun blink() = DerivedColor("${Color.BLINK.code}$combinedCode")
}
// Global enable/disable
private var globalColorsEnabled = true
fun setGlobalColorsEnabled(enabled: Boolean) { globalColorsEnabled = enabled }
fun areGlobalColorsEnabled(): Boolean = globalColorsEnabled
// colorize() overloads
fun colorize(text: String, color: Color): String =
if (globalColorsEnabled) "${color.code}$text${Color.RESET.code}" else text
fun colorize(text: String, color: DerivedColor): String =
if (globalColorsEnabled) "${color.combinedCode}$text${Color.RESET.code}" else text
// RGB color support (for terminals that support it)
fun rgb(r: Int, g: Int, b: Int): DerivedColor =
DerivedColor("\u001B[38;2;$r;$g;${b}m")
fun bgRgb(r: Int, g: Int, b: Int): DerivedColor =
DerivedColor("\u001B[48;2;$r;$g;${b}m")
// Hex color support
fun hex(hexColor: String): DerivedColor {
val hex = hexColor.removePrefix("#")
val r = hex.take(2).toInt(16)
val g = hex.substring(2, 4).toInt(16)
val b = hex.substring(4, 6).toInt(16)
return rgb(r, g, b)
}
// --------------------------------------------------------
// Semantic convenience helpers
// --------------------------------------------------------
fun filename(text: String) = colorize(text, Color.FILENAME)
fun heading(text: String) = colorize(text, Color.HEADING)
fun subheading(text: String) = colorize(text, Color.SUBHEADING)
fun emphasis(text: String) = colorize(text, Color.EMPHASIS)
fun highlight(text: String) = colorize(text, Color.HIGHLIGHT)
fun link(text: String) = colorize(text, Color.LINK)
fun code(text: String) = colorize(text, Color.CODE)
fun comment(text: String) = colorize(text, Color.COMMENT)
fun keyword(text: String) = colorize(text, Color.KEYWORD)
fun string(text: String) = colorize(text, Color.STRING)
fun number(text: String) = colorize(text, Color.NUMBER)
fun operator(text: String) = colorize(text, Color.OPERATOR)
// Status helpers
fun active(text: String) = colorize(text, Color.ACTIVE)
fun inactive(text: String) = colorize(text, Color.INACTIVE)
fun pending(text: String) = colorize(text, Color.PENDING)
fun failed(text: String) = colorize(text, Color.FAILED)
fun passed(text: String) = colorize(text, Color.PASSED)
fun skipped(text: String) = colorize(text, Color.SKIPPED)
fun success(text: String) = colorize(text, Color.SUCCESS)
// Log level helpers
fun trace(text: String) = colorize(text, Color.TRACE)
fun verbose(text: String) = colorize(text, Color.VERBOSE)
fun fatal(text: String) = colorize(text, Color.FATAL)
// --------------------------------------------------------
// Extended color convenience helpers
// --------------------------------------------------------
fun orange(text: String) = colorize(text, Color.ORANGE)
fun purple(text: String) = colorize(text, Color.PURPLE)
fun pink(text: String) = colorize(text, Color.PINK)
fun lime(text: String) = colorize(text, Color.LIME)
fun teal(text: String) = colorize(text, Color.TEAL)
fun navy(text: String) = colorize(text, Color.NAVY)
fun maroon(text: String) = colorize(text, Color.MAROON)
fun olive(text: String) = colorize(text, Color.OLIVE)
fun silver(text: String) = colorize(text, Color.SILVER)
fun gold(text: String) = colorize(text, Color.GOLD)
fun coral(text: String) = colorize(text, Color.CORAL)
fun salmon(text: String) = colorize(text, Color.SALMON)
fun khaki(text: String) = colorize(text, Color.KHAKI)
fun violet(text: String) = colorize(text, Color.VIOLET)
fun indigo(text: String) = colorize(text, Color.INDIGO)
fun turquoise(text: String) = colorize(text, Color.TURQUOISE)
fun crimson(text: String) = colorize(text, Color.CRIMSON)
fun forestGreen(text: String) = colorize(text, Color.FOREST_GREEN)
fun royalBlue(text: String) = colorize(text, Color.ROYAL_BLUE)
fun darkOrange(text: String) = colorize(text, Color.DARK_ORANGE)
fun lightGray(text: String) = colorize(text, Color.LIGHT_GRAY)
fun darkGray(text: String) = colorize(text, Color.DARK_GRAY)
// Pastel helpers
fun pastelPink(text: String) = colorize(text, Color.PASTEL_PINK)
fun pastelBlue(text: String) = colorize(text, Color.PASTEL_BLUE)
fun pastelGreen(text: String) = colorize(text, Color.PASTEL_GREEN)
fun pastelYellow(text: String) = colorize(text, Color.PASTEL_YELLOW)
fun pastelPurple(text: String) = colorize(text, Color.PASTEL_PURPLE)
fun pastelOrange(text: String) = colorize(text, Color.PASTEL_ORANGE)
// --------------------------------------------------------
// Original convenience helpers (keeping all existing ones)
// --------------------------------------------------------
fun bold(text: String) = colorize(text, Color.WHITE.bold())
fun dim(text: String) = colorize(text, Color.GRAY)
fun black(text: String) = colorize(text, Color.BLACK)
fun red(text: String) = colorize(text, Color.RED)
fun green(text: String) = colorize(text, Color.GREEN)
fun yellow(text: String) = colorize(text, Color.YELLOW)
fun blue(text: String) = colorize(text, Color.BLUE)
fun magenta(text: String) = colorize(text, Color.MAGENTA)
fun cyan(text: String) = colorize(text, Color.CYAN)
fun white(text: String) = colorize(text, Color.WHITE)
fun gray(text: String) = colorize(text, Color.GRAY)
fun brightBlack(text: String) = colorize(text, Color.BRIGHT_BLACK)
fun brightRed(text: String) = colorize(text, Color.BRIGHT_RED)
fun brightGreen(text: String) = colorize(text, Color.BRIGHT_GREEN)
fun brightYellow(text: String) = colorize(text, Color.BRIGHT_YELLOW)
fun brightBlue(text: String) = colorize(text, Color.BRIGHT_BLUE)
fun brightMagenta(text: String) = colorize(text, Color.BRIGHT_MAGENTA)
fun brightCyan(text: String) = colorize(text, Color.BRIGHT_CYAN)
fun brightWhite(text: String) = colorize(text, Color.BRIGHT_WHITE)
// Presets
fun error(text: String) = colorize(text, Color.ERROR)
fun warn(text: String) = colorize(text, Color.WARN)
fun info(text: String) = colorize(text, Color.INFO)
fun debug(text: String) = colorize(text, Color.DEBUG)
// Bold helpers (keeping all existing ones)
fun boldBlack(text: String) = colorize(text, Color.BLACK.bold())
fun boldRed(text: String) = colorize(text, Color.RED.bold())
fun boldGreen(text: String) = colorize(text, Color.GREEN.bold())
fun boldYellow(text: String) = colorize(text, Color.YELLOW.bold())
fun boldBlue(text: String) = colorize(text, Color.BLUE.bold())
fun boldMagenta(text: String) = colorize(text, Color.MAGENTA.bold())
fun boldCyan(text: String) = colorize(text, Color.CYAN.bold())
fun boldWhite(text: String) = colorize(text, Color.WHITE.bold())
fun boldBrightBlack(text: String) = colorize(text, Color.BRIGHT_BLACK.bold())
fun boldBrightRed(text: String) = colorize(text, Color.BRIGHT_RED.bold())
fun boldBrightGreen(text: String) = colorize(text, Color.BRIGHT_GREEN.bold())
fun boldBrightYellow(text: String) = colorize(text, Color.BRIGHT_YELLOW.bold())
fun boldBrightBlue(text: String) = colorize(text, Color.BRIGHT_BLUE.bold())
fun boldBrightMagenta(text: String) = colorize(text, Color.BRIGHT_MAGENTA.bold())
fun boldBrightCyan(text: String) = colorize(text, Color.BRIGHT_CYAN.bold())
fun boldBrightWhite(text: String) = colorize(text, Color.BRIGHT_WHITE.bold())
// Dim helpers (keeping all existing ones)
fun dimBlack(text: String) = colorize(text, Color.BLACK.dim())
fun dimRed(text: String) = colorize(text, Color.RED.dim())
fun dimGreen(text: String) = colorize(text, Color.GREEN.dim())
fun dimYellow(text: String) = colorize(text, Color.YELLOW.dim())
fun dimBlue(text: String) = colorize(text, Color.BLUE.dim())
fun dimMagenta(text: String) = colorize(text, Color.MAGENTA.dim())
fun dimCyan(text: String) = colorize(text, Color.CYAN.dim())
fun dimWhite(text: String) = colorize(text, Color.WHITE.dim())
fun dimBrightBlack(text: String) = colorize(text, Color.BRIGHT_BLACK.dim())
fun dimBrightRed(text: String) = colorize(text, Color.BRIGHT_RED.dim())
fun dimBrightGreen(text: String) = colorize(text, Color.BRIGHT_GREEN.dim())
fun dimBrightYellow(text: String) = colorize(text, Color.BRIGHT_YELLOW.dim())
fun dimBrightBlue(text: String) = colorize(text, Color.BRIGHT_BLUE.dim())
fun dimBrightMagenta(text: String) = colorize(text, Color.BRIGHT_MAGENTA.dim())
fun dimBrightCyan(text: String) = colorize(text, Color.BRIGHT_CYAN.dim())
fun dimBrightWhite(text: String) = colorize(text, Color.BRIGHT_WHITE.dim())
// --------------------------------------------------------
// Utility functions
// --------------------------------------------------------
// Create a gradient effect
fun gradient(text: String, startColor: Color, endColor: Color): String {
if (!globalColorsEnabled || text.isEmpty()) return text
val length = text.length
if (length == 1) return colorize(text, startColor)
return text.mapIndexed { index, char ->
val ratio = index.toFloat() / (length - 1)
// Simple interpolation between colors (this is a basic implementation)
val color = if (ratio < 0.5f) startColor else endColor
colorize(char.toString(), color)
}.joinToString("")
}
// Rainbow effect
fun rainbow(text: String): String {
if (!globalColorsEnabled) return text
val colors = listOf(
Color.RED, Color.ORANGE, Color.YELLOW, Color.GREEN,
Color.CYAN, Color.BLUE, Color.MAGENTA
)
return text.mapIndexed { index, char ->
val colorIndex = index % colors.size
colorize(char.toString(), colors[colorIndex])
}.joinToString("")
}
// Strip all ANSI codes
fun stripColors(text: String): String = text.replace(Regex("\u001B\\[[0-9;]*m"), "")
// Get length without ANSI codes
fun getDisplayLength(text: String): Int = stripColors(text).length
}

View File

@@ -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<Any?, Boolean> {
description: String? = null,
private val defaultValue: Boolean = false,
) : KargsProperty<Boolean>(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
}

View File

@@ -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<T>(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<T> {
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
}
}

View File

@@ -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<T>(
val type: ArgType<T>,
val longName: String,
val shortName: String? = null,
val description: String = "",
description: String? = null,
val required: Boolean = false,
val default: T? = null
) : ReadWriteProperty<Any?, T?> {
private val defaultValue: T? = null
) : KargsProperty<T>(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
}

View File

@@ -0,0 +1,37 @@
package org.kargs
/**
* Option that can be used as a flag or with a value
*/
class OptionalOption(
val longName: String,
val shortName: String? = null,
description: String? = null,
private val defaultWhenPresent: String = "true"
) : KargsProperty<String>(description) {
private var wasExplicitlySet = false
init {
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 = str
wasExplicitlySet = true
}
/**
* Set as flag (no value provided)
*/
fun setAsFlag() {
value = defaultWhenPresent
wasExplicitlySet = true
}
fun isSet(): Boolean = wasExplicitlySet
}

View File

@@ -1,98 +1,363 @@
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<Subcommand>()
/**
* 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<String>) {
if (handleVersionFlag(args)) {
return // Exit after showing version
}
if (args.isEmpty()) {
if (config.helpOnEmpty) {
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")
printError("Unknown command: $cmdName")
if (!config.strictMode) {
printGlobalHelp()
}
return
}
if (args.contains("--help") || args.contains("-h")) {
// Check for help, global or command
if ("--help" in args || "-h" in args) {
cmd.printHelp()
return
}
// Reflection: find all Option, Flag, Argument properties
val props = cmd::class.members.filterIsInstance<kotlin.reflect.KProperty<*>>()
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<*> }
// Check for no-color
if ("--no-color" in args) {
Colors.setGlobalColorsEnabled(false)
}
try {
parseCommandArgs(cmd, args.sliceArray(1 ..< args.size))
validateRequiredOptions(cmd)
cmd.execute()
} catch (e: ArgumentParseException) {
handleParseError(e, cmd)
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 || searchName in aliases
}
}
/**
* Parse arguments for a specific command
*/
private fun parseCommandArgs(cmd: Subcommand, args: Array<String>) {
var i = 0
val positionalArgs = mutableListOf<String>()
// Parse args starting at index 1
var i = 1
while (i < args.size) {
val arg = args[i]
if ("--help" in arg || "-h" in arg) i++
if ("--no-color" in arg) 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)
}
/**
* Parse a long option (--option)
*/
private fun parseLongOption(cmd: Subcommand, key: String, args: Array<String>, index: Int): Int {
val option = cmd.options.firstOrNull { it.longName == key }
val flag = cmd.flags.firstOrNull { it.longName == key }
val optionalOption = cmd.optionalOptions.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}")
}
}
optionalOption != null -> {
// Check if next arg exists and doesn't start with -
if (index + 1 < args.size && !args[index + 1].startsWith("-")) {
// Has value
optionalOption.parseValue(args[index + 1])
index + 1
} else {
// Used as flag
optionalOption.setAsFlag()
index
}
}
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<String>, 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() }
val optionalOption = cmd.optionalOptions.firstOrNull { it.shortName == char.toString() }
when {
flag != null -> flag.setFlag()
optionalOption != null -> optionalOption.setAsFlag()
config.strictMode -> throw ArgumentParseException("Unknown flag -$char")
else -> printWarning("Unknown flag -$char")
}
}
return index
}
val option = cmd.options.firstOrNull { it.shortName == key }
val flag = cmd.flags.firstOrNull { it.shortName == key }
val optionalOption = cmd.optionalOptions.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}")
}
}
optionalOption != null -> {
// Check if next arg exists and doesn't start with -
if (index + 1 < args.size && !args[index + 1].startsWith("-")) {
// Has value
optionalOption.parseValue(args[index + 1])
index + 1
} else {
// Used as flag
optionalOption.setAsFlag()
index
}
}
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<String>) {
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 }
val missingArgs = cmd.arguments.filter { it.required && it.value == null }
val errors = mutableListOf<String>()
if (missingRequired.isNotEmpty()) {
val missing = missingRequired.joinToString(", ") { "--${it.longName}" }
errors.add("Missing required options: $missing")
}
if (missingArgs.isNotEmpty()) {
val missing = missingArgs.joinToString(", ") { it.name }
errors.add("Missing required arguments: $missing")
}
if (errors.isNotEmpty()) {
throw ArgumentParseException(errors.joinToString(", "))
}
}
/**
* Print global help menu
*/
private fun printGlobalHelp() {
println("Usage: $programName <command> [options]")
println("\nCommands:")
commands.forEach {
println(" ${it.name}\t${it.description}")
val versionInfo = config.programVersion?.let { " ${Colors.dimBlue("(v$it)")}" } ?: ""
println(Colors.boldWhite("Usage: $programName$versionInfo <command> [options]"))
println()
println(Colors.boldWhite("Commands:"))
commands.forEach { cmd ->
val aliases = if (cmd.aliases.isNotEmpty()) " (${cmd.aliases.joinToString(", ")})" else ""
println(" ${cmd.name}${Colors.dimBlue(aliases)}")
if (cmd.description.isNotEmpty()) {
println(Colors.dimMagenta(" ${cmd.description}"))
}
println()
}
println()
println("Use `$programName <command> --help` for more information about a command.")
}
/**
* Print error message with optional coloring
*/
private fun printError(message: String) {
println(Colors.error("Error: $message"))
}
/**
* Print warning message with optional coloring
*/
private fun printWarning(message: String) {
println(Colors.warn("Warning: $message"))
}
/**
* Handles parse errors
*
* @throw ArgumentParseException if in debug mode
*/
private fun handleParseError(e: ArgumentParseException, cmd: Subcommand) {
printError(e.message ?: "Parse error")
cmd.printHelp()
// Only show stack trace in debug mode
if (System.getProperty("debug") == "true") {
e.printStackTrace()
}
// Exit gracefully instead of throwing
// kotlin.system.exitProcess(1)
}
/**
* Check if version flag is present and handle it
*/
private fun handleVersionFlag(args: Array<String>): 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")
}
}
}
/**
* Custom exception for argument parsing errors
*/
class ArgumentParseException(message: String) : Exception(message)

View File

@@ -0,0 +1,25 @@
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
// 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()
}
init {
Colors.setGlobalColorsEnabled(colorsEnabled)
}
}

View File

@@ -1,41 +1,149 @@
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<String> = emptyList()
) {
private val _options = mutableListOf<Option<*>>()
private val _flags = mutableListOf<Flag>()
private val _arguments = mutableListOf<Argument<*>>()
private val _optionalOptions = mutableListOf<OptionalOption>()
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
is OptionalOption -> _optionalOptions += prop
}
}
// Public read-only access to registered properties
val options: List<Option<*>> get() = _options
val flags: List<Flag> get() = _flags
val arguments: List<Argument<*>> get() = _arguments
val optionalOptions: List<OptionalOption> get() = _optionalOptions
/**
* Execute this subcommand - must be implemented by subclasses
*/
abstract fun execute()
open fun printHelp() {
println("Usage: $name [options] [arguments]")
if (description.isNotEmpty()) println(description)
/**
* Print help information for this subcommand
*/
fun printHelp() {
println(Colors.boldWhite("Usage: $name ${"[options]"}${if (arguments.isNotEmpty()) " ${arguments.joinToString(" ") { if (it.required) "<${it.name}>" else "[${it.name}]" }}" else ""}"))
val props = this::class.members.filterIsInstance<kotlin.reflect.KProperty<*>>()
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<*> }
if (description.isNotEmpty()) {
println()
println(Colors.boldMagenta(description))
}
if (options.isNotEmpty()) {
println("\nOptions:")
options.forEach { o ->
println(" ${o.shortName?.let { "-$it, " } ?: ""}--${o.longName}\t${o.description}")
println()
println(Colors.boldWhite("Options:"))
options.forEach { option ->
val shortName = option.shortName?.let { "-$it, " } ?: " "
val required = if (option.required) Colors.dimBlue(" (required)") else ""
val defaultVal = option.getValueOrDefault()?.let { Colors.boldBlue(" [default: $it]") } ?: ""
val typeInfo = Colors.yellow(getTypeInfo(option.type))
println(" $shortName--${option.longName}${typeInfo}")
option.description?.let { desc ->
println(Colors.dimMagenta(" $desc$required$defaultVal"))
}
println()
}
}
if (optionalOptions.isNotEmpty()) {
println()
println(Colors.boldWhite("Optional Value Options:"))
optionalOptions.forEach { option ->
val shortName = option.shortName?.let { "-$it, " } ?: " "
println(" $shortName--${option.longName} [value]")
option.description?.let { desc ->
println(Colors.dimMagenta(" $desc (can be used as flag or with value)"))
}
println()
}
}
if (flags.isNotEmpty()) {
println("\nFlags:")
flags.forEach { f ->
println(" ${f.shortName?.let { "-$it, " } ?: ""}--${f.longName}\t${f.description}")
println()
println(Colors.boldWhite("Flags:"))
flags.forEach { flag ->
val shortName = flag.shortName?.let { "-$it, " } ?: " "
println(" $shortName--${flag.longName}")
flag.description?.let { desc ->
println(Colors.dimMagenta(" $desc"))
}
println()
}
}
if (arguments.isNotEmpty()) {
println("\nArguments:")
arguments.forEach { a ->
println(" ${a.name}\t${a.description}")
println()
println(Colors.boldWhite("Arguments:"))
arguments.forEach { arg ->
val required = Colors.dimBlue(if (arg.required) " (required)" else " (optional)")
val typeInfo = Colors.yellow(getTypeInfo(arg.type))
println(" ${arg.name}$typeInfo$required")
arg.description?.let { desc ->
println(Colors.dimMagenta(" $desc"))
}
println()
}
}
}
/**
* Validate all properties in this subcommand
* @return list of validation errors, empty if all valid
*/
fun validate(): List<String> {
val errors = mutableListOf<String>()
(options + flags + arguments).forEach { prop ->
prop.getValidationError()?.let { error ->
errors.add(error)
}
}
return errors
}
/**
* Type info helper method
*/
private fun getTypeInfo(type: ArgType<*>): String {
return when (type) {
is ArgType.StringType -> " <string>"
is ArgType.IntType -> " <int>"
is ArgType.DoubleType -> " <double>"
is ArgType.BooleanType -> " <bool>"
is ArgType.IntRange -> " <${type.min}-${type.max}>"
is ArgType.Choice -> " <${type.choices.joinToString("|")}>"
is ArgType.OptionalValue -> " [string]"
is ArgType.FilePath -> " <path>"
}
}
}

View File

@@ -0,0 +1,296 @@
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
import kotlin.io.path.createTempFile
import kotlin.io.path.createTempDirectory
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<ArgumentParseException> { type.convert("not-a-number") }
assertThrows<ArgumentParseException> { type.convert("12.5") }
assertThrows<ArgumentParseException> { 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<ArgumentParseException> { type.convert("maybe") }
assertThrows<ArgumentParseException> { 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<ArgumentParseException> { 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<ArgumentParseException> { type.convert("0") }
assertThrows<ArgumentParseException> { type.convert("11") }
assertThrows<ArgumentParseException> { 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<ArgumentParseException> { type.convert("yellow") }
assertThrows<ArgumentParseException> { 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))
}
}

View File

@@ -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<IllegalArgumentException> {
Argument(ArgType.String, "", "Description")
}
assertThrows<IllegalArgumentException> {
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())
}
}

View File

@@ -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<ArgumentParseException> {
flag.parseValue("maybe")
}
assertThrows<ArgumentParseException> {
flag.parseValue("2")
}
}
@Test
fun `flag throws on invalid names`() {
assertThrows<IllegalArgumentException> {
Flag("", "v") // blank long name
}
assertThrows<IllegalArgumentException> {
Flag("--invalid") // long name with dashes
}
assertThrows<IllegalArgumentException> {
Flag("valid", "ab") // short name too long
}
assertThrows<IllegalArgumentException> {
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
}
}

View File

@@ -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)
}
}

View File

@@ -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<IllegalArgumentException> {
Option(ArgType.String, "", "o") // blank long name
}
assertThrows<IllegalArgumentException> {
Option(ArgType.String, "--invalid", "o") // long name with dashes
}
assertThrows<IllegalArgumentException> {
Option(ArgType.String, "valid", "ab") // short name too long
}
assertThrows<IllegalArgumentException> {
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"
}
}

View File

@@ -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<ArgumentParseException> {
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)
}
}

View File

@@ -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<ArgumentParseException> {
parser.parse(arrayOf("test", "input.txt"))
}
assertFalse(testCmd.executed)
}
@Test
fun `throws exception on invalid integer`() {
assertThrows<ArgumentParseException> {
parser.parse(arrayOf("test", "--required", "value", "--number", "not-a-number", "input.txt"))
}
}
@Test
fun `throws exception on out of range value`() {
assertThrows<ArgumentParseException> {
parser.parse(arrayOf("test", "--required", "value", "--range", "15", "input.txt"))
}
}
@Test
fun `throws exception on invalid choice`() {
assertThrows<ArgumentParseException> {
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 <command>"))
assertTrue(output.contains("test"))
}
@Test
fun `shows global help on --help`() {
val output = TestUtils.captureOutput {
parser.parse(arrayOf("--help"))
}
assertTrue(output.contains("Usage: testapp <command>"))
}
@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<ArgumentParseException> {
parser.parse(arrayOf("test", "--required"))
}
}
@Test
fun `handles missing short option value`() {
assertThrows<ArgumentParseException> {
parser.parse(arrayOf("test", "-r"))
}
}
}

View File

@@ -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<IllegalArgumentException> {
TestUtils.TestSubcommand("", "Description")
}
assertThrows<IllegalArgumentException> {
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"])
}
}

View File

@@ -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<String> = emptyList()
) : Subcommand(name, description, aliases) {
var executed = false
var executionData: Map<String, Any?> = 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
)
}
}
}