diff --git a/.gitignore b/.gitignore index 456ca0f..4238184 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ # PSModule framework outputs folder outputs/* +output/ # .Net build output bin/ diff --git a/README.md b/README.md index 6319793..7104745 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# {{ NAME }} +# Toml -{{ DESCRIPTION }} +PowerShell module for reading and writing [TOML](https://toml.io) data, with full TOML 1.0.0 specification support. ## Prerequisites -This uses the following external resources: +- PowerShell 7.2 or later - The [PSModule framework](https://github.com/PSModule/Process-PSModule) for building, testing and publishing the module. ## Installation @@ -12,23 +12,104 @@ This uses the following external resources: To install the module from the PowerShell Gallery, you can use the following command: ```powershell -Install-PSResource -Name {{ NAME }} -Import-Module -Name {{ NAME }} +Install-PSResource -Name Toml +Import-Module -Name Toml ``` ## Usage -Here is a list of example that are typical use cases for the module. +### Parse a TOML string -### Example 1: Greet an entity +```powershell +$doc = ConvertFrom-Toml -InputObject @' +[database] +host = "localhost" +port = 5432 +enabled = true +'@ + +$doc.Data.database.host # "localhost" +$doc.Data.database.port # 5432 [long] +$doc.Data.database.enabled # $true +``` -Provide examples for typical commands that a user would like to do with the module. +### Import from a TOML file ```powershell -Greet-Entity -Name 'World' -Hello, World! +$doc = Import-Toml -Path './config.toml' +$doc.FilePath # absolute path to the file +$doc.Data # OrderedDictionary of all top-level keys ``` +### Serialize an object to TOML + +```powershell +$toml = ConvertTo-Toml -InputObject ([ordered]@{ + title = 'My App' + version = 1 + server = [ordered]@{ + host = 'localhost' + port = 8080 + } +}) +# title = "My App" +# version = 1 +# +# [server] +# host = "localhost" +# port = 8080 +``` + +### Export to a TOML file + +```powershell +$config = [ordered]@{ + name = 'example' + enabled = $true +} +Export-Toml -InputObject $config -Path './output.toml' +``` + +### Round-trip: file → modify → file + +```powershell +$doc = Import-Toml -Path './config.toml' +$doc.Data['version'] = 2 +Export-Toml -InputObject $doc -Path './config.toml' +``` + +## TOML type mapping + +| TOML type | PowerShell type | +|----------------------|----------------------------| +| String | `[string]` | +| Integer | `[long]` | +| Float | `[double]` | +| Boolean | `[bool]` | +| Offset date-time | `[System.DateTimeOffset]` | +| Local date-time | `[System.DateTime]` | +| Local date | `[System.DateTime]` | +| Local time | `[System.TimeSpan]` | +| Array | `[object[]]` | +| Table / Inline table | `[ordered]` hashtable | +| Array of tables | `[object[]]` of hashtables | + +## Commands + +| Command | Description | +|-------------------|------------------------------------------| +| `ConvertFrom-Toml` | Parse TOML text → `TomlDocument` | +| `ConvertTo-Toml` | Serialize object → TOML text | +| `Import-Toml` | Read TOML file → `TomlDocument` | +| `Export-Toml` | Write object or `TomlDocument` to file | + +## Implementation notes + +- Backed by [Tomlyn v2.0.0](https://github.com/xoofx/Tomlyn), a conformant .NET TOML 1.0.0 library. +- Duplicate keys and table redefinition are rejected per the TOML 1.0.0 spec. +- Files are written as UTF-8 without BOM. + + ### Example 2 Provide examples for typical commands that a user would like to do with the module. diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..b9572f7 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,106 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 +<# + .SYNOPSIS + Builds the Toml module from source into ./output/Toml/. + + .DESCRIPTION + Assembles all source files (classes, init, private functions, public functions) + into a single psm1 and creates a manifest, so tests can be run locally without + the PSModule CI pipeline. +#> +[CmdletBinding()] +param( + # Version to stamp into the manifest. + [string] $ModuleVersion = '0.0.1' +) + +$ErrorActionPreference = 'Stop' +$moduleName = 'Toml' +$srcPath = Join-Path $PSScriptRoot 'src' +$outputPath = Join-Path $PSScriptRoot 'output' $moduleName + +# ── clean / create output directory ───────────────────────────────────────── +if (Test-Path $outputPath) { + Remove-Item $outputPath -Recurse -Force +} +$null = New-Item -ItemType Directory -Path $outputPath -Force + +# ── copy assemblies ────────────────────────────────────────────────────────── +$assembliesSrc = Join-Path $srcPath 'assemblies' +$assembliesDst = Join-Path $outputPath 'assemblies' +$null = New-Item -ItemType Directory -Path $assembliesDst -Force +Copy-Item -Path (Join-Path $assembliesSrc '*.dll') -Destination $assembliesDst -ErrorAction SilentlyContinue + +# ── build psm1 ─────────────────────────────────────────────────────────────── +$psm1Path = Join-Path $outputPath "$moduleName.psm1" +$sb = [System.Text.StringBuilder]::new() + +# header +$headerPath = Join-Path $srcPath 'header.ps1' +if (Test-Path $headerPath) { + $null = $sb.AppendLine((Get-Content $headerPath -Raw)) +} + +# init — emit a corrected version that uses the assembled module's own PSScriptRoot +# The source init uses '../assemblies/Tomlyn.dll' relative to src/init/. +# In the built module, assemblies live next to the psm1, so we rewrite the path. +$initContent = @' +$assemblyPath = Join-Path -Path $PSScriptRoot -ChildPath 'assemblies\Tomlyn.dll' +$resolvedPath = [System.IO.Path]::GetFullPath($assemblyPath) + +if (-not [System.AppDomain]::CurrentDomain.GetAssemblies().Where({ $_.GetName().Name -eq 'Tomlyn' })) { + [System.Reflection.Assembly]::LoadFrom($resolvedPath) | Out-Null + Write-Verbose "Loaded Tomlyn assembly from: $resolvedPath" +} +'@ +$null = $sb.AppendLine($initContent) + +# classes private then public +foreach ($visibility in @('private', 'public')) { + $classPath = Join-Path $srcPath "classes/$visibility" + foreach ($f in (Get-ChildItem $classPath -Filter '*.ps1' -Recurse -ErrorAction SilentlyContinue)) { + $null = $sb.AppendLine((Get-Content $f.FullName -Raw)) + } +} + +# private functions +foreach ($f in (Get-ChildItem (Join-Path $srcPath 'functions/private') -Filter '*.ps1' -Recurse -ErrorAction SilentlyContinue)) { + $null = $sb.AppendLine((Get-Content $f.FullName -Raw)) +} + +# public functions +$publicFunctions = [System.Collections.Generic.List[string]]::new() +foreach ($f in (Get-ChildItem (Join-Path $srcPath 'functions/public') -Filter '*.ps1' -Recurse -ErrorAction SilentlyContinue)) { + $null = $sb.AppendLine((Get-Content $f.FullName -Raw)) + $publicFunctions.Add([System.IO.Path]::GetFileNameWithoutExtension($f.Name)) +} + +# finally +$finallyPath = Join-Path $srcPath 'finally.ps1' +if (Test-Path $finallyPath) { + $null = $sb.AppendLine((Get-Content $finallyPath -Raw)) +} + +# Export-ModuleMember +$null = $sb.AppendLine("Export-ModuleMember -Function @($($publicFunctions | ForEach-Object { "'$_'" } | Join-String -Separator ', '))") + +[System.IO.File]::WriteAllText($psm1Path, $sb.ToString(), [System.Text.UTF8Encoding]::new($false)) + +# ── write manifest ──────────────────────────────────────────────────────────── +$psd1Path = Join-Path $outputPath "$moduleName.psd1" +$manifest = @{ + Path = $psd1Path + ModuleVersion = $ModuleVersion + RootModule = "$moduleName.psm1" + FunctionsToExport = $publicFunctions.ToArray() + PowerShellVersion = '7.2' + Description = 'PowerShell module for reading and writing TOML data.' + Author = 'PSModule' + CompanyName = 'PSModule' + GUID = '6f1b6f8d-1234-4321-abcd-ef0123456789' +} +New-ModuleManifest @manifest + +Write-Host "Built $moduleName $ModuleVersion -> $outputPath" +Write-Host "Public functions: $($publicFunctions -join ', ')" diff --git a/examples/General.ps1 b/examples/General.ps1 index f29e88d..d9b484c 100644 --- a/examples/General.ps1 +++ b/examples/General.ps1 @@ -1,15 +1,76 @@ <# .SYNOPSIS - This is a general example of how to use the Toml module. + General usage examples for the Toml module. #> # Import the module Import-Module -Name 'Toml' -# Convert a TOML string to a PowerShell object -$toml = @' +# ── Parse TOML string ────────────────────────────────────────────────────── +$doc = ConvertFrom-Toml -InputObject @' [database] host = "localhost" port = 5432 +enabled = true +tags = ["production", "primary"] + +[database.credentials] +user = "admin" +'@ + +$doc.Data.database.host # "localhost" +$doc.Data.database.port # 5432 +$doc.Data.database.enabled # True +$doc.Data.database.tags # @("production", "primary") +$doc.Data.database.credentials.user # "admin" + +# ── Serialize to TOML ────────────────────────────────────────────────────── +$toml = ConvertTo-Toml -InputObject ([ordered]@{ + title = 'My Application' + version = 2 + debug = $false + server = [ordered]@{ + host = '0.0.0.0' + port = 8080 + } + features = @('auth', 'logging', 'metrics') +}) +Write-Host $toml + +# ── Import from file ─────────────────────────────────────────────────────── +# $doc = Import-Toml -Path './config.toml' +# $doc.FilePath # absolute path to the source file +# $doc.Data # [ordered] hashtable of all top-level keys + +# ── Export to file ───────────────────────────────────────────────────────── +# Export-Toml -InputObject ([ordered]@{ key = 'value' }) -Path './config.toml' + +# ── Round-trip: file → edit → file ──────────────────────────────────────── +# $doc = Import-Toml -Path './config.toml' +# $doc.Data['version'] = 3 +# Export-Toml -InputObject $doc -Path './config.toml' + +# ── Pipeline usage ───────────────────────────────────────────────────────── +# '[server] +# host = "localhost"' | ConvertFrom-Toml | ConvertTo-Toml + +# ── All TOML scalar types ────────────────────────────────────────────────── +$types = ConvertFrom-Toml -InputObject @' +a_string = "hello" +an_integer = 42 +a_float = 3.14 +a_bool = true +an_offset_dt = 1979-05-27T07:32:00Z +a_local_dt = 1979-05-27T07:32:00 +a_local_date = 1979-05-27 +a_local_time = 07:32:00 '@ -ConvertFrom-Toml -InputObject $toml + +$types.Data.a_string # [string] +$types.Data.an_integer # [long] +$types.Data.a_float # [double] +$types.Data.a_bool # [bool] +$types.Data.an_offset_dt # [System.DateTimeOffset] +$types.Data.a_local_dt # [System.DateTime] (Kind=Unspecified) +$types.Data.a_local_date # [System.DateTime] (00:00:00) +$types.Data.a_local_time # [System.TimeSpan] diff --git a/src/assemblies/LsonLib.dll b/src/assemblies/LsonLib.dll deleted file mode 100644 index 3661807..0000000 Binary files a/src/assemblies/LsonLib.dll and /dev/null differ diff --git a/src/assemblies/Tomlyn.dll b/src/assemblies/Tomlyn.dll new file mode 100644 index 0000000..b76eee4 Binary files /dev/null and b/src/assemblies/Tomlyn.dll differ diff --git a/src/classes/public/TomlDocument.ps1 b/src/classes/public/TomlDocument.ps1 new file mode 100644 index 0000000..3a6c46a --- /dev/null +++ b/src/classes/public/TomlDocument.ps1 @@ -0,0 +1,35 @@ +# Represents a parsed TOML document. +# Exposes the root key-value data as an ordered dictionary and records the +# file path when the document was loaded from disk. +class TomlDocument { + # The root key-value pairs of the TOML document, preserving insertion order. + [System.Collections.Specialized.OrderedDictionary] $Data + + # The absolute path to the source file, if loaded with Import-Toml. + # $null when the document was created from a string. + [string] $FilePath + + TomlDocument() { + $this.Data = [System.Collections.Specialized.OrderedDictionary]::new( + [System.StringComparer]::Ordinal + ) + } + + TomlDocument([System.Collections.Specialized.OrderedDictionary] $data) { + $this.Data = $data + } + + # Returns true when the document contains the given top-level key. + [bool] ContainsKey([string] $key) { + return $this.Data.Contains($key) + } + + # Returns the number of top-level keys. + [int] GetCount() { + return $this.Data.Count + } + + [string] ToString() { + return "TomlDocument[$($this.Data.Count) key(s)]" + } +} diff --git a/src/formats/CultureInfo.Format.ps1xml b/src/formats/CultureInfo.Format.ps1xml deleted file mode 100644 index a715e08..0000000 --- a/src/formats/CultureInfo.Format.ps1xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - System.Globalization.CultureInfo - - System.Globalization.CultureInfo - - - - - 16 - - - 16 - - - - - - - - LCID - - - Name - - - DisplayName - - - - - - - - diff --git a/src/formats/Mygciview.Format.ps1xml b/src/formats/Mygciview.Format.ps1xml deleted file mode 100644 index 4c972c2..0000000 --- a/src/formats/Mygciview.Format.ps1xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - mygciview - - System.IO.DirectoryInfo - System.IO.FileInfo - - - PSParentPath - - - - - - 7 - Left - - - - 26 - Right - - - - 26 - Right - - - - 14 - Right - - - - Left - - - - - - - - ModeWithoutHardLink - - - LastWriteTime - - - CreationTime - - - Length - - - Name - - - - - - - - diff --git a/src/functions/private/Toml/ConvertFrom-TomlDateTime.ps1 b/src/functions/private/Toml/ConvertFrom-TomlDateTime.ps1 new file mode 100644 index 0000000..eb9be73 --- /dev/null +++ b/src/functions/private/Toml/ConvertFrom-TomlDateTime.ps1 @@ -0,0 +1,59 @@ +function ConvertFrom-TomlDateTime { + <# + .SYNOPSIS + Converts a Tomlyn TomlDateTime to the appropriate PowerShell date/time type. + + .DESCRIPTION + Maps each TOML date/time variant to the most natural PowerShell type: + - OffsetDateTimeByZ / OffsetDateTimeByNumber -> [System.DateTimeOffset] + - LocalDateTime -> [System.DateTime] (Unspecified Kind) + - LocalDate -> [System.DateTime] (date only, 00:00:00) + - LocalTime -> [System.TimeSpan] (time of day) + + .EXAMPLE + ConvertFrom-TomlDateTime -TomlDt $tomlDateTimeValue + + Returns a [DateTimeOffset], [DateTime], or [TimeSpan] depending on kind. + + .INPUTS + [Tomlyn.TomlDateTime] + + .OUTPUTS + [object] — [System.DateTimeOffset], [System.DateTime], or [System.TimeSpan] + + .NOTES + Internal helper. Not exported. No pipeline input. + #> + [OutputType([System.DateTimeOffset], [System.DateTime], [System.TimeSpan])] + [CmdletBinding()] + param( + # The Tomlyn TomlDateTime to convert. + [Parameter(Mandatory)] + [Tomlyn.TomlDateTime] $TomlDt + ) + + switch ($TomlDt.Kind) { + ([Tomlyn.TomlDateTimeKind]::OffsetDateTimeByZ) { + return $TomlDt.DateTime + } + ([Tomlyn.TomlDateTimeKind]::OffsetDateTimeByNumber) { + return $TomlDt.DateTime + } + ([Tomlyn.TomlDateTimeKind]::LocalDateTime) { + # Strip offset — keep year/month/day/hour/minute/second as-is + $utc = $TomlDt.DateTime.DateTime + return [System.DateTime]::SpecifyKind($utc, [System.DateTimeKind]::Unspecified) + } + ([Tomlyn.TomlDateTimeKind]::LocalDate) { + $utc = $TomlDt.DateTime.Date + return [System.DateTime]::SpecifyKind($utc, [System.DateTimeKind]::Unspecified) + } + ([Tomlyn.TomlDateTimeKind]::LocalTime) { + return $TomlDt.DateTime.TimeOfDay + } + default { + # Fallback: return DateTimeOffset + return $TomlDt.DateTime + } + } +} diff --git a/src/functions/private/Toml/ConvertFrom-TomlynTable.ps1 b/src/functions/private/Toml/ConvertFrom-TomlynTable.ps1 new file mode 100644 index 0000000..dccaa19 --- /dev/null +++ b/src/functions/private/Toml/ConvertFrom-TomlynTable.ps1 @@ -0,0 +1,43 @@ +function ConvertFrom-TomlynTable { + <# + .SYNOPSIS + Converts a Tomlyn TomlTable to an [ordered] hashtable. + + .DESCRIPTION + Iterates every key-value pair in the Tomlyn model TomlTable and + calls ConvertFrom-TomlynValue recursively, building an + [ordered] hashtable that preserves TOML key order. + + .EXAMPLE + $table = [Tomlyn.TomlSerializer]::Deserialize[Tomlyn.Model.TomlTable]($toml, $opts) + ConvertFrom-TomlynTable -Table $table + + Returns an [ordered] hashtable of the table's contents. + + .INPUTS + [Tomlyn.Model.TomlTable] + + .OUTPUTS + [System.Collections.Specialized.OrderedDictionary] + + .NOTES + Internal helper. Not exported. No pipeline input. + #> + [OutputType([System.Collections.Specialized.OrderedDictionary])] + [CmdletBinding()] + param( + # The Tomlyn TomlTable to convert. + [Parameter(Mandatory)] + [Tomlyn.Model.TomlTable] $Table + ) + + $dict = [System.Collections.Specialized.OrderedDictionary]::new( + [System.StringComparer]::Ordinal + ) + + foreach ($kv in $Table) { + $dict[$kv.Key] = ConvertFrom-TomlynValue -Value $kv.Value + } + + return $dict +} diff --git a/src/functions/private/Toml/ConvertFrom-TomlynValue.ps1 b/src/functions/private/Toml/ConvertFrom-TomlynValue.ps1 new file mode 100644 index 0000000..5f76b42 --- /dev/null +++ b/src/functions/private/Toml/ConvertFrom-TomlynValue.ps1 @@ -0,0 +1,74 @@ +function ConvertFrom-TomlynValue { + <# + .SYNOPSIS + Converts a Tomlyn model value to a native PowerShell value. + + .DESCRIPTION + Recursively maps the Tomlyn object model (TomlTable, TomlTableArray, + TomlArray, TomlDateTime, and scalars) to corresponding PowerShell types: + - Tomlyn.Model.TomlTable -> [System.Collections.Specialized.OrderedDictionary] + - Tomlyn.Model.TomlTableArray -> [object[]] of [ordered] + - Tomlyn.Model.TomlArray -> [object[]] + - Tomlyn.TomlDateTime -> [DateTimeOffset], [datetime], or [timespan] + - string, bool, long, double -> their PowerShell equivalents + + .EXAMPLE + $table = [Tomlyn.TomlSerializer]::Deserialize[Tomlyn.Model.TomlTable]($toml, $opts) + ConvertFrom-TomlynValue -Value $table + + Returns an [ordered] hashtable representing the TOML table. + + .INPUTS + [object] — any Tomlyn model value. + + .OUTPUTS + [object] + + .NOTES + Internal helper. Not exported. No pipeline input. + #> + [OutputType( + [System.Collections.Specialized.OrderedDictionary], [object[]], + [string], [long], [double], [bool], + [System.DateTimeOffset], [System.DateTime], [System.TimeSpan] + )] + [CmdletBinding()] + param( + # The Tomlyn model value to convert. + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + return $null + } + + if ($Value -is [Tomlyn.Model.TomlTableArray]) { + # Array of tables: [[key]] sections + $list = [System.Collections.Generic.List[object]]::new() + foreach ($tableItem in $Value) { + $list.Add((ConvertFrom-TomlynTable -Table $tableItem)) + } + return , $list.ToArray() + } + + if ($Value -is [Tomlyn.Model.TomlTable]) { + return ConvertFrom-TomlynTable -Table $Value + } + + if ($Value -is [Tomlyn.Model.TomlArray]) { + $list = [System.Collections.Generic.List[object]]::new() + foreach ($item in $Value) { + $list.Add((ConvertFrom-TomlynValue -Value $item)) + } + return , $list.ToArray() + } + + if ($Value -is [Tomlyn.TomlDateTime]) { + return ConvertFrom-TomlDateTime -TomlDt $Value + } + + # Scalar: string, bool, long, double — already a native .NET type + return $Value +} diff --git a/src/functions/private/Toml/ConvertTo-TomlynArray.ps1 b/src/functions/private/Toml/ConvertTo-TomlynArray.ps1 new file mode 100644 index 0000000..68931f2 --- /dev/null +++ b/src/functions/private/Toml/ConvertTo-TomlynArray.ps1 @@ -0,0 +1,41 @@ +function ConvertTo-TomlynArray { + <# + .SYNOPSIS + Converts a PowerShell enumerable to a Tomlyn TomlArray. + + .DESCRIPTION + Iterates each element of the input collection and calls ConvertTo-TomlynValue + recursively. Strings are enumerated character-by-character by default in + PowerShell, so strings are handled explicitly before the IEnumerable path. + + .EXAMPLE + ConvertTo-TomlynArray -Value @(1, 2, 3) + + Returns a [Tomlyn.Model.TomlArray] with three integer elements. + + .INPUTS + [System.Collections.IEnumerable] + + .OUTPUTS + [Tomlyn.Model.TomlArray] + + .NOTES + Internal helper. Not exported. No pipeline input. + #> + [OutputType([Tomlyn.Model.TomlArray])] + [CmdletBinding()] + param( + # The collection to convert. + [Parameter(Mandatory)] + [System.Collections.IEnumerable] $Value + ) + + $array = [Tomlyn.Model.TomlArray]::new() + + foreach ($item in $Value) { + $array.Add((ConvertTo-TomlynValue -Value $item)) + } + + # Wrap to prevent PowerShell from enumerating TomlArray (IEnumerable) + return , $array +} diff --git a/src/functions/private/Toml/ConvertTo-TomlynTable.ps1 b/src/functions/private/Toml/ConvertTo-TomlynTable.ps1 new file mode 100644 index 0000000..c49d7ff --- /dev/null +++ b/src/functions/private/Toml/ConvertTo-TomlynTable.ps1 @@ -0,0 +1,55 @@ +function ConvertTo-TomlynTable { + <# + .SYNOPSIS + Converts a PowerShell dictionary or PSCustomObject to a Tomlyn TomlTable. + + .DESCRIPTION + Iterates the properties/keys of the input and calls ConvertTo-TomlynValue + recursively for each value. Accepts [hashtable], [ordered], + [System.Collections.Specialized.OrderedDictionary], and [PSCustomObject]. + + .EXAMPLE + ConvertTo-TomlynTable -Value @{ host = 'localhost'; port = 5432 } + + Returns a [Tomlyn.Model.TomlTable] with two entries. + + .INPUTS + [object] + + .OUTPUTS + [Tomlyn.Model.TomlTable] + + .NOTES + Internal helper. Not exported. No pipeline input. + #> + [OutputType([Tomlyn.Model.TomlTable])] + [CmdletBinding()] + param( + # The dictionary or PSCustomObject to convert. + [Parameter(Mandatory)] + [object] $Value + ) + + $table = [Tomlyn.Model.TomlTable]::new() + + if ($Value -is [System.Management.Automation.PSCustomObject]) { + foreach ($prop in $Value.PSObject.Properties) { + if ($prop.MemberType -notin 'NoteProperty', 'ScriptProperty', 'Property') { + continue + } + $table[$prop.Name] = ConvertTo-TomlynValue -Value $prop.Value + } + } elseif ($Value -is [System.Collections.IDictionary]) { + foreach ($key in $Value.Keys) { + $table[$key.ToString()] = ConvertTo-TomlynValue -Value $Value[$key] + } + } else { + throw [System.InvalidOperationException]::new( + "Cannot convert '$($Value.GetType().FullName)' to a TOML table. " + + 'Provide a [hashtable], [ordered], or [PSCustomObject].' + ) + } + + # Wrap in array to prevent PowerShell from enumerating TomlTable (which implements IEnumerable) + return , $table +} diff --git a/src/functions/private/Toml/ConvertTo-TomlynValue.ps1 b/src/functions/private/Toml/ConvertTo-TomlynValue.ps1 new file mode 100644 index 0000000..b89ccf2 --- /dev/null +++ b/src/functions/private/Toml/ConvertTo-TomlynValue.ps1 @@ -0,0 +1,128 @@ +function ConvertTo-TomlynValue { + <# + .SYNOPSIS + Converts a PowerShell value to the appropriate Tomlyn model object. + + .DESCRIPTION + Maps PowerShell types to Tomlyn model objects used by TomlSerializer: + - [System.DateTimeOffset] -> Tomlyn.TomlDateTime (OffsetDateTimeByNumber) + - [System.DateTime] (date-only = midnight) -> Tomlyn.TomlDateTime (LocalDate) + - [System.DateTime] (has time component) -> Tomlyn.TomlDateTime (LocalDateTime) + - [System.TimeSpan] -> Tomlyn.TomlDateTime (LocalTime) + - [hashtable] / [OrderedDictionary] / [PSCustomObject] / [PSObject] -> TomlTable + - [array] / [List] / [IEnumerable] -> TomlArray + - [bool] -> bool + - [int16/32/64] / [byte] / [sbyte] -> long + - [single/double] -> double + - [string] -> string + - everything else -> string via ToString() + + .EXAMPLE + ConvertTo-TomlynValue -Value 42 + Returns [long] 42. + + .EXAMPLE + ConvertTo-TomlynValue -Value @{ key = 'val' } + Returns a [Tomlyn.Model.TomlTable]. + + .INPUTS + [object] + + .OUTPUTS + [object] + + .NOTES + Internal helper. Not exported. No pipeline input. + #> + [OutputType([object])] + [CmdletBinding()] + param( + # The PowerShell value to convert. + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + throw [System.ArgumentNullException]::new( + 'Value', + 'TOML does not support null values. Remove the key or supply a valid value.' + ) + } + + # Unwrap PSObject wrapper + if ($Value -is [System.Management.Automation.PSObject] -and + $Value -isnot [System.Management.Automation.PSCustomObject]) { + $Value = $Value.BaseObject + } + + if ($Value -is [System.DateTimeOffset]) { + return [Tomlyn.TomlDateTime]::new( + $Value, + 0, + [Tomlyn.TomlDateTimeKind]::OffsetDateTimeByNumber + ) + } + + if ($Value -is [System.DateTime]) { + if ($Value.TimeOfDay -eq [System.TimeSpan]::Zero) { + # Date-only: use the (year, month, day) constructor for LocalDate + return [Tomlyn.TomlDateTime]::new($Value.Year, $Value.Month, $Value.Day) + } else { + # Date + time: use (DateTime) constructor for LocalDateTime + return [Tomlyn.TomlDateTime]::new( + [System.DateTime]::SpecifyKind($Value, [System.DateTimeKind]::Unspecified) + ) + } + } + + if ($Value -is [System.TimeSpan]) { + # LocalTime — build a DateTimeOffset on epoch with the time component + $dto = [System.DateTimeOffset]::new( + 1970, 1, 1, + $Value.Hours, $Value.Minutes, $Value.Seconds, + [System.TimeSpan]::Zero + ) + return [Tomlyn.TomlDateTime]::new($dto, 0, [Tomlyn.TomlDateTimeKind]::LocalTime) + } + + if ($Value -is [bool]) { + return $Value + } + + if ($Value -is [System.Int16] -or + $Value -is [System.Int32] -or + $Value -is [System.Int64] -or + $Value -is [System.Byte] -or + $Value -is [System.SByte] -or + $Value -is [System.UInt16] -or + $Value -is [System.UInt32] -or + $Value -is [System.UInt64]) { + return [long]$Value + } + + if ($Value -is [System.Single] -or $Value -is [System.Double]) { + return [double]$Value + } + + if ($Value -is [string]) { + return $Value + } + + if ($Value -is [System.Collections.IDictionary] -or + $Value -is [System.Management.Automation.PSCustomObject]) { + $subTable = ConvertTo-TomlynTable -Value $Value + # Use , to prevent PowerShell from iterating the TomlTable (IEnumerable) in the pipeline + return , $subTable + } + + # Array and list types + if ($Value -is [System.Collections.IEnumerable]) { + $subArray = ConvertTo-TomlynArray -Value $Value + # Use , to prevent PowerShell from iterating the TomlArray (IEnumerable) in the pipeline + return , $subArray + } + + # Fallback: stringify + return $Value.ToString() +} diff --git a/src/functions/public/ConvertFrom-Toml.ps1 b/src/functions/public/ConvertFrom-Toml.ps1 deleted file mode 100644 index 7e67845..0000000 --- a/src/functions/public/ConvertFrom-Toml.ps1 +++ /dev/null @@ -1,24 +0,0 @@ -function ConvertFrom-Toml { - <# - .SYNOPSIS - Converts a TOML string to a PowerShell object. - - .DESCRIPTION - Converts a TOML formatted string into a PowerShell hashtable or object. - - .EXAMPLE - ConvertFrom-Toml -InputObject '[database] - host = "localhost" - port = 5432' - - Converts a TOML string to a PowerShell object. - #> - [CmdletBinding()] - param ( - # The TOML string to convert. - [Parameter(Mandatory)] - [string] $InputObject - ) - $null = $InputObject - throw [System.NotImplementedException] 'ConvertFrom-Toml is not yet implemented.' -} diff --git a/src/functions/public/Toml/ConvertFrom-Toml.ps1 b/src/functions/public/Toml/ConvertFrom-Toml.ps1 new file mode 100644 index 0000000..da96dfb --- /dev/null +++ b/src/functions/public/Toml/ConvertFrom-Toml.ps1 @@ -0,0 +1,101 @@ +function ConvertFrom-Toml { + <# + .SYNOPSIS + Converts a TOML string to a TomlDocument object. + + .DESCRIPTION + Parses a TOML-formatted string using the Tomlyn library and returns a + TomlDocument whose Data property contains an ordered dictionary of the + document's key-value pairs. TOML tables become nested ordered dictionaries, + arrays become object arrays, and TOML scalar types are mapped to PowerShell + types as follows: + + | TOML type | PowerShell type | + |---------------------|--------------------------| + | String | [string] | + | Integer | [long] | + | Float | [double] | + | Boolean | [bool] | + | Offset date-time | [System.DateTimeOffset] | + | Local date-time | [System.DateTime] | + | Local date | [System.DateTime] | + | Local time | [System.TimeSpan] | + | Array | [object[]] | + | Table / Inline table| [ordered] hashtable | + + Full TOML 1.0.0 specification is supported. + + .EXAMPLE + $doc = ConvertFrom-Toml -InputObject '[database] + host = "localhost" + port = 5432' + + $doc.Data.database.host # returns 'localhost' + $doc.Data.database.port # returns 5432 + + Parses a simple TOML document with a table and two keys. + + .EXAMPLE + $toml = Get-Content 'config.toml' -Raw + $config = ConvertFrom-Toml -InputObject $toml + $config.Data.server.timeout + + Reads a TOML file and accesses a nested value. + + .INPUTS + [string] + + .OUTPUTS + [TomlDocument] + + .NOTES + Throws [Tomlyn.TomlException] or [System.InvalidOperationException] on + invalid TOML input. Error messages include line and column information + from the Tomlyn parser. + #> + [OutputType([TomlDocument])] + [CmdletBinding()] + param( + # The TOML-formatted string to parse. Must be valid TOML 1.0.0. + [Parameter(Mandatory, ValueFromPipeline)] + [ValidateNotNullOrEmpty()] + [string] $InputObject + ) + + process { + $opts = [Tomlyn.TomlSerializerOptions]::new() + $tomlTable = $null + + try { + # SyntaxParser.ParseStrict validates the full document and throws a + # detailed exception on duplicate keys or table redefinition, which + # TomlSerializer.Deserialize does not enforce by itself. + $null = [Tomlyn.Parsing.SyntaxParser]::ParseStrict($InputObject, $opts, $null, $true) + } catch { + throw [System.InvalidOperationException]::new( + "Failed to parse TOML: $($_.Exception.Message)", + $_.Exception + ) + } + + try { + $tomlTable = [Tomlyn.TomlSerializer]::Deserialize[Tomlyn.Model.TomlTable]( + $InputObject, + $opts + ) + } catch [Tomlyn.TomlException] { + throw [System.InvalidOperationException]::new( + "Failed to parse TOML: $($_.Exception.Message)", + $_.Exception + ) + } catch { + throw [System.InvalidOperationException]::new( + "Unexpected error parsing TOML: $($_.Exception.Message)", + $_.Exception + ) + } + + $data = ConvertFrom-TomlynTable -Table $tomlTable + [TomlDocument]::new($data) + } +} diff --git a/src/functions/public/Toml/ConvertTo-Toml.ps1 b/src/functions/public/Toml/ConvertTo-Toml.ps1 new file mode 100644 index 0000000..4739b4e --- /dev/null +++ b/src/functions/public/Toml/ConvertTo-Toml.ps1 @@ -0,0 +1,97 @@ +function ConvertTo-Toml { + <# + .SYNOPSIS + Converts a PowerShell object graph to a TOML string. + + .DESCRIPTION + Serializes a [TomlDocument], [hashtable], [ordered] dictionary, or + [PSCustomObject] into a TOML-formatted string using the Tomlyn library. + + Accepted input types and their TOML encoding: + - [string] -> TOML String + - [bool] -> TOML Boolean (true / false) + - [int16/32/64] / [byte] -> TOML Integer + - [single] / [double] -> TOML Float + - [System.DateTimeOffset] -> TOML Offset date-time + - [System.DateTime] (no time component) -> TOML Local date + - [System.DateTime] (has time) -> TOML Local date-time + - [System.TimeSpan] -> TOML Local time + - [hashtable] / [ordered] / [PSCustomObject] -> TOML Table + - [array] / [List] / IEnumerable -> TOML Array + - [TomlDocument] -> TOML document from .Data + + Null values are not supported by TOML and cause a terminating error. + + .EXAMPLE + ConvertTo-Toml -InputObject ([ordered]@{ + title = 'Config' + server = [ordered]@{ host = 'localhost'; port = 8080 } + }) + + Produces: + title = "Config" + [server] + host = "localhost" + port = 8080 + + .EXAMPLE + $doc = ConvertFrom-Toml -InputObject $tomlString + ConvertTo-Toml -InputObject $doc + + Round-trips a parsed TOML document back to a TOML string. + + .INPUTS + [object] + + .OUTPUTS + [string] + + .NOTES + The output format follows Tomlyn's default serialization, which produces + valid TOML 1.0.0. Key order is preserved when the input is an [ordered] + dictionary or [TomlDocument]. + #> + [OutputType([string])] + [CmdletBinding()] + param( + # The object to serialize to TOML. Accepts TomlDocument, hashtable, + # ordered dictionary, or PSCustomObject. + [Parameter(Mandatory, ValueFromPipeline)] + [ValidateNotNull()] + [object] $InputObject + ) + + process { + # Unwrap TomlDocument to its data dict + $source = if ($InputObject -is [TomlDocument]) { + $InputObject.Data + } else { + $InputObject + } + + $tomlTable = $null + try { + $tomlTable = ConvertTo-TomlynTable -Value $source + } catch [System.ArgumentNullException] { + throw [System.InvalidOperationException]::new( + "Cannot serialize object: $($_.Exception.Message)", + $_.Exception + ) + } catch { + throw [System.InvalidOperationException]::new( + "Failed to build TOML model: $($_.Exception.Message)", + $_.Exception + ) + } + + $opts = [Tomlyn.TomlSerializerOptions]::new() + try { + [Tomlyn.TomlSerializer]::Serialize[Tomlyn.Model.TomlTable]($tomlTable, $opts) + } catch { + throw [System.InvalidOperationException]::new( + "Failed to serialize TOML: $($_.Exception.Message)", + $_.Exception + ) + } + } +} diff --git a/src/functions/public/Toml/Export-Toml.ps1 b/src/functions/public/Toml/Export-Toml.ps1 new file mode 100644 index 0000000..b4ce236 --- /dev/null +++ b/src/functions/public/Toml/Export-Toml.ps1 @@ -0,0 +1,72 @@ +function Export-Toml { + <# + .SYNOPSIS + Serializes an object graph to a TOML file. + + .DESCRIPTION + Converts the input object to a TOML string using ConvertTo-Toml and + writes it to the specified file path. Intermediate directories are + created when they do not exist. + + The file is written in UTF-8 encoding without a BOM, which is the + recommended encoding for TOML files. + + .EXAMPLE + $config = [ordered]@{ + title = 'My App' + server = [ordered]@{ host = 'localhost'; port = 8080 } + } + Export-Toml -InputObject $config -Path 'config.toml' + + Writes a TOML file with a string key and a nested table. + + .EXAMPLE + Import-Toml 'input.toml' | Export-Toml -Path 'output.toml' + + Round-trips a TOML file: parse then re-serialize. + + .INPUTS + [object] — pipeline input supported. + + .OUTPUTS + [void] — nothing is written to the output stream. + + .NOTES + Throws when: + - the object graph contains null values (TOML has no null) + - the object graph contains types that cannot be serialized to TOML + - the file cannot be created or written + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The object to serialize. Accepts TomlDocument, hashtable, + # ordered dictionary, or PSCustomObject. + [Parameter(Mandatory, ValueFromPipeline)] + [ValidateNotNull()] + [object] $InputObject, + + # Destination file path. Created (including parent directories) if absent. + [Parameter(Mandatory, Position = 0)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + process { + $tomlString = ConvertTo-Toml -InputObject $InputObject + + $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + $parent = [System.IO.Path]::GetDirectoryName($resolvedPath) + + if (-not [System.IO.Directory]::Exists($parent)) { + $null = [System.IO.Directory]::CreateDirectory($parent) + } + + if ($PSCmdlet.ShouldProcess($resolvedPath, 'Write TOML file')) { + [System.IO.File]::WriteAllText( + $resolvedPath, + $tomlString, + [System.Text.UTF8Encoding]::new($false) # UTF-8 without BOM + ) + } + } +} diff --git a/src/functions/public/Toml/Import-Toml.ps1 b/src/functions/public/Toml/Import-Toml.ps1 new file mode 100644 index 0000000..51037e0 --- /dev/null +++ b/src/functions/public/Toml/Import-Toml.ps1 @@ -0,0 +1,55 @@ +function Import-Toml { + <# + .SYNOPSIS + Imports and parses a TOML file into a TomlDocument. + + .DESCRIPTION + Reads the content of a TOML file from disk and parses it using + ConvertFrom-Toml. The returned TomlDocument includes the resolved + absolute file path in its FilePath property. + + UTF-8 encoding (with or without BOM) is used when reading the file. + + .EXAMPLE + $config = Import-Toml -Path 'config.toml' + $config.Data.database.host + + Reads config.toml and returns a TomlDocument. The nested value is + accessed via the Data property. + + .EXAMPLE + Import-Toml -Path 'settings.toml' | ForEach-Object { $_.Data } + + Pipes the document and inspects the data dictionary. + + .INPUTS + [string] — pipeline input supported for the Path parameter. + + .OUTPUTS + [TomlDocument] + + .NOTES + Throws when: + - the file does not exist + - the file cannot be read + - the file content is not valid TOML 1.0.0 + #> + [OutputType([TomlDocument])] + [CmdletBinding()] + param( + # Path to the TOML file to import. Accepts relative and absolute paths. + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + process { + $resolvedPath = Resolve-Path -Path $Path -ErrorAction Stop + + $content = [System.IO.File]::ReadAllText($resolvedPath.ProviderPath) + + $doc = ConvertFrom-Toml -InputObject $content + $doc.FilePath = $resolvedPath.ProviderPath + $doc + } +} diff --git a/src/init/initializer.ps1 b/src/init/initializer.ps1 index 28396fb..c3cf3f5 100644 --- a/src/init/initializer.ps1 +++ b/src/init/initializer.ps1 @@ -1,3 +1,7 @@ -Write-Verbose '-------------------------------' -Write-Verbose '--- THIS IS AN INITIALIZER ---' -Write-Verbose '-------------------------------' +$assemblyPath = Join-Path -Path $PSScriptRoot -ChildPath '..\assemblies\Tomlyn.dll' +$resolvedPath = [System.IO.Path]::GetFullPath($assemblyPath) + +if (-not [System.AppDomain]::CurrentDomain.GetAssemblies().Where({ $_.GetName().Name -eq 'Tomlyn' })) { + [System.Reflection.Assembly]::LoadFrom($resolvedPath) | Out-Null + Write-Verbose "Loaded Tomlyn assembly from: $resolvedPath" +} diff --git a/src/modules/OtherPSModule.psm1 b/src/modules/OtherPSModule.psm1 deleted file mode 100644 index 5d6af8e..0000000 --- a/src/modules/OtherPSModule.psm1 +++ /dev/null @@ -1,19 +0,0 @@ -function Get-OtherPSModule { - <# - .SYNOPSIS - Performs tests on a module. - - .DESCRIPTION - A longer description of the function. - - .EXAMPLE - Get-OtherPSModule -Name 'World' - #> - [CmdletBinding()] - param( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/scripts/loader.ps1 b/src/scripts/loader.ps1 deleted file mode 100644 index 973735a..0000000 --- a/src/scripts/loader.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -Write-Verbose '-------------------------' -Write-Verbose '--- THIS IS A LOADER ---' -Write-Verbose '-------------------------' diff --git a/src/types/DirectoryInfo.Types.ps1xml b/src/types/DirectoryInfo.Types.ps1xml deleted file mode 100644 index aef538b..0000000 --- a/src/types/DirectoryInfo.Types.ps1xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - System.IO.FileInfo - - - Status - Success - - - - - System.IO.DirectoryInfo - - - Status - Success - - - - diff --git a/src/types/FileInfo.Types.ps1xml b/src/types/FileInfo.Types.ps1xml deleted file mode 100644 index 4cfaf6b..0000000 --- a/src/types/FileInfo.Types.ps1xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - System.IO.FileInfo - - - Age - - ((Get-Date) - ($this.CreationTime)).Days - - - - - diff --git a/src/variables/private/PrivateVariables.ps1 b/src/variables/private/PrivateVariables.ps1 deleted file mode 100644 index f1fc2c3..0000000 --- a/src/variables/private/PrivateVariables.ps1 +++ /dev/null @@ -1,47 +0,0 @@ -$script:HabitablePlanets = @( - @{ - Name = 'Earth' - Mass = 5.97 - Diameter = 12756 - DayLength = 24.0 - }, - @{ - Name = 'Mars' - Mass = 0.642 - Diameter = 6792 - DayLength = 24.7 - }, - @{ - Name = 'Proxima Centauri b' - Mass = 1.17 - Diameter = 11449 - DayLength = 5.15 - }, - @{ - Name = 'Kepler-442b' - Mass = 2.34 - Diameter = 11349 - DayLength = 5.7 - }, - @{ - Name = 'Kepler-452b' - Mass = 5.0 - Diameter = 17340 - DayLength = 20.0 - } -) - -$script:InhabitedPlanets = @( - @{ - Name = 'Earth' - Mass = 5.97 - Diameter = 12756 - DayLength = 24.0 - }, - @{ - Name = 'Mars' - Mass = 0.642 - Diameter = 6792 - DayLength = 24.7 - } -) diff --git a/src/variables/public/Moons.ps1 b/src/variables/public/Moons.ps1 deleted file mode 100644 index dd0f33c..0000000 --- a/src/variables/public/Moons.ps1 +++ /dev/null @@ -1,6 +0,0 @@ -$script:Moons = @( - @{ - Planet = 'Earth' - Name = 'Moon' - } -) diff --git a/src/variables/public/Planets.ps1 b/src/variables/public/Planets.ps1 deleted file mode 100644 index 5927bc5..0000000 --- a/src/variables/public/Planets.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -$script:Planets = @( - @{ - Name = 'Mercury' - Mass = 0.330 - Diameter = 4879 - DayLength = 4222.6 - }, - @{ - Name = 'Venus' - Mass = 4.87 - Diameter = 12104 - DayLength = 2802.0 - }, - @{ - Name = 'Earth' - Mass = 5.97 - Diameter = 12756 - DayLength = 24.0 - } -) diff --git a/src/variables/public/SolarSystems.ps1 b/src/variables/public/SolarSystems.ps1 deleted file mode 100644 index acbcedf..0000000 --- a/src/variables/public/SolarSystems.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -$script:SolarSystems = @( - @{ - Name = 'Solar System' - Planets = $script:Planets - Moons = $script:Moons - }, - @{ - Name = 'Alpha Centauri' - Planets = @() - Moons = @() - }, - @{ - Name = 'Sirius' - Planets = @() - Moons = @() - } -) diff --git a/tests/ConvertFrom-Toml.Tests.ps1 b/tests/ConvertFrom-Toml.Tests.ps1 new file mode 100644 index 0000000..c563543 --- /dev/null +++ b/tests/ConvertFrom-Toml.Tests.ps1 @@ -0,0 +1,420 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +Describe 'Toml' { + BeforeAll { + . (Join-Path $PSScriptRoot 'bootstrap.ps1') + } + + Describe 'ConvertFrom-Toml' { + + Context 'ConvertFrom-Toml - return type' { + It 'ConvertFrom-Toml - returns a TomlDocument for a minimal document' { + $result = ConvertFrom-Toml -InputObject 'key = "value"' + $result.GetType().Name | Should -Be 'TomlDocument' + } + + It 'ConvertFrom-Toml - Data property is an OrderedDictionary' { + $result = ConvertFrom-Toml -InputObject 'key = "value"' + $result.Data | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + } + + It 'ConvertFrom-Toml - FilePath is null when parsed from string' { + $result = ConvertFrom-Toml -InputObject 'key = "value"' + $result.FilePath | Should -BeNullOrEmpty + } + + It 'ConvertFrom-Toml - accepts pipeline input' { + $result = 'key = "value"' | ConvertFrom-Toml + $result.GetType().Name | Should -Be 'TomlDocument' + } + } + + Context 'ConvertFrom-Toml - strings' { + It 'ConvertFrom-Toml - parses basic string' { + $result = ConvertFrom-Toml -InputObject 'key = "hello"' + $result.Data['key'] | Should -Be 'hello' + } + + It 'ConvertFrom-Toml - parses literal string' { + $result = ConvertFrom-Toml -InputObject "key = 'C:\path'" + $result.Data['key'] | Should -Be 'C:\path' + } + + It 'ConvertFrom-Toml - parses basic string with escape sequences' { + $result = ConvertFrom-Toml -InputObject 'key = "tab\there"' + $result.Data['key'] | Should -Be "tab`there" + } + + It 'ConvertFrom-Toml - parses basic string with quoted escape' { + $result = ConvertFrom-Toml -InputObject 'key = "say \"hi\""' + $result.Data['key'] | Should -Be 'say "hi"' + } + + It 'ConvertFrom-Toml - parses Unicode escape sequence' { + $result = ConvertFrom-Toml -InputObject 'key = "\u03B1"' + $result.Data['key'] | Should -Be ([char]0x03B1).ToString() + } + + It 'ConvertFrom-Toml - parses multi-line basic string' { + $toml = "key = `"`"`"`nline one`nline two`n`"`"`"" + $result = ConvertFrom-Toml -InputObject $toml + $result.Data['key'] | Should -Match 'line one' + $result.Data['key'] | Should -Match 'line two' + } + + It 'ConvertFrom-Toml - parses multi-line literal string' { + $toml = "key = '''" + [System.Environment]::NewLine + "raw\nno escape" + [System.Environment]::NewLine + "'''" + $result = ConvertFrom-Toml -InputObject $toml + $result.Data['key'] | Should -Match 'raw\\nno escape' + } + + It 'ConvertFrom-Toml - parses empty string' { + $result = ConvertFrom-Toml -InputObject 'key = ""' + $result.Data['key'] | Should -Be '' + } + + It 'ConvertFrom-Toml - parses strings from file' { + $path = Join-Path $PSScriptRoot 'data\strings.toml' + $result = ConvertFrom-Toml -InputObject (Get-Content $path -Raw) + $result.Data['literal'] | Should -Be 'C:\Users\nodejs\templates' + $result.Data['empty'] | Should -Be '' + } + } + + Context 'ConvertFrom-Toml - integers' { + It 'ConvertFrom-Toml - parses decimal integer' { + $result = ConvertFrom-Toml -InputObject 'n = 42' + $result.Data['n'] | Should -Be 42 + $result.Data['n'] | Should -BeOfType [long] + } + + It 'ConvertFrom-Toml - parses negative integer' { + $result = ConvertFrom-Toml -InputObject 'n = -17' + $result.Data['n'] | Should -Be -17 + } + + It 'ConvertFrom-Toml - parses zero' { + $result = ConvertFrom-Toml -InputObject 'n = 0' + $result.Data['n'] | Should -Be 0 + } + + It 'ConvertFrom-Toml - parses integer with underscore separator' { + $result = ConvertFrom-Toml -InputObject 'n = 1_000_000' + $result.Data['n'] | Should -Be 1000000 + } + + It 'ConvertFrom-Toml - parses hexadecimal integer' { + $result = ConvertFrom-Toml -InputObject 'n = 0xFF' + $result.Data['n'] | Should -Be 255 + } + + It 'ConvertFrom-Toml - parses octal integer' { + $result = ConvertFrom-Toml -InputObject 'n = 0o17' + $result.Data['n'] | Should -Be 15 + } + + It 'ConvertFrom-Toml - parses binary integer' { + $result = ConvertFrom-Toml -InputObject 'n = 0b1010' + $result.Data['n'] | Should -Be 10 + } + + It 'ConvertFrom-Toml - parses all integer forms from file' { + $path = Join-Path $PSScriptRoot 'data\integers.toml' + $result = ConvertFrom-Toml -InputObject (Get-Content $path -Raw) + $result.Data['decimal'] | Should -Be 42 + $result.Data['negative'] | Should -Be -17 + $result.Data['hex'] | Should -Be 3735928559 # 0xDEADBEEF as unsigned long + $result.Data['octal'] | Should -Be 493 # 0o755 + $result.Data['binary'] | Should -Be 214 # 0b11010110 + } + } + + Context 'ConvertFrom-Toml - floats' { + It 'ConvertFrom-Toml - parses positive float' { + $result = ConvertFrom-Toml -InputObject 'n = 3.14' + $result.Data['n'] | Should -Be 3.14 + $result.Data['n'] | Should -BeOfType [double] + } + + It 'ConvertFrom-Toml - parses negative float' { + $result = ConvertFrom-Toml -InputObject 'n = -0.01' + $result.Data['n'] | Should -Be -0.01 + } + + It 'ConvertFrom-Toml - parses scientific notation' { + $result = ConvertFrom-Toml -InputObject 'n = 5e+22' + $result.Data['n'] | Should -Be 5e22 + } + + It 'ConvertFrom-Toml - parses inf' { + $result = ConvertFrom-Toml -InputObject 'n = inf' + $result.Data['n'] | Should -Be ([double]::PositiveInfinity) + } + + It 'ConvertFrom-Toml - parses -inf' { + $result = ConvertFrom-Toml -InputObject 'n = -inf' + $result.Data['n'] | Should -Be ([double]::NegativeInfinity) + } + + It 'ConvertFrom-Toml - parses nan' { + $result = ConvertFrom-Toml -InputObject 'n = nan' + [double]::IsNaN($result.Data['n']) | Should -BeTrue + } + + It 'ConvertFrom-Toml - parses floats from file' { + $path = Join-Path $PSScriptRoot 'data\floats.toml' + $result = ConvertFrom-Toml -InputObject (Get-Content $path -Raw) + ([double]$result.Data['positive'] - 3.1415) | Should -BeLessThan 0.0001 + ([double]$result.Data['negative'] - (-0.01)) | Should -BeLessThan 0.0001 + } + } + + Context 'ConvertFrom-Toml - booleans' { + It 'ConvertFrom-Toml - parses true' { + $result = ConvertFrom-Toml -InputObject 'flag = true' + $result.Data['flag'] | Should -Be $true + $result.Data['flag'] | Should -BeOfType [bool] + } + + It 'ConvertFrom-Toml - parses false' { + $result = ConvertFrom-Toml -InputObject 'flag = false' + $result.Data['flag'] | Should -Be $false + } + } + + Context 'ConvertFrom-Toml - datetimes' { + It 'ConvertFrom-Toml - parses offset datetime with Z suffix as DateTimeOffset' { + $result = ConvertFrom-Toml -InputObject 'dt = 1979-05-27T07:32:00Z' + $result.Data['dt'] | Should -BeOfType [System.DateTimeOffset] + ([System.DateTimeOffset]$result.Data['dt']).Year | Should -Be 1979 + ([System.DateTimeOffset]$result.Data['dt']).Month | Should -Be 5 + ([System.DateTimeOffset]$result.Data['dt']).Day | Should -Be 27 + } + + It 'ConvertFrom-Toml - parses offset datetime with numeric offset as DateTimeOffset' { + $result = ConvertFrom-Toml -InputObject 'dt = 1979-05-27T07:32:00+05:30' + $result.Data['dt'] | Should -BeOfType [System.DateTimeOffset] + $offset = ([System.DateTimeOffset]$result.Data['dt']).Offset + $offset.Hours | Should -Be 5 + $offset.Minutes | Should -Be 30 + } + + It 'ConvertFrom-Toml - parses local datetime as DateTime with Unspecified kind' { + $result = ConvertFrom-Toml -InputObject 'dt = 1979-05-27T07:32:00' + $result.Data['dt'] | Should -BeOfType [System.DateTime] + ([System.DateTime]$result.Data['dt']).Kind | Should -Be ([System.DateTimeKind]::Unspecified) + ([System.DateTime]$result.Data['dt']).Year | Should -Be 1979 + } + + It 'ConvertFrom-Toml - parses local date as DateTime with zero time' { + $result = ConvertFrom-Toml -InputObject 'dt = 1979-05-27' + $result.Data['dt'] | Should -BeOfType [System.DateTime] + $dt = [System.DateTime]$result.Data['dt'] + $dt.Year | Should -Be 1979 + $dt.Month | Should -Be 5 + $dt.Day | Should -Be 27 + $dt.Hour | Should -Be 0 + $dt.Minute | Should -Be 0 + $dt.Second | Should -Be 0 + } + + It 'ConvertFrom-Toml - parses local time as TimeSpan' { + $result = ConvertFrom-Toml -InputObject 'tm = 07:32:00' + $result.Data['tm'] | Should -BeOfType [System.TimeSpan] + ([System.TimeSpan]$result.Data['tm']).Hours | Should -Be 7 + ([System.TimeSpan]$result.Data['tm']).Minutes | Should -Be 32 + } + + It 'ConvertFrom-Toml - parses all datetime types from file' { + $path = Join-Path $PSScriptRoot 'data\datetimes.toml' + $result = ConvertFrom-Toml -InputObject (Get-Content $path -Raw) + $result.Data['offset_dt_z'] | Should -BeOfType [System.DateTimeOffset] + $result.Data['offset_dt_num'] | Should -BeOfType [System.DateTimeOffset] + $result.Data['local_dt'] | Should -BeOfType [System.DateTime] + $result.Data['local_date'] | Should -BeOfType [System.DateTime] + $result.Data['local_time'] | Should -BeOfType [System.TimeSpan] + } + } + + Context 'ConvertFrom-Toml - arrays' { + It 'ConvertFrom-Toml - parses integer array' { + $result = ConvertFrom-Toml -InputObject 'arr = [1, 2, 3]' + $result.Data['arr'] | Should -HaveCount 3 + $result.Data['arr'][0] | Should -Be 1 + $result.Data['arr'][2] | Should -Be 3 + } + + It 'ConvertFrom-Toml - parses string array' { + $result = ConvertFrom-Toml -InputObject 'arr = ["a", "b", "c"]' + $result.Data['arr'][0] | Should -Be 'a' + $result.Data['arr'][1] | Should -Be 'b' + } + + It 'ConvertFrom-Toml - parses empty array' { + $result = ConvertFrom-Toml -InputObject 'arr = []' + $result.Data['arr'] | Should -HaveCount 0 + } + + It 'ConvertFrom-Toml - parses nested array' { + $result = ConvertFrom-Toml -InputObject 'arr = [[1, 2], [3, 4]]' + $result.Data['arr'] | Should -HaveCount 2 + $result.Data['arr'][0] | Should -HaveCount 2 + $result.Data['arr'][0][1] | Should -Be 2 + } + + It 'ConvertFrom-Toml - parses multiline array' { + $toml = "arr = [`n 1,`n 2,`n 3,`n]" + $result = ConvertFrom-Toml -InputObject $toml + $result.Data['arr'] | Should -HaveCount 3 + } + + It 'ConvertFrom-Toml - parses arrays from file' { + $path = Join-Path $PSScriptRoot 'data\arrays.toml' + $result = ConvertFrom-Toml -InputObject (Get-Content $path -Raw) + $result.Data['integers'] | Should -HaveCount 3 + $result.Data['strings'] | Should -HaveCount 3 + $result.Data['empty'] | Should -HaveCount 0 + $result.Data['nested'] | Should -HaveCount 2 + } + } + + Context 'ConvertFrom-Toml - tables' { + It 'ConvertFrom-Toml - parses standard table' { + $toml = "[server]`nhost = `"localhost`"" + $result = ConvertFrom-Toml -InputObject $toml + $result.Data['server'] | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $result.Data['server']['host'] | Should -Be 'localhost' + } + + It 'ConvertFrom-Toml - parses inline table' { + $result = ConvertFrom-Toml -InputObject 'point = { x = 1, y = 2 }' + $result.Data['point'] | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $result.Data['point']['x'] | Should -Be 1 + $result.Data['point']['y'] | Should -Be 2 + } + + It 'ConvertFrom-Toml - parses nested tables via dotted header' { + $toml = "[a.b.c]`nkey = `"deep`"" + $result = ConvertFrom-Toml -InputObject $toml + $result.Data['a']['b']['c']['key'] | Should -Be 'deep' + } + + It 'ConvertFrom-Toml - tables from file' { + $path = Join-Path $PSScriptRoot 'data\tables.toml' + $result = ConvertFrom-Toml -InputObject (Get-Content $path -Raw) + $result.Data['simple']['key'] | Should -Be 'value' + $result.Data['dotted']['key']['works'] | Should -Be $true + $result.Data['inline_parent']['inline']['one'] | Should -Be 1 + } + } + + Context 'ConvertFrom-Toml - array of tables' { + It 'ConvertFrom-Toml - parses array of tables' { + $path = Join-Path $PSScriptRoot 'data\array-of-tables.toml' + $result = ConvertFrom-Toml -InputObject (Get-Content $path -Raw) + $result.Data['products'] | Should -HaveCount 2 + $result.Data['products'][0]['name'] | Should -Be 'Hammer' + $result.Data['products'][1]['name'] | Should -Be 'Nail' + } + + It 'ConvertFrom-Toml - parses nested array of tables' { + $path = Join-Path $PSScriptRoot 'data\array-of-tables.toml' + $result = ConvertFrom-Toml -InputObject (Get-Content $path -Raw) + $result.Data['fruits'][0]['varieties'] | Should -HaveCount 2 + $result.Data['fruits'][0]['varieties'][0]['name'] | Should -Be 'red delicious' + } + } + + Context 'ConvertFrom-Toml - keys' { + It 'ConvertFrom-Toml - parses bare key' { + $result = ConvertFrom-Toml -InputObject 'bare_key = "value"' + $result.Data['bare_key'] | Should -Be 'value' + } + + It 'ConvertFrom-Toml - parses quoted key' { + $result = ConvertFrom-Toml -InputObject '"quoted key" = "value"' + $result.Data['quoted key'] | Should -Be 'value' + } + + It 'ConvertFrom-Toml - parses dotted key' { + $result = ConvertFrom-Toml -InputObject 'a.b = "value"' + $result.Data['a']['b'] | Should -Be 'value' + } + + It 'ConvertFrom-Toml - preserves key order' { + $toml = "z = 1`ny = 2`nx = 3" + $result = ConvertFrom-Toml -InputObject $toml + $keys = $result.Data.Keys + $keys[0] | Should -Be 'z' + $keys[1] | Should -Be 'y' + $keys[2] | Should -Be 'x' + } + } + + Context 'ConvertFrom-Toml - comments and whitespace' { + It 'ConvertFrom-Toml - ignores inline comments' { + $result = ConvertFrom-Toml -InputObject 'key = "value" # this is a comment' + $result.Data['key'] | Should -Be 'value' + } + + It 'ConvertFrom-Toml - ignores full-line comments' { + $toml = "# comment`nkey = `"value`"" + $result = ConvertFrom-Toml -InputObject $toml + $result.Data['key'] | Should -Be 'value' + } + + It 'ConvertFrom-Toml - handles CRLF line endings' { + $toml = "a = 1`r`nb = 2" + $result = ConvertFrom-Toml -InputObject $toml + $result.Data['a'] | Should -Be 1 + $result.Data['b'] | Should -Be 2 + } + } + + Context 'ConvertFrom-Toml - full example file' { + It 'ConvertFrom-Toml - parses full example document' { + $path = Join-Path $PSScriptRoot 'data\full-example.toml' + $result = ConvertFrom-Toml -InputObject (Get-Content $path -Raw) + $result.Data['title'] | Should -Be 'TOML Example' + $result.Data['owner']['name'] | Should -Be 'Tom Preston-Werner' + $result.Data['database']['enabled'] | Should -Be $true + $result.Data['database']['ports'] | Should -HaveCount 3 + $result.Data['servers']['alpha']['ip'] | Should -Be '10.0.0.1' + $result.Data['servers']['beta']['role'] | Should -Be 'backend' + } + } + + Context 'ConvertFrom-Toml - error handling' { + It 'ConvertFrom-Toml - throws on invalid TOML syntax' { + { ConvertFrom-Toml -InputObject 'invalid = = "bad"' } | Should -Throw + } + + It 'ConvertFrom-Toml - throws on duplicate key' { + $toml = "key = 1`nkey = 2" + { ConvertFrom-Toml -InputObject $toml } | Should -Throw + } + + It 'ConvertFrom-Toml - throws on missing value' { + { ConvertFrom-Toml -InputObject 'key =' } | Should -Throw + } + + It 'ConvertFrom-Toml - throws on empty string input' { + { ConvertFrom-Toml -InputObject '' } | Should -Throw + } + + It 'ConvertFrom-Toml - throws on redefining a table as a key' { + $toml = "[a]`nkey = 1`n[a]`nother = 2" + { ConvertFrom-Toml -InputObject $toml } | Should -Throw + } + } + } +} diff --git a/tests/ConvertTo-Toml.Tests.ps1 b/tests/ConvertTo-Toml.Tests.ps1 new file mode 100644 index 0000000..04d455b --- /dev/null +++ b/tests/ConvertTo-Toml.Tests.ps1 @@ -0,0 +1,212 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +Describe 'Toml' { + BeforeAll { + . (Join-Path $PSScriptRoot 'bootstrap.ps1') + } + + Describe 'ConvertTo-Toml' { + + Context 'ConvertTo-Toml - return type' { + It 'ConvertTo-Toml - returns a string' { + $result = ConvertTo-Toml -InputObject ([ordered]@{ key = 'value' }) + $result | Should -BeOfType [string] + } + + It 'ConvertTo-Toml - result is non-empty for non-empty input' { + $result = ConvertTo-Toml -InputObject ([ordered]@{ key = 'value' }) + $result | Should -Not -BeNullOrEmpty + } + + It 'ConvertTo-Toml - accepts pipeline input' { + $result = [ordered]@{ key = 'value' } | ConvertTo-Toml + $result | Should -BeOfType [string] + } + } + + Context 'ConvertTo-Toml - string serialization' { + It 'ConvertTo-Toml - serializes a simple string key' { + $result = ConvertTo-Toml -InputObject ([ordered]@{ title = 'Hello' }) + $result | Should -Match 'title = "Hello"' + } + + It 'ConvertTo-Toml - output is valid TOML that round-trips' { + $original = [ordered]@{ key = 'value' } + $toml = ConvertTo-Toml -InputObject $original + $roundTrip = ConvertFrom-Toml -InputObject $toml + $roundTrip.Data['key'] | Should -Be 'value' + } + } + + Context 'ConvertTo-Toml - integer serialization' { + It 'ConvertTo-Toml - serializes integer' { + $result = ConvertTo-Toml -InputObject ([ordered]@{ n = [long]42 }) + $result | Should -Match 'n = 42' + } + + It 'ConvertTo-Toml - integer round-trips correctly' { + $original = [ordered]@{ n = [long]12345 } + $toml = ConvertTo-Toml -InputObject $original + $rt = ConvertFrom-Toml -InputObject $toml + $rt.Data['n'] | Should -Be 12345 + } + } + + Context 'ConvertTo-Toml - float serialization' { + It 'ConvertTo-Toml - serializes float' { + $result = ConvertTo-Toml -InputObject ([ordered]@{ pi = 3.14 }) + $result | Should -Match 'pi = ' + $result | Should -Match '3.14' + } + + It 'ConvertTo-Toml - float round-trips correctly' { + $original = [ordered]@{ n = 2.718 } + $toml = ConvertTo-Toml -InputObject $original + $rt = ConvertFrom-Toml -InputObject $toml + ([double]$rt.Data['n'] - 2.718) | Should -BeLessThan 0.001 + } + } + + Context 'ConvertTo-Toml - boolean serialization' { + It 'ConvertTo-Toml - serializes true' { + $result = ConvertTo-Toml -InputObject ([ordered]@{ flag = $true }) + $result | Should -Match 'flag = true' + } + + It 'ConvertTo-Toml - serializes false' { + $result = ConvertTo-Toml -InputObject ([ordered]@{ flag = $false }) + $result | Should -Match 'flag = false' + } + } + + Context 'ConvertTo-Toml - datetime serialization' { + It 'ConvertTo-Toml - serializes DateTimeOffset as offset datetime' { + $dto = [System.DateTimeOffset]::new(1979, 5, 27, 7, 32, 0, [System.TimeSpan]::Zero) + $result = ConvertTo-Toml -InputObject ([ordered]@{ dt = $dto }) + $result | Should -Match '1979-05-27' + } + + It 'ConvertTo-Toml - DateTimeOffset round-trips' { + $dto = [System.DateTimeOffset]::new(2024, 3, 15, 12, 0, 0, [System.TimeSpan]::FromHours(2)) + $original = [ordered]@{ dt = $dto } + $toml = ConvertTo-Toml -InputObject $original + $rt = ConvertFrom-Toml -InputObject $toml + $rt.Data['dt'] | Should -BeOfType [System.DateTimeOffset] + ([System.DateTimeOffset]$rt.Data['dt']).Year | Should -Be 2024 + } + + It 'ConvertTo-Toml - serializes DateTime with time as local datetime' { + $dt = [System.DateTime]::new(2024, 6, 1, 15, 30, 0, [System.DateTimeKind]::Unspecified) + $result = ConvertTo-Toml -InputObject ([ordered]@{ dt = $dt }) + $result | Should -Match '2024-06-01' + } + + It 'ConvertTo-Toml - serializes date-only DateTime as local date' { + $dt = [System.DateTime]::new(2024, 6, 1, 0, 0, 0, [System.DateTimeKind]::Unspecified) + $result = ConvertTo-Toml -InputObject ([ordered]@{ dt = $dt }) + $result | Should -Match '2024-06-01' + } + + It 'ConvertTo-Toml - serializes TimeSpan as local time' { + $ts = [System.TimeSpan]::new(0, 8, 30, 0) + $result = ConvertTo-Toml -InputObject ([ordered]@{ tm = $ts }) + $result | Should -Match '08:30:00' + } + } + + Context 'ConvertTo-Toml - nested table serialization' { + It 'ConvertTo-Toml - serializes nested ordered dict as TOML table' { + $obj = [ordered]@{ + server = [ordered]@{ host = 'localhost'; port = [long]8080 } + } + $result = ConvertTo-Toml -InputObject $obj + $result | Should -Match '\[server\]' + $result | Should -Match 'host = "localhost"' + } + + It 'ConvertTo-Toml - nested table round-trips' { + $original = [ordered]@{ + database = [ordered]@{ host = 'db.example.com'; port = [long]5432 } + } + $toml = ConvertTo-Toml -InputObject $original + $rt = ConvertFrom-Toml -InputObject $toml + $rt.Data['database']['host'] | Should -Be 'db.example.com' + $rt.Data['database']['port'] | Should -Be 5432 + } + } + + Context 'ConvertTo-Toml - array serialization' { + It 'ConvertTo-Toml - serializes array of integers' { + $obj = [ordered]@{ ports = @([long]80, [long]443, [long]8080) } + $result = ConvertTo-Toml -InputObject $obj + $result | Should -Match '80' + $result | Should -Match '443' + } + + It 'ConvertTo-Toml - integer array round-trips' { + $original = [ordered]@{ nums = @([long]1, [long]2, [long]3) } + $toml = ConvertTo-Toml -InputObject $original + $rt = ConvertFrom-Toml -InputObject $toml + $rt.Data['nums'] | Should -HaveCount 3 + $rt.Data['nums'][0] | Should -Be 1 + } + } + + Context 'ConvertTo-Toml - PSCustomObject input' { + It 'ConvertTo-Toml - serializes PSCustomObject' { + $obj = [PSCustomObject]@{ name = 'Alice'; age = [long]30 } + $result = ConvertTo-Toml -InputObject $obj + $result | Should -Match 'name = "Alice"' + $result | Should -Match 'age = 30' + } + } + + Context 'ConvertTo-Toml - TomlDocument input' { + It 'ConvertTo-Toml - accepts TomlDocument from ConvertFrom-Toml' { + $toml = "title = `"Test`"`n[section]`nvalue = 42" + $doc = ConvertFrom-Toml -InputObject $toml + $result = ConvertTo-Toml -InputObject $doc + $result | Should -Match 'title = "Test"' + $result | Should -Match '\[section\]' + } + } + + Context 'ConvertTo-Toml - round-trip' { + It 'ConvertTo-Toml - full example round-trips losslessly for string/integer/boolean/float' { + $path = Join-Path $PSScriptRoot 'data\full-example.toml' + $original = ConvertFrom-Toml -InputObject (Get-Content $path -Raw) + $toml = ConvertTo-Toml -InputObject $original + $rt = ConvertFrom-Toml -InputObject $toml + $rt.Data['title'] | Should -Be 'TOML Example' + $rt.Data['owner']['name'] | Should -Be 'Tom Preston-Werner' + $rt.Data['database']['enabled'] | Should -Be $true + $rt.Data['database']['ports'] | Should -HaveCount 3 + $rt.Data['servers']['alpha']['ip'] | Should -Be '10.0.0.1' + } + + It 'ConvertTo-Toml - array of integers round-trips' { + $path = Join-Path $PSScriptRoot 'data\arrays.toml' + $original = ConvertFrom-Toml -InputObject (Get-Content $path -Raw) + $toml = ConvertTo-Toml -InputObject $original + $rt = ConvertFrom-Toml -InputObject $toml + $rt.Data['integers'] | Should -HaveCount 3 + $rt.Data['strings'] | Should -HaveCount 3 + } + } + + Context 'ConvertTo-Toml - error handling' { + It 'ConvertTo-Toml - throws on null input' { + { ConvertTo-Toml -InputObject $null } | Should -Throw + } + } + } +} diff --git a/tests/Export-Toml.Tests.ps1 b/tests/Export-Toml.Tests.ps1 new file mode 100644 index 0000000..9cd06aa --- /dev/null +++ b/tests/Export-Toml.Tests.ps1 @@ -0,0 +1,160 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +Describe 'Toml' { + BeforeAll { + . (Join-Path $PSScriptRoot 'bootstrap.ps1') + } + + Describe 'Export-Toml' { + + Context 'Export-Toml - basic write' { + It 'Export-Toml - creates a file at the given path' { + $path = Join-Path $TestDrive 'output.toml' + Export-Toml -InputObject ([ordered]@{ key = 'value' }) -Path $path + Test-Path -Path $path | Should -BeTrue + } + + It 'Export-Toml - file content is valid TOML' { + $path = Join-Path $TestDrive 'output.toml' + Export-Toml -InputObject ([ordered]@{ title = 'Hello' }) -Path $path + $content = Get-Content -Path $path -Raw + $content | Should -Match 'title = "Hello"' + } + + It 'Export-Toml - returns nothing to the output stream' { + $path = Join-Path $TestDrive 'void.toml' + $result = Export-Toml -InputObject ([ordered]@{ k = 'v' }) -Path $path + $result | Should -BeNullOrEmpty + } + + It 'Export-Toml - creates parent directory when it does not exist' { + $path = Join-Path $TestDrive 'nested\subdir\output.toml' + Export-Toml -InputObject ([ordered]@{ k = 'v' }) -Path $path + Test-Path -Path $path | Should -BeTrue + } + } + + Context 'Export-Toml - round-trip via Import-Toml' { + It 'Export-Toml - round-trip preserves string values' { + $inputDoc = [ordered]@{ greeting = 'Hello World' } + $path = Join-Path $TestDrive 'rt-string.toml' + Export-Toml -InputObject $inputDoc -Path $path + $result = Import-Toml -Path $path + $result.Data['greeting'] | Should -Be 'Hello World' + } + + It 'Export-Toml - round-trip preserves integer values' { + $inputDoc = [ordered]@{ count = [long]42 } + $path = Join-Path $TestDrive 'rt-int.toml' + Export-Toml -InputObject $inputDoc -Path $path + $result = Import-Toml -Path $path + $result.Data['count'] | Should -Be 42 + } + + It 'Export-Toml - round-trip preserves boolean values' { + $inputDoc = [ordered]@{ enabled = $true; disabled = $false } + $path = Join-Path $TestDrive 'rt-bool.toml' + Export-Toml -InputObject $inputDoc -Path $path + $result = Import-Toml -Path $path + $result.Data['enabled'] | Should -Be $true + $result.Data['disabled'] | Should -Be $false + } + + It 'Export-Toml - round-trip preserves float values' { + $inputDoc = [ordered]@{ pi = 3.14159 } + $path = Join-Path $TestDrive 'rt-float.toml' + Export-Toml -InputObject $inputDoc -Path $path + $result = Import-Toml -Path $path + ([double]$result.Data['pi'] - 3.14159) | Should -BeLessThan 0.00001 + } + + It 'Export-Toml - round-trip preserves nested tables' { + $inputDoc = [ordered]@{ + server = [ordered]@{ host = 'localhost'; port = [long]8080 } + } + $path = Join-Path $TestDrive 'rt-nested.toml' + Export-Toml -InputObject $inputDoc -Path $path + $result = Import-Toml -Path $path + $result.Data['server']['host'] | Should -Be 'localhost' + $result.Data['server']['port'] | Should -Be 8080 + } + + It 'Export-Toml - round-trip preserves integer arrays' { + $inputDoc = [ordered]@{ ports = @([long]80, [long]443) } + $path = Join-Path $TestDrive 'rt-array.toml' + Export-Toml -InputObject $inputDoc -Path $path + $result = Import-Toml -Path $path + $result.Data['ports'] | Should -HaveCount 2 + $result.Data['ports'][0] | Should -Be 80 + } + + It 'Export-Toml - full file round-trip' { + $srcPath = Join-Path $PSScriptRoot 'data\full-example.toml' + $original = Import-Toml -Path $srcPath + + $outPath = Join-Path $TestDrive 'rt-full.toml' + Export-Toml -InputObject $original -Path $outPath + + $rt = Import-Toml -Path $outPath + $rt.Data['title'] | Should -Be 'TOML Example' + $rt.Data['owner']['name'] | Should -Be 'Tom Preston-Werner' + $rt.Data['database']['enabled'] | Should -Be $true + $rt.Data['servers']['alpha']['ip'] | Should -Be '10.0.0.1' + } + } + + Context 'Export-Toml - UTF-8 encoding' { + It 'Export-Toml - writes UTF-8 without BOM' { + $path = Join-Path $TestDrive 'utf8.toml' + Export-Toml -InputObject ([ordered]@{ key = 'αβγ' }) -Path $path + $bytes = [System.IO.File]::ReadAllBytes($path) + # UTF-8 BOM is EF BB BF — should not be present + if ($bytes.Length -ge 3) { + ($bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) | Should -BeFalse + } + } + + It 'Export-Toml - round-trips Unicode string content correctly' { + $path = Join-Path $TestDrive 'unicode.toml' + Export-Toml -InputObject ([ordered]@{ msg = 'こんにちは' }) -Path $path + $result = Import-Toml -Path $path + $result.Data['msg'] | Should -Be 'こんにちは' + } + } + + Context 'Export-Toml - pipeline input' { + It 'Export-Toml - accepts TomlDocument from pipeline' { + $srcPath = Join-Path $PSScriptRoot 'data\booleans.toml' + $outPath = Join-Path $TestDrive 'from-pipeline.toml' + Import-Toml -Path $srcPath | Export-Toml -Path $outPath + Test-Path $outPath | Should -BeTrue + $rt = Import-Toml -Path $outPath + $rt.Data['enabled'] | Should -Be $true + } + } + + Context 'Export-Toml - WhatIf support' { + It 'Export-Toml - WhatIf does not create file' { + $path = Join-Path $TestDrive 'whatif.toml' + Export-Toml -InputObject ([ordered]@{ k = 'v' }) -Path $path -WhatIf + Test-Path -Path $path | Should -BeFalse + } + } + + Context 'Export-Toml - error handling' { + It 'Export-Toml - throws on null InputObject' { + $path = Join-Path $TestDrive 'null.toml' + { Export-Toml -InputObject $null -Path $path } | Should -Throw + } + } + } +} diff --git a/tests/Import-Toml.Tests.ps1 b/tests/Import-Toml.Tests.ps1 new file mode 100644 index 0000000..8668d83 --- /dev/null +++ b/tests/Import-Toml.Tests.ps1 @@ -0,0 +1,114 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +Describe 'Toml' { + Describe 'Import-Toml' { + BeforeAll { + . (Join-Path $PSScriptRoot 'bootstrap.ps1') + $testDataDir = Join-Path $PSScriptRoot 'data' + } + + Context 'Import-Toml - return type' { + It 'Import-Toml - returns a TomlDocument' { + $path = Join-Path $testDataDir 'full-example.toml' + $result = Import-Toml -Path $path + $result.GetType().Name | Should -Be 'TomlDocument' + } + + It 'Import-Toml - sets FilePath to resolved absolute path' { + $path = Join-Path $testDataDir 'full-example.toml' + $result = Import-Toml -Path $path + $result.FilePath | Should -Not -BeNullOrEmpty + [System.IO.Path]::IsPathRooted($result.FilePath) | Should -BeTrue + } + + It 'Import-Toml - Data property is an OrderedDictionary' { + $path = Join-Path $testDataDir 'full-example.toml' + $result = Import-Toml -Path $path + $result.Data | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + } + } + + Context 'Import-Toml - file content' { + It 'Import-Toml - reads all keys from full example file' { + $path = Join-Path $testDataDir 'full-example.toml' + $result = Import-Toml -Path $path + $result.Data['title'] | Should -Be 'TOML Example' + $result.Data['owner']['name'] | Should -Be 'Tom Preston-Werner' + $result.Data['database']['enabled'] | Should -Be $true + } + + It 'Import-Toml - reads integer file correctly' { + $path = Join-Path $testDataDir 'integers.toml' + $result = Import-Toml -Path $path + $result.Data['decimal'] | Should -Be 42 + $result.Data['negative'] | Should -Be -17 + $result.Data['hex'] | Should -Be 3735928559 # 0xDEADBEEF as unsigned long + } + + It 'Import-Toml - reads float file correctly' { + $path = Join-Path $testDataDir 'floats.toml' + $result = Import-Toml -Path $path + ([double]$result.Data['positive'] - 3.1415) | Should -BeLessThan 0.0001 + [double]::IsNaN($result.Data['special_nan']) | Should -BeTrue + } + + It 'Import-Toml - reads boolean file correctly' { + $path = Join-Path $testDataDir 'booleans.toml' + $result = Import-Toml -Path $path + $result.Data['enabled'] | Should -Be $true + $result.Data['disabled'] | Should -Be $false + } + + It 'Import-Toml - reads datetime file correctly' { + $path = Join-Path $testDataDir 'datetimes.toml' + $result = Import-Toml -Path $path + $result.Data['offset_dt_z'] | Should -BeOfType [System.DateTimeOffset] + $result.Data['local_date'] | Should -BeOfType [System.DateTime] + $result.Data['local_time'] | Should -BeOfType [System.TimeSpan] + } + + It 'Import-Toml - reads array file correctly' { + $path = Join-Path $testDataDir 'arrays.toml' + $result = Import-Toml -Path $path + $result.Data['integers'] | Should -HaveCount 3 + $result.Data['empty'] | Should -HaveCount 0 + } + + It 'Import-Toml - reads array-of-tables file correctly' { + $path = Join-Path $testDataDir 'array-of-tables.toml' + $result = Import-Toml -Path $path + $result.Data['products'] | Should -HaveCount 2 + $result.Data['products'][0]['name'] | Should -Be 'Hammer' + } + } + + Context 'Import-Toml - pipeline' { + It 'Import-Toml - accepts path from pipeline' { + $path = Join-Path $testDataDir 'booleans.toml' + $result = $path | Import-Toml + $result.GetType().Name | Should -Be 'TomlDocument' + } + } + + Context 'Import-Toml - error handling' { + It 'Import-Toml - throws when file does not exist' { + { Import-Toml -Path 'nonexistent-file-that-does-not-exist.toml' } | Should -Throw + } + + It 'Import-Toml - throws when file contains invalid TOML' { + $badToml = Join-Path $TestDrive 'bad.toml' + Set-Content -Path $badToml -Value 'invalid = = "bad"' + { Import-Toml -Path $badToml } | Should -Throw + } + } + } +} diff --git a/tests/Toml.Tests.ps1 b/tests/Toml.Tests.ps1 index dfdbc33..c77ae33 100644 --- a/tests/Toml.Tests.ps1 +++ b/tests/Toml.Tests.ps1 @@ -10,7 +10,15 @@ param() Describe 'Module' { - It 'Function: ConvertFrom-Toml - Throws NotImplementedException' { - { ConvertFrom-Toml -InputObject '[database]' } | Should -Throw + BeforeAll { + . (Join-Path $PSScriptRoot 'bootstrap.ps1') + } + + It 'Module has expected commands' { + $commands = Get-Command -Module Toml | Select-Object -ExpandProperty Name | Sort-Object + $commands | Should -Contain 'ConvertFrom-Toml' + $commands | Should -Contain 'ConvertTo-Toml' + $commands | Should -Contain 'Import-Toml' + $commands | Should -Contain 'Export-Toml' } } diff --git a/tests/bootstrap.ps1 b/tests/bootstrap.ps1 new file mode 100644 index 0000000..41f1bd8 --- /dev/null +++ b/tests/bootstrap.ps1 @@ -0,0 +1,17 @@ +<# + .SYNOPSIS + Bootstrap helper for local Pester runs. + + .DESCRIPTION + Imports the built Toml module from ./output/Toml/ before tests run. + Call from a BeforeAll block in every test file. +#> +[CmdletBinding()] +param() + +$outputManifest = Join-Path $PSScriptRoot '..' 'output' 'Toml' 'Toml.psd1' +if (-not (Test-Path $outputManifest)) { + throw "Module manifest not found at '$outputManifest'. Run build.ps1 first." +} + +Import-Module $outputManifest -Force -ErrorAction Stop diff --git a/tests/data/array-of-tables.toml b/tests/data/array-of-tables.toml new file mode 100644 index 0000000..5117a59 --- /dev/null +++ b/tests/data/array-of-tables.toml @@ -0,0 +1,21 @@ +[[products]] +name = "Hammer" +sku = 738594937 + +[[products]] +name = "Nail" +sku = 284758393 +color = "gray" + +[[fruits]] +name = "apple" + +[fruits.physical] +color = "red" +shape = "round" + +[[fruits.varieties]] +name = "red delicious" + +[[fruits.varieties]] +name = "granny smith" diff --git a/tests/data/arrays.toml b/tests/data/arrays.toml new file mode 100644 index 0000000..1ce7fd0 --- /dev/null +++ b/tests/data/arrays.toml @@ -0,0 +1,11 @@ +# Array types +integers = [1, 2, 3] +strings = ["a", "b", "c"] +mixed_types = [1, "two", 3.0, true] +nested = [[1, 2], [3, 4, 5]] +empty = [] +multiline = [ + 1, + 2, + 3, +] diff --git a/tests/data/booleans.toml b/tests/data/booleans.toml new file mode 100644 index 0000000..db0df2c --- /dev/null +++ b/tests/data/booleans.toml @@ -0,0 +1,3 @@ +# Boolean values +enabled = true +disabled = false diff --git a/tests/data/datetimes.toml b/tests/data/datetimes.toml new file mode 100644 index 0000000..5a53ae5 --- /dev/null +++ b/tests/data/datetimes.toml @@ -0,0 +1,6 @@ +# Date and time types +offset_dt_z = 1979-05-27T07:32:00Z +offset_dt_num = 1979-05-27T07:32:00+05:30 +local_dt = 1979-05-27T07:32:00 +local_date = 1979-05-27 +local_time = 07:32:00 diff --git a/tests/data/floats.toml b/tests/data/floats.toml new file mode 100644 index 0000000..3da6357 --- /dev/null +++ b/tests/data/floats.toml @@ -0,0 +1,9 @@ +# Float values +positive = 3.1415 +negative = -0.01 +exponent = 5e+22 +large = 1e6 +combo = 6.626e-34 +special_inf = inf +special_neg_inf = -inf +special_nan = nan diff --git a/tests/data/full-example.toml b/tests/data/full-example.toml new file mode 100644 index 0000000..00a6050 --- /dev/null +++ b/tests/data/full-example.toml @@ -0,0 +1,22 @@ +# Full TOML 1.0.0 example - valid document +title = "TOML Example" + +[owner] +name = "Tom Preston-Werner" +dob = 1979-05-27T07:32:00-08:00 + +[database] +enabled = true +ports = [ 8001, 8001, 8002 ] +data = [ ["delta", "phi"], [3.14] ] +temp_targets = { cpu = 79.5, case = 72.0 } + +[servers] + +[servers.alpha] +ip = "10.0.0.1" +role = "frontend" + +[servers.beta] +ip = "10.0.0.2" +role = "backend" diff --git a/tests/data/integers.toml b/tests/data/integers.toml new file mode 100644 index 0000000..9d9e862 --- /dev/null +++ b/tests/data/integers.toml @@ -0,0 +1,9 @@ +# All integer representations +decimal = 42 +negative = -17 +positive = +99 +zero = 0 +large = 9_007_199_254_740_992 +hex = 0xDEADBEEF +octal = 0o755 +binary = 0b11010110 diff --git a/tests/data/strings.toml b/tests/data/strings.toml new file mode 100644 index 0000000..ae05e4c --- /dev/null +++ b/tests/data/strings.toml @@ -0,0 +1,12 @@ +# Strings - all four TOML string types +basic = "I'm a string. \"You can quote me\". Name\tTab\nNewLine." +literal = 'C:\Users\nodejs\templates' +multiline_basic = """ +Roses are red +Violets are blue""" +multiline_literal = ''' +First paragraph. + +Second paragraph.''' +escaped_unicode = "\u03B1\u03B2\u03B3" +empty = "" diff --git a/tests/data/tables.toml b/tests/data/tables.toml new file mode 100644 index 0000000..c857d63 --- /dev/null +++ b/tests/data/tables.toml @@ -0,0 +1,9 @@ +# Table types +[simple] +key = "value" + +[dotted.key] +works = true + +[inline_parent] +inline = { one = 1, two = 2 }