diff --git a/src/PkgTemplatesCommandLineInterface.jl b/src/PkgTemplatesCommandLineInterface.jl index 2d17955..c7326c9 100644 --- a/src/PkgTemplatesCommandLineInterface.jl +++ b/src/PkgTemplatesCommandLineInterface.jl @@ -27,6 +27,9 @@ include("config_manager.jl") # Include PluginDiscovery module include("plugin_discovery.jl") +# Include PluginOptionParser module (shared by CreateCommand and ConfigCommand) +include("plugin_option_parser.jl") + # Include TemplateManager module include("template_manager.jl") diff --git a/src/config_command.jl b/src/config_command.jl index 2b6759e..2d90529 100644 --- a/src/config_command.jl +++ b/src/config_command.jl @@ -12,6 +12,7 @@ using TOML using ..PkgTemplatesCommandLineInterface: CommandResult, JTCError, ConfigurationError import ..ConfigManager import ..PluginDiscovery +import ..PluginOptionParser """ format_config(config::Dict{String, Any})::String @@ -90,100 +91,6 @@ function _resolve_section_target(defaults::Dict, section::AbstractString; return get(canonical, lowercase(s), s) end -""" - parse_plugin_option_value(value_str::AbstractString) - -Convert a string token into the appropriate Julia type for storage in TOML. -Mirrors `parse_plugin_option_value` in the Python port. -""" -function parse_plugin_option_value(value_str::AbstractString) - s = String(value_str) - lower = lowercase(s) - if lower in ("true", "yes") - return true - elseif lower in ("false", "no") - return false - elseif startswith(s, "[") && endswith(s, "]") - inner = strip(s[2:end-1]) - if isempty(inner) - return String[] - end - return [String(strip(strip(item), ['"', '\''])) for item in split(inner, ',')] - elseif occursin(r"^\d+$", s) - return parse(Int, s) - else - # Decimal-looking values (e.g., "1.2", "1.10") stay strings: downstream - # plugins such as ProjectFile expect a string they can parse into a - # VersionNumber, and Float coercion would also drop trailing zeros. - was_quoted = (startswith(s, '"') && endswith(s, '"')) || - (startswith(s, '\'') && endswith(s, '\'')) - if was_quoted - s = s[2:end-1] - end - # Auto-promote comma-separated strings to arrays for PkgTemplates.jl - # compatibility — but only when the user did NOT explicitly quote the - # value. Quotes signal "this is a literal string", so a value like - # `name="Doe, Jane"` must stay a single string instead of becoming - # ["Doe", "Jane"] (which would break String-typed fields like Git.name). - if !was_quoted && occursin(',', s) - return [String(strip(item)) for item in split(s, ',') if !isempty(strip(item))] - end - return s - end -end - -# Split an option string respecting quotes and `[...]` arrays so that -# `--plugin 'a=1 ignore=".vscode,.DS_Store"'` parses cleanly. -function _split_plugin_option_string(s::AbstractString)::Vector{String} - parts = String[] - current = IOBuffer() - in_quotes = false - quote_char = '\0' - bracket_depth = 0 - - for c in s - if c in ('"', '\'') && !in_quotes && bracket_depth == 0 - in_quotes = true - quote_char = c - print(current, c) - elseif in_quotes && c == quote_char - in_quotes = false - quote_char = '\0' - print(current, c) - elseif c == '[' && !in_quotes - bracket_depth += 1 - print(current, c) - elseif c == ']' && !in_quotes - bracket_depth = max(bracket_depth - 1, 0) - print(current, c) - elseif c == ' ' && !in_quotes && bracket_depth == 0 - piece = String(strip(String(take!(current)))) - if !isempty(piece) - push!(parts, piece) - end - else - print(current, c) - end - end - piece = String(strip(String(take!(current)))) - if !isempty(piece) - push!(parts, piece) - end - return parts -end - -# Parse a `key=value key2=value2` string into a Dict, with type coercion. -function _parse_plugin_kv_string(s::AbstractString)::Dict{String,Any} - options = Dict{String,Any}() - for part in _split_plugin_option_string(s) - if contains(part, '=') - k, v = split(part, '=', limit=2) - options[String(strip(k))] = parse_plugin_option_value(strip(v)) - end - end - return options -end - # Apply parsed `config set` arguments to the in-memory config dict. # Returns (updated_config, messages::Vector{String}). function _apply_set_args(config::Dict{String,Any}, sub_args::Dict{String,Any}) @@ -298,7 +205,7 @@ function _apply_set_args(config::Dict{String,Any}, sub_args::Dict{String,Any}) section = Dict{String,Any}() end if !isempty(value) - for (opt_key, opt_val) in _parse_plugin_kv_string(value) + for (opt_key, opt_val) in PluginOptionParser.parse_kv_string(value) section[opt_key] = opt_val push!(messages, "Set default $plugin_name.$opt_key: $(repr(opt_val))") end diff --git a/src/create_command.jl b/src/create_command.jl index ec196a2..ecde617 100644 --- a/src/create_command.jl +++ b/src/create_command.jl @@ -13,9 +13,10 @@ using ..PkgTemplatesCommandLineInterface: CommandResult, PackageGenerationError import ..ConfigManager import ..PackageGenerator import ..PluginDiscovery +import ..PluginOptionParser import ..TemplateManager -export execute, merge_config, parse_plugin_options, parse_plugin_option_value +export execute, merge_config, parse_plugin_options """ merge_config(config_defaults::Dict, cli_args::Dict)::Dict @@ -41,48 +42,6 @@ function merge_config(config_defaults::Dict, cli_args::Dict)::Dict return merged end -""" - parse_plugin_option_value(opt::String)::Tuple{String, Any} - -Parse plugin option from "key=value" string format. -Performs type inference: -- "true" / "false" → Bool -- "123" → Int -- "1.5" → Float64 -- "[a,b,c]" → Vector{String} -- Otherwise → String -""" -function parse_plugin_option_value(opt::String)::Tuple{String, Any} - parts = split(opt, '=', limit=2) - if length(parts) != 2 - error("Invalid plugin option format: '$opt'. Expected 'key=value'") - end - - key, value_str = parts[1], parts[2] - - value = if value_str == "true" - true - elseif value_str == "false" - false - elseif startswith(value_str, "[") && endswith(value_str, "]") - # Array parsing: "[item1,item2]" → ["item1", "item2"] - inner = value_str[2:end-1] # Remove brackets - if isempty(inner) - String[] - else - split(inner, ',') - end - elseif occursin(r"^\d+$", value_str) - parse(Int, value_str) - elseif occursin(r"^\d+\.\d+$", value_str) - parse(Float64, value_str) - else - value_str # String as-is - end - - return (String(key), value) -end - """ parse_plugin_options(args::Dict)::Dict{String, Dict{String, Any}} @@ -96,8 +55,9 @@ registration, the value is one of: - `nothing` → `--` not supplied; skip. - `""` → `--` supplied without a value; enable the plugin with default options (empty section). -- `"k1=v1 k2=v2 ..."` → `-- "..."`; split on whitespace and - parse each token as a typed `KEY=VALUE` pair. +- `"k1=v1 k2=v2 ..."` → `-- "..."`; route through + `PluginOptionParser.parse_kv_string` so quoted strings, bracket arrays, + and version-like values parse identically to the `config set` path. Output keys are canonicalised against `PluginDiscovery.canonical_names()` so PkgTemplates plugin types resolve via `getfield(PkgTemplates, Symbol(...))`. @@ -113,14 +73,9 @@ function parse_plugin_options(args::Dict)::Dict{String, Dict{String, Any}} if value === nothing continue elseif value isa AbstractString - section = Dict{String, Any}() - if !isempty(value) - for tok in split(value) - isempty(tok) && continue - opt_key, opt_value = parse_plugin_option_value(String(tok)) - section[opt_key] = opt_value - end - end + section = isempty(value) ? + Dict{String, Any}() : + PluginOptionParser.parse_kv_string(value) plugin_options[canonical_name] = section end end diff --git a/src/plugin_option_parser.jl b/src/plugin_option_parser.jl new file mode 100644 index 0000000..7729ac1 --- /dev/null +++ b/src/plugin_option_parser.jl @@ -0,0 +1,160 @@ +""" +PluginOptionParser module — single source of truth for converting plugin +option strings (`KEY=VALUE` bundles) into typed Julia values. + +Both `CreateCommand` (CLI plugin options) and `ConfigCommand` (`config set` +plugin options) consume the same shape — a single optional space-separated +string per `--plugin` flag — so they must parse it identically. Routing +both modules through this parser prevents the divergence that issue #9 +documented (Float coercion, missing quote handling, etc.). +""" +module PluginOptionParser + +export parse_value, split_string, parse_kv_string + +""" + parse_value(value_str::AbstractString) + +Convert a single value token into the appropriate Julia type: +- `"true"` / `"yes"` (case-insensitive) → `true` +- `"false"` / `"no"` (case-insensitive) → `false` +- `"[a, b, c]"` (bracket form) → `Vector{String}`, with surrounding quotes + on each item stripped +- `"123"` (digits only) → `Int` +- Decimal-looking values (e.g. `"1.2"`, `"1.10"`) **stay strings**, since + downstream plugins such as `ProjectFile` expect a string parsable as + `VersionNumber` and Float coercion would drop trailing zeros. +- An explicitly quoted string (`"..."` or `'...'`) has its quotes stripped + and is returned as a single string, even if it contains commas. +- An unquoted comma-separated value is auto-promoted to `Vector{String}` + for PkgTemplates.jl compatibility. +- Any other input is returned as a String unchanged. +""" +function parse_value(value_str::AbstractString) + s = String(value_str) + lower = lowercase(s) + if lower in ("true", "yes") + return true + elseif lower in ("false", "no") + return false + elseif startswith(s, "[") && endswith(s, "]") + inner = strip(s[2:end-1]) + if isempty(inner) + return String[] + end + return [String(strip(strip(item), ['"', '\''])) for item in split(inner, ',')] + elseif occursin(r"^\d+$", s) + return parse(Int, s) + else + was_quoted = (startswith(s, '"') && endswith(s, '"')) || + (startswith(s, '\'') && endswith(s, '\'')) + if was_quoted + s = s[2:end-1] + end + # Auto-promote comma-separated values to arrays — but only when the + # user did NOT explicitly quote the value. Quotes signal "this is + # a literal string", so a value like `name="Doe, Jane"` must stay + # a single string instead of becoming ["Doe", "Jane"] (which would + # break String-typed fields like Git.name). + if !was_quoted && occursin(',', s) + return [String(strip(item)) for item in split(s, ',') if !isempty(strip(item))] + end + return s + end +end + +""" + split_string(s::AbstractString)::Vector{String} + +Split a plugin option bundle on whitespace while respecting `"..."`, +`'...'`, and `[...]` so that values like `name="Doe, Jane"` or +`ignore=[.DS_Store, .vscode]` survive intact. + +A `'` or `"` only opens a quoted block when it appears at the start of +a token — that is, immediately after whitespace, `=`, or the start of +the input. Inside a value (e.g. `name=O'Connor`) an apostrophe is a +literal character and does not affect splitting. Empty pieces from +consecutive whitespace are skipped. Unmatched closing brackets clamp +`bracket_depth` at zero rather than going negative; the opening-quote +character is recorded so a `'` inside a `"..."` block does not close +the quoting context. +""" +function split_string(s::AbstractString)::Vector{String} + parts = String[] + current = IOBuffer() + in_quotes = false + quote_char = '\0' + bracket_depth = 0 + prev_char = '\0' # '\0' marks start-of-input + + for c in s + # A quote char only opens a quoted block at the start of a token, + # i.e. right after whitespace, `=`, or the start of input. Without + # this guard, `name=O'Connor email=x` would enter quote mode at + # the apostrophe and silently swallow the rest of the bundle. + at_token_start = prev_char == '\0' || isspace(prev_char) || prev_char == '=' + + if c in ('"', '\'') && !in_quotes && bracket_depth == 0 && at_token_start + in_quotes = true + quote_char = c + print(current, c) + elseif in_quotes && c == quote_char + in_quotes = false + quote_char = '\0' + print(current, c) + elseif c == '[' && !in_quotes + bracket_depth += 1 + print(current, c) + elseif c == ']' && !in_quotes + bracket_depth = max(bracket_depth - 1, 0) + print(current, c) + elseif isspace(c) && !in_quotes && bracket_depth == 0 + # Treat any whitespace (spaces, tabs, newlines) as a separator + # so pasted multiline bundles parse the same as single-line ones. + piece = String(strip(String(take!(current)))) + if !isempty(piece) + push!(parts, piece) + end + else + print(current, c) + end + prev_char = c + end + piece = String(strip(String(take!(current)))) + if !isempty(piece) + push!(parts, piece) + end + return parts +end + +""" + parse_kv_string(s::AbstractString)::Dict{String,Any} + +Parse a whitespace-separated `key=value key2=value2` bundle into a typed +Dict. Only tokens that already contain `=` are considered; other tokens +are silently dropped. Within each such token, whitespace around the key +and value is stripped before the value is passed to `parse_value` for +type coercion. This means `key=value` must remain a single token — +whitespace around `=` (for example, `k= v` or `k =v`) will split the +pair across tokens and will not be parsed as a single option. + +Returns an empty `Dict` for an empty input or a string with no `=` tokens. +""" +function parse_kv_string(s::AbstractString)::Dict{String,Any} + options = Dict{String,Any}() + for part in split_string(s) + if contains(part, '=') + k, v = split(part, '=', limit=2) + key = String(strip(k)) + # Drop empty-key tokens (e.g. `=value`, ` =x`) so downstream + # plugin construction and TOML writes never see an empty + # option name. + if !isempty(key) + options[key] = parse_value(strip(v)) + end + end + end + return options +end + +end # module PluginOptionParser diff --git a/test/runtests.jl b/test/runtests.jl index 5d150d4..e5b1a13 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -19,6 +19,7 @@ using Aqua include("test_types.jl") include("test_config_manager.jl") include("test_plugin_discovery.jl") + include("test_plugin_option_parser.jl") include("test_template_manager.jl") include("test_package_generator.jl") include("test_create_command.jl") diff --git a/test/test_config_command.jl b/test/test_config_command.jl index 461770d..d2ce5e9 100644 --- a/test/test_config_command.jl +++ b/test/test_config_command.jl @@ -289,60 +289,6 @@ using PkgTemplatesCommandLineInterface.ConfigCommand end end - # Contract: parse_plugin_option_value preserves the user's intent across - # types — bools, ints, decimals (kept as strings so VersionNumber survives), - # bracket arrays, comma-promoted arrays, and quoted strings. - @testset "parse_plugin_option_value contracts" begin - @testset "boolean synonyms cover true/yes/false/no but not 0/1" begin - @test ConfigCommand.parse_plugin_option_value("true") === true - @test ConfigCommand.parse_plugin_option_value("YES") === true - @test ConfigCommand.parse_plugin_option_value("false") === false - @test ConfigCommand.parse_plugin_option_value("No") === false - # `1`/`0` must round-trip as integers so plugin int options like - # `indent=1` don't become booleans. - @test ConfigCommand.parse_plugin_option_value("1") === 1 - @test ConfigCommand.parse_plugin_option_value("0") === 0 - end - - @testset "integer values keep their type" begin - @test ConfigCommand.parse_plugin_option_value("42") === 42 - @test ConfigCommand.parse_plugin_option_value("100") === 100 - end - - @testset "decimal-shaped values stay strings" begin - # ProjectFile.version expects a string parseable into VersionNumber; - # Float coercion both breaks the constructor and loses trailing zeros. - @test ConfigCommand.parse_plugin_option_value("1.10") == "1.10" - @test ConfigCommand.parse_plugin_option_value("1.10") isa AbstractString - @test ConfigCommand.parse_plugin_option_value("1.2") == "1.2" - end - - @testset "bracket-form arrays parse to Vector{String}" begin - @test ConfigCommand.parse_plugin_option_value("[a,b,c]") == ["a", "b", "c"] - @test ConfigCommand.parse_plugin_option_value("[]") == String[] - # Quoted items inside brackets keep their internal text - @test ConfigCommand.parse_plugin_option_value("[\"a b\",c]") == ["a b", "c"] - end - - @testset "unquoted comma values promote to arrays" begin - @test ConfigCommand.parse_plugin_option_value("a,b,c") == ["a", "b", "c"] - @test ConfigCommand.parse_plugin_option_value(".DS_Store,.vscode") == - [".DS_Store", ".vscode"] - end - - @testset "quoted strings keep commas as literal text" begin - # Without this contract, Git.name="Doe, Jane" silently becomes a - # Vector{String} and the plugin constructor fails. - @test ConfigCommand.parse_plugin_option_value("\"Doe, Jane\"") == "Doe, Jane" - @test ConfigCommand.parse_plugin_option_value("'a, b, c'") == "a, b, c" - end - - @testset "plain strings flow through unchanged" begin - @test ConfigCommand.parse_plugin_option_value("blue") == "blue" - @test ConfigCommand.parse_plugin_option_value("MyPkg") == "MyPkg" - end - end - # Contract: ConfigCommand.execute accepts both legacy flat dicts and the # ArgParse-shaped nested dict, and writes the same values either way. @testset "execute() ArgParse-shape contracts" begin diff --git a/test/test_create_command.jl b/test/test_create_command.jl index 7f994ad..5c1e6ac 100644 --- a/test/test_create_command.jl +++ b/test/test_create_command.jl @@ -55,33 +55,10 @@ import PkgTemplatesCommandLineInterface.CreateCommand end end - @testset "parse_plugin_option_value" begin - @testset "boolean values" begin - @test CreateCommand.parse_plugin_option_value("ssh=true") == ("ssh", true) - @test CreateCommand.parse_plugin_option_value("ssh=false") == ("ssh", false) - end - - @testset "integer values" begin - @test CreateCommand.parse_plugin_option_value("indent=4") == ("indent", 4) - @test CreateCommand.parse_plugin_option_value("count=123") == ("count", 123) - end - - @testset "float values" begin - @test CreateCommand.parse_plugin_option_value("version=1.5") == ("version", 1.5) - @test CreateCommand.parse_plugin_option_value("ratio=0.75") == ("ratio", 0.75) - end - - @testset "string values" begin - @test CreateCommand.parse_plugin_option_value("style=blue") == ("style", "blue") - @test CreateCommand.parse_plugin_option_value("name=MyPkg") == ("name", "MyPkg") - end - - @testset "array values" begin - key, val = CreateCommand.parse_plugin_option_value("items=[a,b,c]") - @test key == "items" - @test val == ["a", "b", "c"] - end - end + # `parse_plugin_option_value` was deleted in #9 (issue #9): the broken + # CreateCommand-local copy (Float coercion, no quote handling) is + # replaced by PluginOptionParser.parse_value, whose contract is + # exercised in test/test_plugin_option_parser.jl. @testset "parse_plugin_options" begin # ArgParse stores plugin options under the lowercase plugin name @@ -413,5 +390,55 @@ import PkgTemplatesCommandLineInterface.CreateCommand # "Plugin: " line is the cleanest way to confirm. @test !occursin("Plugin: ", out) end + + # Contract: with the parser unified under PluginOptionParser + # (issue #9), the input shapes that worked under `config set` + # must also work end-to-end under `create`. Each case below is + # the same shape that issue #9's acceptance criteria call out. + @testset "issue #9: quoted comma value preserved across CLI path" begin + # `--git 'name="Doe, Jane" email=x'` must reach Git as a + # single string `name`, not as a Vector split on the comma. + result, out = _e2e_dry_run( + ["create", "E2EPkg", "--user", "u", + "--git", "name=\"Doe, Jane\" email=x"], + ) + @test result.success == true + @test occursin("Plugin: Git", out) + @test occursin("name = Doe, Jane", out) + @test occursin("email = x", out) + end + + @testset "issue #9: bracket array reaches plugin options as Vector" begin + # `--git 'ignore=[.DS_Store, .vscode]'` must arrive as + # Vector{String}, not be split on whitespace inside the + # bracket nor flattened into a single string. + result, out = _e2e_dry_run( + ["create", "E2EPkg", "--user", "u", + "--git", "ignore=[.DS_Store, .vscode]"], + ) + @test result.success == true + @test occursin("Plugin: Git", out) + # The dry-run prints `ignore = [".DS_Store", ".vscode"]` for + # Vector values; assert the array formatting so this fails + # if the value is silently flattened into a single string + # such as ".DS_Store,.vscode". + @test occursin("ignore = [", out) + @test occursin("\".DS_Store\"", out) + @test occursin("\".vscode\"", out) + end + + @testset "issue #9: version-like value stays a String" begin + # `--projectfile version=1.10` must reach ProjectFile as the + # string "1.10", not as Float64(1.1) (which loses the trailing + # zero and breaks VersionNumber construction downstream). + result, out = _e2e_dry_run( + ["create", "E2EPkg", "--user", "u", + "--projectfile", "version=1.10"], + ) + @test result.success == true + @test occursin("Plugin: ProjectFile", out) + @test occursin("version = 1.10", out) + @test !occursin("version = 1.1\n", out) + end end end diff --git a/test/test_plugin_option_parser.jl b/test/test_plugin_option_parser.jl new file mode 100644 index 0000000..d4dac36 --- /dev/null +++ b/test/test_plugin_option_parser.jl @@ -0,0 +1,217 @@ +""" +Tests for PluginOptionParser module. + +Locks in the contract that both `create` and `config set` consume: a single +`KEY=VALUE` bundle string parses identically across both commands. The +parser's behaviour was audited under issue #9 and every case below must +keep matching across future edits. +""" + +using Test +using PkgTemplatesCommandLineInterface +import PkgTemplatesCommandLineInterface.PluginOptionParser + +@testset "PluginOptionParser" begin + # Contract: parse_value preserves the user's intent across types — + # bools, ints, decimals (kept as strings so VersionNumber survives), + # bracket arrays, comma-promoted arrays, and quoted strings. + @testset "parse_value contracts" begin + @testset "boolean synonyms cover true/yes/false/no but not 0/1" begin + @test PluginOptionParser.parse_value("true") === true + @test PluginOptionParser.parse_value("YES") === true + @test PluginOptionParser.parse_value("false") === false + @test PluginOptionParser.parse_value("No") === false + # `1`/`0` must round-trip as integers so plugin int options like + # `indent=1` don't become booleans. + @test PluginOptionParser.parse_value("1") === 1 + @test PluginOptionParser.parse_value("0") === 0 + end + + @testset "integer values keep their type" begin + @test PluginOptionParser.parse_value("42") === 42 + @test PluginOptionParser.parse_value("100") === 100 + end + + @testset "decimal-shaped values stay strings" begin + # ProjectFile.version expects a string parseable into VersionNumber; + # Float coercion both breaks the constructor and loses trailing zeros. + @test PluginOptionParser.parse_value("1.10") == "1.10" + @test PluginOptionParser.parse_value("1.10") isa AbstractString + @test PluginOptionParser.parse_value("1.2") == "1.2" + end + + @testset "bracket-form arrays parse to Vector{String}" begin + @test PluginOptionParser.parse_value("[a,b,c]") == ["a", "b", "c"] + @test PluginOptionParser.parse_value("[]") == String[] + # Quoted items inside brackets keep their internal text + @test PluginOptionParser.parse_value("[\"a b\",c]") == ["a b", "c"] + end + + @testset "unquoted comma values promote to arrays" begin + @test PluginOptionParser.parse_value("a,b,c") == ["a", "b", "c"] + @test PluginOptionParser.parse_value(".DS_Store,.vscode") == + [".DS_Store", ".vscode"] + end + + @testset "quoted strings keep commas as literal text" begin + # Without this contract, Git.name="Doe, Jane" silently becomes a + # Vector{String} and the plugin constructor fails. + @test PluginOptionParser.parse_value("\"Doe, Jane\"") == "Doe, Jane" + @test PluginOptionParser.parse_value("'a, b, c'") == "a, b, c" + end + + @testset "plain strings flow through unchanged" begin + @test PluginOptionParser.parse_value("blue") == "blue" + @test PluginOptionParser.parse_value("MyPkg") == "MyPkg" + end + end + + # Contract: split_string respects quotes and brackets so multi-key + # bundles never lose tokens to whitespace inside a single value. + @testset "split_string contracts" begin + @testset "splits unquoted tokens on whitespace" begin + @test PluginOptionParser.split_string("a=1 b=2") == ["a=1", "b=2"] + end + + @testset "preserves whitespace inside double quotes" begin + @test PluginOptionParser.split_string("name=\"Doe, Jane\" k=v") == + ["name=\"Doe, Jane\"", "k=v"] + end + + @testset "preserves whitespace inside single quotes" begin + @test PluginOptionParser.split_string("a='1 2' b=3") == ["a='1 2'", "b=3"] + end + + @testset "preserves whitespace inside brackets" begin + @test PluginOptionParser.split_string("ignore=[.DS_Store, .vscode] k=v") == + ["ignore=[.DS_Store, .vscode]", "k=v"] + end + + @testset "skips consecutive whitespace runs" begin + @test PluginOptionParser.split_string("a=1 b=2") == ["a=1", "b=2"] + end + + @testset "treats tabs and newlines as separators" begin + # The docstring claims "whitespace" splitting; pasted multiline + # input must parse the same as single-line. + @test PluginOptionParser.split_string("a=1\tb=2") == ["a=1", "b=2"] + @test PluginOptionParser.split_string("a=1\nb=2") == ["a=1", "b=2"] + @test PluginOptionParser.split_string("a=1\t\nb=2 c=3") == + ["a=1", "b=2", "c=3"] + end + + @testset "apostrophe in unquoted value is literal, not a delimiter" begin + # Without the at-token-start guard, the `'` after `O` would + # open a quoted block and swallow ` email=x` into the name + # value, dropping the email entirely. + @test PluginOptionParser.split_string("name=O'Connor email=x") == + ["name=O'Connor", "email=x"] + # Same idea with double quote inside an unquoted value. + @test PluginOptionParser.split_string("k=he\"said k2=v2") == + ["k=he\"said", "k2=v2"] + end + + @testset "quote at start-of-token still opens a quoted block" begin + # The token-start guard must not regress the canonical case: + # quoted values that contain whitespace or commas survive intact. + @test PluginOptionParser.split_string("name=\"Doe, Jane\" k=v") == + ["name=\"Doe, Jane\"", "k=v"] + @test PluginOptionParser.split_string("a='1 2' b=3") == + ["a='1 2'", "b=3"] + # A quoted token that is not preceded by `=` (just whitespace). + @test PluginOptionParser.split_string("'literal token' k=v") == + ["'literal token'", "k=v"] + end + + @testset "empty input yields empty vector" begin + @test PluginOptionParser.split_string("") == String[] + @test PluginOptionParser.split_string(" ") == String[] + end + end + + # Contract: parse_kv_string composes split_string and parse_value into + # a single typed Dict, dropping malformed (no `=`) tokens silently. + @testset "parse_kv_string contracts" begin + @testset "empty string yields empty Dict" begin + @test PluginOptionParser.parse_kv_string("") == Dict{String,Any}() + end + + @testset "single KEY=VALUE pair" begin + @test PluginOptionParser.parse_kv_string("ssh=true") == + Dict{String,Any}("ssh" => true) + end + + @testset "multiple pairs" begin + result = PluginOptionParser.parse_kv_string("ssh=true manifest=false") + @test result == Dict{String,Any}("ssh" => true, "manifest" => false) + end + + @testset "issue #9: quoted comma preserved as single string" begin + # Acceptance criterion: `--git 'name="Doe, Jane" email=x'` must + # preserve `name` as a single string, not split it on the comma. + result = PluginOptionParser.parse_kv_string("name=\"Doe, Jane\" email=x") + @test result["name"] == "Doe, Jane" + @test result["email"] == "x" + end + + @testset "issue #9: bracket array round-trips" begin + # Acceptance criterion: `--git 'ignore=[.DS_Store, .vscode]'` + # must arrive at PkgTemplates as Vector{String}. + result = PluginOptionParser.parse_kv_string("ignore=[.DS_Store, .vscode]") + @test result["ignore"] == [".DS_Store", ".vscode"] + end + + @testset "issue #9: version-like value stays String" begin + # Acceptance criterion: `--projectfile version=1.10` must reach + # ProjectFile as the string "1.10", not Float64(1.1). + result = PluginOptionParser.parse_kv_string("version=1.10") + @test result["version"] == "1.10" + @test result["version"] isa AbstractString + end + + @testset "trailing whitespace tolerated" begin + @test PluginOptionParser.parse_kv_string("k=v ") == + Dict{String,Any}("k" => "v") + end + + @testset "mixed quote types coexist" begin + result = PluginOptionParser.parse_kv_string("a='1' b=\"2\"") + @test result["a"] == "1" + @test result["b"] == "2" + end + + @testset "= inside quoted value preserved" begin + result = PluginOptionParser.parse_kv_string("k=\"val=with=equals\"") + @test result["k"] == "val=with=equals" + end + + @testset "tokens without = are silently dropped" begin + # `standalone` has no `=`, so it should not produce a Dict entry + # (it cannot meaningfully map to a key/value pair). + @test PluginOptionParser.parse_kv_string("standalone") == Dict{String,Any}() + # But a real KV next to a malformed token still produces the KV. + @test PluginOptionParser.parse_kv_string("standalone k=v") == + Dict{String,Any}("k" => "v") + end + + @testset "apostrophe inside value preserved as literal" begin + # End-to-end invariant: a literal `'` mid-value must not + # swallow following options. Previously this corruption + # silently dropped `email`. + result = PluginOptionParser.parse_kv_string("name=O'Connor email=x") + @test result["name"] == "O'Connor" + @test result["email"] == "x" + end + + @testset "empty-key tokens are dropped" begin + # `=value` (and similar) must not produce a Dict entry under + # the empty-string key — that would silently corrupt downstream + # plugin construction and TOML writes. + @test PluginOptionParser.parse_kv_string("=value") == Dict{String,Any}() + @test PluginOptionParser.parse_kv_string(" =x") == Dict{String,Any}() + # A real KV alongside an empty-key token still parses cleanly. + @test PluginOptionParser.parse_kv_string("=junk k=v") == + Dict{String,Any}("k" => "v") + end + end +end