AzureCAF.psm1

function Export-CafResourceType {
    <#
    .SYNOPSIS
        Exports the Cloud Adoption Framework naming rules for one or more resource types to a CSV or
        Markdown file.
    .DESCRIPTION
        Writes the ResourceType/Resource/Slug/Scope/length/casing fields returned by Get-CafResourceType to
        a file, as a reference table a team can review without running PowerShell - e.g. to print, link
        from a wiki page, or attach to a design doc. The format is chosen from -Path's extension: .csv
        or .md (.markdown also works).
 
        Accepts the same objects Get-CafResourceType returns on the pipeline, so you can export a filtered
        subset; with nothing piped in, it exports every resource type the module knows about.
    .PARAMETER Path
        Where to write the export. Must end in .csv, .md or .markdown.
    .PARAMETER InputObject
        Resource type definitions to export, as returned by Get-CafResourceType. Defaults to every
        resource type the module knows about (equivalent to piping in `Get-CafResourceType`) if nothing is
        supplied.
    .PARAMETER Force
        Overwrite -Path if it already exists.
    .EXAMPLE
        Export-CafResourceType -Path ./resource-types.md
 
        Writes a Markdown table of every resource type the module knows about.
    .EXAMPLE
        Get-CafResourceType -ResourceType *storage* | Export-CafResourceType -Path ./storage-types.csv
 
        Exports just the storage-related resource types to CSV.
    .LINK
        https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Path,

        [Parameter(ValueFromPipeline)]
        [object[]]
        $InputObject,

        [switch]
        $Force
    )

    begin {
        $Items = [System.Collections.Generic.List[object]]::new()
    }

    process {
        foreach ($Item in $InputObject) {
            $Items.Add($Item)
        }
    }

    end {
        $Extension = [System.IO.Path]::GetExtension($Path).ToLowerInvariant()

        if ($Extension -notin '.csv', '.md', '.markdown') {
            Write-Error "'$Path' must end in .csv, .md or .markdown."
            return
        }

        if ($Items.Count -eq 0) {
            $Items.AddRange(@(Get-CafResourceType))
        }

        if ((Test-Path -Path $Path) -and -not $Force) {
            Write-Error "'$Path' already exists. Specify -Force to overwrite it."
            return
        }

        if (-not $PSCmdlet.ShouldProcess($Path, "Export $($Items.Count) resource type(s)")) {
            return
        }

        $Properties = 'ResourceType', 'Resource', 'Slug', 'Scope', 'MinLength', 'MaxLength', 'Lowercase', 'Dashes'

        if ($Extension -eq '.csv') {
            $Items | Select-Object -Property $Properties | Export-Csv -Path $Path -NoTypeInformation
        }
        else {
            $Lines = [System.Collections.Generic.List[string]]::new()
            $Lines.Add("| $($Properties -join ' | ') |")
            $Lines.Add("| $(($Properties | ForEach-Object { '---' }) -join ' | ') |")

            foreach ($Item in $Items) {
                $Cells = foreach ($Property in $Properties) {
                    "$($Item.$Property)".Replace('|', '\|')
                }
                $Lines.Add("| $($Cells -join ' | ') |")
            }

            Set-Content -Path $Path -Value $Lines
        }

        Get-Item -Path $Path
    }
}
function Get-CafRegion {
    <#
    .SYNOPSIS
        Looks up the abbreviation for one or more Azure regions.
    .DESCRIPTION
        Searches the module's bundled regionDefinition.json for Azure regions and returns the short
        abbreviation Get-CafResourceName's -Region parameter resolves it to (e.g. 'eastus' -> 'eus'),
        along with its Azure-paired region (e.g. 'eastus' -> 'westus'), if it has one.
 
        This is a local, offline lookup against a static data set bundled with the module - it's not a
        replacement for Get-AzLocation (from the Az.Resources module), which calls the live Azure
        Resource Manager API to report which regions are available to a subscription right now.
 
        Region abbreviations aren't officially published by Microsoft for every region - the bundled
        list is sourced from https://www.jlaundry.nz/2022/azure_region_abbreviations/, a community
        reference, and may not cover newly-announced regions. PairedRegion is sourced from Microsoft's
        official https://learn.microsoft.com/en-us/azure/reliability/regions-list and is $null for a
        region that doesn't have one - newer regions increasingly don't, having moved to availability
        zones instead of a paired region for resiliency. PairedRegion is informational only;
        Get-CafResourceName's -Region doesn't resolve against it.
    .PARAMETER Region
        The ARM region name to search for, e.g. 'eastus', its display name, e.g. 'East US', or its CAF
        slug, e.g. 'eus'. The ARM name and display name match as a substring by default, same as
        -ResourceType on Get-CafResourceType, so 'east' alone matches multiple regions - the slug only
        matches exactly, since it's short enough that partial matching on it would mostly just be
        ambiguous. If omitted, every known region is returned.
    .EXAMPLE
        Get-CafRegion -Region eastus
 
        Returns the 'eus' abbreviation for East US, along with its paired region, 'westus'.
    .EXAMPLE
        Get-CafRegion -Region 'East US'
 
        Looks up the same region by its display name instead of its ARM name.
    .EXAMPLE
        Get-CafRegion -Region eus
 
        Looks up the same region by its slug instead.
    .EXAMPLE
        Get-CafRegion
 
        Lists every region known to the module.
    .LINK
        https://www.jlaundry.nz/2022/azure_region_abbreviations/
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]
        $Region
    )

    $AllDefinitions = @(Get-CafRegionDefinition)
    $definitions = $AllDefinitions

    if ($Region) {
        $definitions = @($AllDefinitions | Where-Object Slug -EQ $Region)

        if ($definitions.Count -eq 0) {
            $definitions = Select-CafMatch -InputObject $AllDefinitions -Property Region -Value $Region
        }

        if ($definitions.Count -eq 0) {
            $definitions = Select-CafMatch -InputObject $AllDefinitions -Property DisplayName -Value $Region
        }
    }

    $definitions | Select-Object Region, DisplayName, Slug, PairedRegion
}
function Get-CafResourceName {
    <#
    .SYNOPSIS
        Generates an Azure resource name that follows the Cloud Adoption Framework naming convention,
        returning just the name as a string.
    .DESCRIPTION
        A thin wrapper around New-CafResourceName that returns just the generated Name string, for
        callers who don't need the ResourceType/Resource/Slug/InstanceNumber/RandomSuffix that produced
        it. Every parameter here behaves exactly as it does on New-CafResourceName - see its help for
        the full description of each one, the length-budget/trimming/cleaning behaviour, and
        -ConventionPath support.
 
        Use New-CafResourceName directly instead if you need to tell generated names apart when
        batch-generating across different resource types and/or -Count, or to export the result (e.g.
        to CSV) alongside the values that produced it.
    .PARAMETER ResourceType
        The ARM resource type to generate a name for, e.g. 'Microsoft.Compute/virtualMachines'. Matches
        as a substring by default, so 'virtualMachines' also works - as long as it narrows down to a
        single resource type, or you also specify -Resource. Tab completion lists the resource types
        known to the module. Normally used on its own (or -Alias, or -Resource, used instead); takes
        precedence if more than one happens to be specified, e.g. by piping in an object that has
        ResourceType, Alias and Resource properties all describing the same entry.
    .PARAMETER Alias
        The Cloud Adoption Framework abbreviation to generate a name for, e.g. 'vmss', if you already
        know it, used as a short alias for the ARM resource type. Matches as a substring by default,
        same as -ResourceType, and also accepts -Slug as an alias (so piping Get-CafResourceType's output
        straight in binds to this by its Slug property). Normally used on its own, instead of
        -ResourceType.
    .PARAMETER Resource
        The friendly name of the resource to generate, e.g. 'Key vault' - every such name in the
        module's data is unique, so on its own this works as a third alternative to -ResourceType/
        -Alias (also matching as a substring, so 'vault' alone works too). It also doubles as a
        disambiguator when combined with -ResourceType/-Alias, for the handful of ARM types used by
        more than one kind of resource (for example 'Microsoft.Web/sites' covers both a "Web app" and a
        "Function app", with different recommended abbreviations) - required in that case, since
        -ResourceType/-Alias alone would otherwise be ambiguous.
    .PARAMETER Prefix
        One or more strings to prepend to the resource name, in order, e.g. a company name, environment,
        or region. Falls back to -ConventionPath's value if not specified.
    .PARAMETER Suffix
        One or more strings to append to the resource name, in order. Falls back to -ConventionPath's
        value if not specified.
    .PARAMETER Region
        An Azure region to append to the resource name, as its ARM name (e.g. 'eastus'), display name
        (e.g. 'East US') or its CAF slug (e.g. 'eus') - resolved to the CAF-recommended abbreviation the
        same way -ResourceType resolves to -Slug, and inserted after the resource type's slug but before
        any -Suffix, so a -Suffix you specify is always the last segment (aside from an instance number).
        Already passing the slug in is a no-op that just confirms it's a recognized one. Falls back to
        -ConventionPath's value if not specified. Use Get-CafRegion to look up the abbreviation a given
        region resolves to, or to see every region the module knows about. Region abbreviations aren't
        officially published by Microsoft for every region, so an unrecognized value errors rather than
        guessing - pass the code yourself via -Suffix instead if that happens.
    .PARAMETER Separator
        The character(s) used to join the prefix, abbreviation and suffix segments together. Ignored for
        resource types that don't permit a separator character, in which case the segments are
        concatenated directly. Defaults to '-', or to -ConventionPath's value if it specifies one.
    .PARAMETER InstanceNumber
        An instance number to append to the resource name, for differentiating otherwise-identical
        resources (e.g. 'vm-sql-test-001', 'vm-sql-test-002' in the Cloud Adoption Framework's own naming
        examples). Zero-padded to -InstanceNumberPadding digits and reserved out of the resource type's
        maximum length before the rest of the name is trimmed, so it's never cut short.
    .PARAMETER InstanceNumberPadding
        The minimum number of digits to zero-pad -InstanceNumber to, e.g. the default of 3 formats '1'
        as '001'. A number that's already at least this many digits long is left as-is. Ignored unless
        -InstanceNumber is specified. Falls back to -ConventionPath's value if not specified.
    .PARAMETER Count
        Generates this many names in one call, with sequential instance numbers, instead of just one.
        Starts from -InstanceNumber if specified (e.g. -InstanceNumber 5 -Count 3 produces 005, 006, 007),
        or from 1 otherwise. Implies an instance number even if -InstanceNumber isn't specified, since
        otherwise every generated name would be identical. Each generated name gets its own random
        suffix if -RandomSuffixLength is also specified.
    .PARAMETER RandomSuffixLength
        Appends this many random lowercase alphanumeric characters to the end of the name, for
        scenarios that require a globally unique name (such as a storage account or key vault). Always
        lowercase, even for a resource type that otherwise allows uppercase - the suffix exists for
        uniqueness, not communication, and doesn't need the extra entropy. The characters are reserved
        out of the resource type's maximum length before the rest of the name is trimmed, so the random
        suffix is never cut short. Falls back to -ConventionPath's value if not specified.
    .PARAMETER RandomSuffixSeed
        Seeds the random number generator used by -RandomSuffixLength, so the same seed (with the same
        other parameters) always produces the same random characters - useful if you need to generate
        the same name idempotently, e.g. across repeated deployments. Ignored unless -RandomSuffixLength
        is specified.
    .PARAMETER ConventionPath
        Path to a JSON file of organizational naming-convention defaults for -Prefix, -Suffix, -Region,
        -Separator, -InstanceNumberPadding and -RandomSuffixLength, created with
        New-CafNamingConvention. Any of those you also specify explicitly (including via the
        pipeline) take precedence over the file - this just saves retyping the same org-standard values
        on every call.
    .PARAMETER NoClean
        By default, characters that aren't valid for the given resource type are stripped from -Prefix
        and -Suffix before the name is built. Specify -NoClean to pass them through unmodified instead
        (the generated name will then fail validation if it contains invalid characters).
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -Prefix contoso, prod -Suffix 01
 
        Generates a virtual machine name such as 'contoso-prod-vm-01'.
    .EXAMPLE
        Get-CafResourceName -ResourceType KeyVault -Prefix contoso
 
        -ResourceType matches as a substring, so this is equivalent to specifying the full
        'Microsoft.KeyVault/vaults' ARM type. If a substring matches more than one resource type,
        narrow it down with a longer -ResourceType value and/or -Resource.
    .EXAMPLE
        Get-CafResourceName -Alias vmss -Prefix contoso
 
        If you already know the Cloud Adoption Framework abbreviation, -Alias (or its -Slug alias) is a
        shorter alternative to -ResourceType.
    .EXAMPLE
        Get-CafResourceName -Resource 'Key vault' -Prefix contoso
 
        -Resource alone works too, since every friendly name is unique - a third alternative to
        -ResourceType/-Alias for when you only remember the resource's plain-English name.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -Prefix contoso, prod -Region eastus
 
        Generates 'contoso-prod-vm-eus' - -Region resolves 'eastus' to its CAF-recommended abbreviation
        the same way -ResourceType resolves to its slug, and inserts it before any -Suffix, so -Suffix
        stays the last segment.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -Prefix contoso, prod -InstanceNumber 1
 
        Generates 'contoso-prod-vm-001' - -InstanceNumber is zero-padded to 3 digits by default, matching
        the Cloud Adoption Framework's own example names such as 'vm-sql-test-001'.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -Prefix contoso, prod -Count 3
 
        Generates 'contoso-prod-vm-001', 'contoso-prod-vm-002' and 'contoso-prod-vm-003' in one call.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Storage/storageAccounts -Prefix contoso -RandomSuffixLength 6
 
        Generates a storage account name such as 'contosostabcdef', with 6 random characters appended to
        help ensure the globally-unique name doesn't collide with one that already exists.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Storage/storageAccounts -Prefix contoso -RandomSuffixLength 6 -RandomSuffixSeed 42
 
        Generates the same storage account name every time it's run, since the random suffix is seeded.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Web/sites -Resource 'Function app' -Prefix contoso, prod
 
        Generates a function app name. -Resource is required here because 'Microsoft.Web/sites' is also
        used for web apps, which use a different abbreviation.
    .EXAMPLE
        Get-CafResourceType -Slug kv | Get-CafResourceName -Prefix contoso -RandomSuffixLength 4
 
        Pipes the resource type definition for the 'kv' (Key Vault) abbreviation straight into
        Get-CafResourceName.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -Prefix contoso -ConventionPath ./contoso.json
 
        Uses -Prefix explicitly, but falls back to whatever -Suffix/-Region/-Separator/etc. are saved in
        contoso.json (see New-CafNamingConvention) for anything not specified here.
    .EXAMPLE
        Import-Csv ./resources.csv | Get-CafResourceName
 
        Batch-generates a name for every row of resources.csv, which needs at least a ResourceType or
        Alias column, and may also have Resource/Prefix/Suffix/Separator/InstanceNumber columns.
    .LINK
        https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]
        $ResourceType,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('Slug')]
        [ValidateNotNullOrEmpty()]
        [string]
        $Alias,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Resource,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string[]]
        $Prefix,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string[]]
        $Suffix,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Region,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Separator = '-',

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(0, [int]::MaxValue)]
        [int]
        $InstanceNumber,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(1, 10)]
        [int]
        $InstanceNumberPadding = 3,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(1, 1000)]
        [int]
        $Count = 1,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(0, 24)]
        [int]
        $RandomSuffixLength = 0,

        [Parameter(ValueFromPipelineByPropertyName)]
        [int]
        $RandomSuffixSeed,

        [string]
        $ConventionPath,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]
        $NoClean
    )

    begin {
        # Every parameter above exists only to be bound and forwarded to New-CafResourceName via the
        # proxy-command plumbing below (for CmdletBinding/tab-completion/help purposes), never read
        # directly here - this satisfies PSScriptAnalyzer's PSReviewUnusedParameter rule.
        $null = $ResourceType, $Alias, $Resource, $Prefix, $Suffix, $Region, $Separator, $InstanceNumber,
        $InstanceNumberPadding, $Count, $RandomSuffixLength, $RandomSuffixSeed, $ConventionPath, $NoClean

        # A hand-rolled `New-CafResourceName @PSBoundParameters` here would be unsafe: $PSBoundParameters
        # keeps stale values from a previous pipeline object once a later object lacks that property (a
        # known PowerShell quirk), which would leak e.g. one row's -Suffix into the next row's call. The
        # proxy-command pattern below instead feeds each pipeline object into New-CafResourceName's own
        # pipeline binding, which rebinds correctly per object.
        $WrappedCommand = $ExecutionContext.InvokeCommand.GetCommand('New-CafResourceName', [System.Management.Automation.CommandTypes]::Function)
        $ScriptCmd = { & $WrappedCommand @PSBoundParameters | ForEach-Object Name }
        $SteppablePipeline = $ScriptCmd.GetSteppablePipeline($MyInvocation.CommandOrigin)
        $SteppablePipeline.Begin($PSCmdlet)
    }

    process {
        $SteppablePipeline.Process($_)
    }

    end {
        $SteppablePipeline.End()
    }
}
function Get-CafResourceType {
    <#
    .SYNOPSIS
        Looks up the Cloud Adoption Framework naming rules for one or more Azure resource types.
    .DESCRIPTION
        Searches the module's bundled resourceDefinition.json for Azure resource types and returns their
        recommended CAF abbreviation along with the naming scope, length limits and casing rules that
        Get-CafResourceName uses to build a compliant name.
 
        This is a local, offline lookup against a static data set bundled with the module. It's not a
        replacement for Get-AzResourceProvider (from the Az.Resources module): that cmdlet calls the live
        Azure Resource Manager API to report which resource providers/types are registered and available
        in a subscription, and requires being signed in to Azure. Get-CafResourceType has no dependency on
        Az.Resources or an Azure session - it only answers "what's the recommended naming convention for
        this resource type", not "is this resource type available to me right now".
    .PARAMETER ResourceType
        The ARM resource type to search for, e.g. 'Microsoft.Compute/virtualMachines'. Matches as a
        substring by default, e.g. 'virtualMachines' or 'compute' both match it too - pass an explicit
        wildcard pattern (e.g. 'Microsoft.Compute/*') for tighter control, such as anchoring to the
        start. If omitted, all resource types are returned (optionally filtered by -Resource/-Alias).
    .PARAMETER Resource
        The friendly resource name to search for, e.g. 'Virtual machine'. Matches as a substring by
        default, same as -ResourceType. Useful to disambiguate ARM resource types that cover more than
        one kind of resource (for example 'Microsoft.Compute/disks' covers both a "Managed disk (OS)"
        and a "Managed disk (data)").
    .PARAMETER Alias
        The Cloud Adoption Framework abbreviation to search for, e.g. 'vm', used as a short alias for
        the ARM resource type. Matches as a substring by default, same as -ResourceType/-Resource, and
        also accepts -Slug as an alias.
    .EXAMPLE
        Get-CafResourceType -ResourceType Microsoft.Compute/virtualMachines
 
        Returns the naming rule for an Azure virtual machine.
    .EXAMPLE
        Get-CafResourceType -ResourceType storageAccount
 
        Finds resource types whose ARM type contains 'storageAccount'.
    .EXAMPLE
        Get-CafResourceType -Alias vm
 
        Finds the resource type(s) that use the 'vm' abbreviation.
    .EXAMPLE
        Get-CafResourceType
 
        Lists every resource type known to the module.
    .LINK
        https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]
        $ResourceType,

        [string]
        $Resource,

        [Alias('Slug')]
        [string]
        $Alias
    )

    $definitions = @(Get-CafResourceDefinition)

    if ($ResourceType) {
        $definitions = Select-CafMatch -InputObject $definitions -Property ResourceType -Value $ResourceType
    }

    if ($Resource) {
        $definitions = Select-CafMatch -InputObject $definitions -Property Resource -Value $Resource
    }

    if ($Alias) {
        $definitions = Select-CafMatch -InputObject $definitions -Property Slug -Value $Alias
    }

    $definitions | Select-Object ResourceType, Resource, Slug, Scope, MinLength, MaxLength, Lowercase, Dashes
}
function New-CafNamingConvention {
    <#
    .SYNOPSIS
        Saves a reusable set of Get-CafResourceName defaults (prefix, suffix, region, separator, etc.)
        to a JSON file, so your organization's naming convention doesn't need to be retyped on every
        call.
    .DESCRIPTION
        Writes a small JSON file that Get-CafResourceName's -ConventionPath reads defaults from for
        -Prefix, -Suffix, -Region, -Separator, -InstanceNumberPadding and -RandomSuffixLength. Anything
        you pass explicitly to Get-CafResourceName still takes precedence over the file - this is just a
        way to avoid repeating the same organization-standard values (company name, environment, region,
        your preferred separator/padding) on every call.
 
        -RandomSuffixSeed deliberately isn't one of these defaults, even though Get-CafResourceName has
        it - a convention file is meant to be the same shared values reused verbatim by everyone who
        points -ConventionPath at it, but a seed baked in that way would make the "random" suffix
        identical for every one of those calls, defeating the uniqueness -RandomSuffixLength exists for.
        It only makes sense as a per-call value (e.g. for one idempotent redeploy), so it stays something
        you pass to Get-CafResourceName directly.
    .PARAMETER Path
        Where to save the convention file, e.g. './contoso.json'.
    .PARAMETER Prefix
        One or more strings to use as the default -Prefix, e.g. a company name or environment - see the
        naming components guide this links to for what's commonly included.
    .PARAMETER Suffix
        One or more strings to use as the default -Suffix.
    .PARAMETER Region
        An Azure region (ARM name, display name or slug) to use as the default -Region - resolved and
        stored as its ARM region name, the same way Get-CafResourceName resolves it, so an unrecognized
        value is caught here rather than on every later call that uses the file.
    .PARAMETER Separator
        The default -Separator. Omit to leave this to Get-CafResourceName's own default ('-').
    .PARAMETER InstanceNumberPadding
        The default -InstanceNumberPadding. Omit to leave this to Get-CafResourceName's own default (3).
    .PARAMETER RandomSuffixLength
        The default -RandomSuffixLength. Omit to leave this to Get-CafResourceName's own default (0, i.e.
        no random suffix unless a call asks for one explicitly).
    .PARAMETER Force
        Overwrite -Path if it already exists.
    .EXAMPLE
        New-CafNamingConvention -Path ./contoso.json -Prefix contoso, prod -Region eastus -Separator '-' -InstanceNumberPadding 2
 
        Saves a convention so that Get-CafResourceName -ConventionPath ./contoso.json applies that
        prefix, region, separator and padding by default.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -ConventionPath ./contoso.json -InstanceNumber 1
 
        Uses the saved convention, overriding only -InstanceNumber for this particular call.
    .LINK
        https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-naming#choose-naming-components
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]
        $Path,

        [string[]]
        $Prefix,

        [string[]]
        $Suffix,

        [string]
        $Region,

        [string]
        $Separator,

        [ValidateRange(1, 10)]
        [int]
        $InstanceNumberPadding,

        [ValidateRange(0, 24)]
        [int]
        $RandomSuffixLength,

        [switch]
        $Force
    )

    if ((Test-Path -Path $Path) -and -not $Force) {
        Write-Error "'$Path' already exists. Specify -Force to overwrite it."
        return
    }

    $Convention = [ordered]@{}

    if ($PSBoundParameters.ContainsKey('Prefix')) { $Convention.Prefix = $Prefix }
    if ($PSBoundParameters.ContainsKey('Suffix')) { $Convention.Suffix = $Suffix }

    if ($PSBoundParameters.ContainsKey('Region')) {
        $RegionDefinition = Resolve-CafRegion -Region $Region

        if (-not $RegionDefinition) {
            return
        }

        $Convention.Region = $RegionDefinition.Region
    }

    if ($PSBoundParameters.ContainsKey('Separator')) { $Convention.Separator = $Separator }
    if ($PSBoundParameters.ContainsKey('InstanceNumberPadding')) { $Convention.InstanceNumberPadding = $InstanceNumberPadding }
    if ($PSBoundParameters.ContainsKey('RandomSuffixLength')) { $Convention.RandomSuffixLength = $RandomSuffixLength }

    if ($Convention.Count -eq 0) {
        Write-Error 'Specify at least one of -Prefix, -Suffix, -Region, -Separator, -InstanceNumberPadding or -RandomSuffixLength.'
        return
    }

    if ($PSCmdlet.ShouldProcess($Path, 'Create naming convention file')) {
        $Convention | ConvertTo-Json | Set-Content -Path $Path
        Get-Item -Path $Path
    }
}
function New-CafResourceName {
    <#
    .SYNOPSIS
        Generates an Azure resource name that follows the Cloud Adoption Framework naming convention,
        returning the full result as an object.
    .DESCRIPTION
        Builds a resource name from an optional prefix, the Cloud Adoption Framework recommended
        abbreviation for the given Azure resource type, and an optional suffix. The result is checked
        against the resource type's length limit and allowed characters, and is trimmed and/or converted
        to lowercase automatically if the resource type requires it.
 
        Returns an object with Name, ResourceType, Resource, Slug, InstanceNumber and RandomSuffix
        properties - use Get-CafResourceName instead if you just want the name as a plain string.
 
        Accepts pipeline input by property name, so a list of resource specs (e.g. imported from a CSV
        or a PSD1 describing a whole deployment) can be piped straight through to generate every name
        for a project in one batch - see the pipeline example below.
 
        Use Get-CafResourceType to discover the ResourceType/Resource values this cmdlet accepts. Use
        -Verbose to see the length-budget reasoning (reserved segments, trimming decisions) behind a
        given result.
    .PARAMETER ResourceType
        The ARM resource type to generate a name for, e.g. 'Microsoft.Compute/virtualMachines'. Matches
        as a substring by default, so 'virtualMachines' also works - as long as it narrows down to a
        single resource type, or you also specify -Resource. Tab completion lists the resource types
        known to the module. Normally used on its own (or -Alias, or -Resource, used instead); takes
        precedence if more than one happens to be specified, e.g. by piping in an object that has
        ResourceType, Alias and Resource properties all describing the same entry.
    .PARAMETER Alias
        The Cloud Adoption Framework abbreviation to generate a name for, e.g. 'vmss', if you already
        know it, used as a short alias for the ARM resource type. Matches as a substring by default,
        same as -ResourceType, and also accepts -Slug as an alias (so piping Get-CafResourceType's output
        straight in binds to this by its Slug property). Normally used on its own, instead of
        -ResourceType.
    .PARAMETER Resource
        The friendly name of the resource to generate, e.g. 'Key vault' - every such name in the
        module's data is unique, so on its own this works as a third alternative to -ResourceType/
        -Alias (also matching as a substring, so 'vault' alone works too). It also doubles as a
        disambiguator when combined with -ResourceType/-Alias, for the handful of ARM types used by
        more than one kind of resource (for example 'Microsoft.Web/sites' covers both a "Web app" and a
        "Function app", with different recommended abbreviations) - required in that case, since
        -ResourceType/-Alias alone would otherwise be ambiguous.
    .PARAMETER Prefix
        One or more strings to prepend to the resource name, in order, e.g. a company name, environment,
        or region. Falls back to -ConventionPath's value if not specified.
    .PARAMETER Suffix
        One or more strings to append to the resource name, in order. Falls back to -ConventionPath's
        value if not specified.
    .PARAMETER Region
        An Azure region to append to the resource name, as its ARM name (e.g. 'eastus'), display name
        (e.g. 'East US') or its CAF slug (e.g. 'eus') - resolved to the CAF-recommended abbreviation the
        same way -ResourceType resolves to -Slug, and inserted after the resource type's slug but before
        any -Suffix, so a -Suffix you specify is always the last segment (aside from an instance number).
        Already passing the slug in is a no-op that just confirms it's a recognized one. Falls back to
        -ConventionPath's value if not specified. Use Get-CafRegion to look up the abbreviation a given
        region resolves to, or to see every region the module knows about. Region abbreviations aren't
        officially published by Microsoft for every region, so an unrecognized value errors rather than
        guessing - pass the code yourself via -Suffix instead if that happens.
    .PARAMETER Separator
        The character(s) used to join the prefix, abbreviation and suffix segments together. Ignored for
        resource types that don't permit a separator character, in which case the segments are
        concatenated directly. Defaults to '-', or to -ConventionPath's value if it specifies one.
    .PARAMETER InstanceNumber
        An instance number to append to the resource name, for differentiating otherwise-identical
        resources (e.g. 'vm-sql-test-001', 'vm-sql-test-002' in the Cloud Adoption Framework's own naming
        examples). Zero-padded to -InstanceNumberPadding digits and reserved out of the resource type's
        maximum length before the rest of the name is trimmed, so it's never cut short.
    .PARAMETER InstanceNumberPadding
        The minimum number of digits to zero-pad -InstanceNumber to, e.g. the default of 3 formats '1'
        as '001'. A number that's already at least this many digits long is left as-is. Ignored unless
        -InstanceNumber is specified. Falls back to -ConventionPath's value if not specified.
    .PARAMETER Count
        Generates this many names in one call, with sequential instance numbers, instead of just one.
        Starts from -InstanceNumber if specified (e.g. -InstanceNumber 5 -Count 3 produces 005, 006, 007),
        or from 1 otherwise. Implies an instance number even if -InstanceNumber isn't specified, since
        otherwise every generated name would be identical. Each generated name gets its own random
        suffix if -RandomSuffixLength is also specified.
    .PARAMETER RandomSuffixLength
        Appends this many random lowercase alphanumeric characters to the end of the name, for
        scenarios that require a globally unique name (such as a storage account or key vault). Always
        lowercase, even for a resource type that otherwise allows uppercase - the suffix exists for
        uniqueness, not communication, and doesn't need the extra entropy. The characters are reserved
        out of the resource type's maximum length before the rest of the name is trimmed, so the random
        suffix is never cut short. Falls back to -ConventionPath's value if not specified.
    .PARAMETER RandomSuffixSeed
        Seeds the random number generator used by -RandomSuffixLength, so the same seed (with the same
        other parameters) always produces the same random characters - useful if you need to generate
        the same name idempotently, e.g. across repeated deployments. Ignored unless -RandomSuffixLength
        is specified.
    .PARAMETER ConventionPath
        Path to a JSON file of organizational naming-convention defaults for -Prefix, -Suffix, -Region,
        -Separator, -InstanceNumberPadding and -RandomSuffixLength, created with
        New-CafNamingConvention. Any of those you also specify explicitly (including via the
        pipeline) take precedence over the file - this just saves retyping the same org-standard values
        on every call.
    .PARAMETER NoClean
        By default, characters that aren't valid for the given resource type are stripped from -Prefix
        and -Suffix before the name is built. Specify -NoClean to pass them through unmodified instead
        (the generated name will then fail validation if it contains invalid characters).
    .EXAMPLE
        New-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -Prefix contoso, prod -Suffix 01
 
        Generates a virtual machine name such as 'contoso-prod-vm-01', returned as an object with Name,
        ResourceType, Resource, Slug, InstanceNumber and RandomSuffix properties.
    .EXAMPLE
        New-CafResourceName -ResourceType KeyVault -Prefix contoso
 
        -ResourceType matches as a substring, so this is equivalent to specifying the full
        'Microsoft.KeyVault/vaults' ARM type. If a substring matches more than one resource type,
        narrow it down with a longer -ResourceType value and/or -Resource.
    .EXAMPLE
        New-CafResourceName -Alias vmss -Prefix contoso
 
        If you already know the Cloud Adoption Framework abbreviation, -Alias (or its -Slug alias) is a
        shorter alternative to -ResourceType.
    .EXAMPLE
        New-CafResourceName -Resource 'Key vault' -Prefix contoso
 
        -Resource alone works too, since every friendly name is unique - a third alternative to
        -ResourceType/-Alias for when you only remember the resource's plain-English name.
    .EXAMPLE
        New-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -Prefix contoso, prod -Region eastus
 
        Generates 'contoso-prod-vm-eus' - -Region resolves 'eastus' to its CAF-recommended abbreviation
        the same way -ResourceType resolves to its slug, and inserts it before any -Suffix, so -Suffix
        stays the last segment.
    .EXAMPLE
        New-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -Prefix contoso, prod -InstanceNumber 1
 
        Generates 'contoso-prod-vm-001' - -InstanceNumber is zero-padded to 3 digits by default, matching
        the Cloud Adoption Framework's own example names such as 'vm-sql-test-001'.
    .EXAMPLE
        New-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -Prefix contoso, prod -Count 3
 
        Generates 'contoso-prod-vm-001', 'contoso-prod-vm-002' and 'contoso-prod-vm-003' in one call.
    .EXAMPLE
        New-CafResourceName -ResourceType Microsoft.Storage/storageAccounts -Prefix contoso -RandomSuffixLength 6
 
        Generates a storage account name such as 'contosostabcdef', with 6 random characters appended to
        help ensure the globally-unique name doesn't collide with one that already exists.
    .EXAMPLE
        New-CafResourceName -ResourceType Microsoft.Storage/storageAccounts -Prefix contoso -RandomSuffixLength 6 -RandomSuffixSeed 42
 
        Generates the same storage account name every time it's run, since the random suffix is seeded.
    .EXAMPLE
        New-CafResourceName -ResourceType Microsoft.Web/sites -Resource 'Function app' -Prefix contoso, prod
 
        Generates a function app name. -Resource is required here because 'Microsoft.Web/sites' is also
        used for web apps, which use a different abbreviation.
    .EXAMPLE
        Get-CafResourceType -Slug kv | New-CafResourceName -Prefix contoso -RandomSuffixLength 4
 
        Pipes the resource type definition for the 'kv' (Key Vault) abbreviation straight into
        New-CafResourceName.
    .EXAMPLE
        New-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -Prefix contoso -ConventionPath ./contoso.json
 
        Uses -Prefix explicitly, but falls back to whatever -Suffix/-Region/-Separator/etc. are saved in
        contoso.json (see New-CafNamingConvention) for anything not specified here.
    .EXAMPLE
        Import-Csv ./resources.csv | New-CafResourceName | Export-Csv ./generated-names.csv
 
        Batch-generates a name for every row of resources.csv, which needs at least a ResourceType or
        Alias column, and may also have Resource/Prefix/Suffix/Separator/InstanceNumber columns. The
        ResourceType/Resource/Slug/InstanceNumber/RandomSuffix that produced each name is kept alongside
        it, rather than returning bare strings that lose track of which input row (or which -Count
        iteration) each one came from.
    .LINK
        https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Pure in-memory name generator - the New verb reflects that it returns a new result object (as opposed to Get-CafResourceName''s plain string), not that it changes any external state.')]
    [CmdletBinding()]
    param(
        [Parameter(Position = 0, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]
        $ResourceType,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('Slug')]
        [ValidateNotNullOrEmpty()]
        [string]
        $Alias,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Resource,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string[]]
        $Prefix,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string[]]
        $Suffix,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Region,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Separator = '-',

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(0, [int]::MaxValue)]
        [int]
        $InstanceNumber,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(1, 10)]
        [int]
        $InstanceNumberPadding = 3,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(1, 1000)]
        [int]
        $Count = 1,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(0, 24)]
        [int]
        $RandomSuffixLength = 0,

        [Parameter(ValueFromPipelineByPropertyName)]
        [int]
        $RandomSuffixSeed,

        [string]
        $ConventionPath,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]
        $NoClean
    )

    begin {
        function Get-CleanString {
            param (
                [string[]]$InputObject,
                [string]$Pattern
            )

            foreach ($Str in $InputObject) {
                $Str -replace $Pattern, ''
            }
        }

        function Get-TrimString {
            param (
                [string]$InputObject,
                [int]$MaxLength
            )

            if ($InputObject.Length -gt $MaxLength) {
                $InputObject.Substring(0, $MaxLength)
            }
            else {
                $InputObject
            }
        }

        $Convention = $null
        if ($ConventionPath) {
            if (-not (Test-Path -Path $ConventionPath -PathType Leaf)) {
                $PSCmdlet.ThrowTerminatingError(
                    [System.Management.Automation.ErrorRecord]::new(
                        [System.IO.FileNotFoundException]::new("Naming convention file not found: '$ConventionPath'."),
                        'ConventionFileNotFound',
                        [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                        $ConventionPath
                    )
                )
            }

            try {
                $Convention = Get-Content -Path $ConventionPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
                Write-Verbose "Loaded naming convention from '$ConventionPath'."
            }
            catch {
                $PSCmdlet.ThrowTerminatingError($_)
            }
        }
    }

    process {
        if (-not $ResourceType -and -not $Alias -and -not $Resource) {
            Write-Error 'Specify -ResourceType, -Alias or -Resource to identify the resource type.'
            return
        }

        $definition = Resolve-CafResourceDefinition -ResourceType $ResourceType -Alias $Alias -Resource $Resource

        if (-not $definition) {
            return
        }

        Write-Verbose "Resolved to $($definition.ResourceType) [$($definition.Resource)] (slug '$($definition.Slug)', max length $($definition.MaxLength), dashes $($definition.Dashes), lowercase $($definition.Lowercase))."

        $EffectivePrefix = if ($PSBoundParameters.ContainsKey('Prefix')) { $Prefix } else { $Convention.Prefix }
        $EffectiveSuffix = if ($PSBoundParameters.ContainsKey('Suffix')) { $Suffix } else { $Convention.Suffix }

        $EffectiveRegion = if ($PSBoundParameters.ContainsKey('Region')) { $Region } else { $Convention.Region }

        if ($EffectiveRegion) {
            $RegionDefinition = Resolve-CafRegion -Region $EffectiveRegion

            if (-not $RegionDefinition) {
                return
            }

            Write-Verbose "Resolved region '$EffectiveRegion' to '$($RegionDefinition.Region)' (slug '$($RegionDefinition.Slug)')."
            $EffectiveRegionSlug = $RegionDefinition.Slug
        }

        $EffectiveSeparator = if ($PSBoundParameters.ContainsKey('Separator')) { $Separator } elseif ($Convention.Separator) { $Convention.Separator } else { $Separator }
        $EffectiveInstanceNumberPadding = if ($PSBoundParameters.ContainsKey('InstanceNumberPadding')) { $InstanceNumberPadding } elseif ($Convention.InstanceNumberPadding) { $Convention.InstanceNumberPadding } else { $InstanceNumberPadding }
        $EffectiveRandomSuffixLength = if ($PSBoundParameters.ContainsKey('RandomSuffixLength')) { $RandomSuffixLength } elseif ($null -ne $Convention.RandomSuffixLength) { $Convention.RandomSuffixLength } else { $RandomSuffixLength }

        if ($EffectiveInstanceNumberPadding -lt 1 -or $EffectiveInstanceNumberPadding -gt 10) {
            Write-Error "InstanceNumberPadding from the naming convention must be between 1 and 10, but was $EffectiveInstanceNumberPadding."
            return
        }

        if ($EffectiveRandomSuffixLength -lt 0 -or $EffectiveRandomSuffixLength -gt 24) {
            Write-Error "RandomSuffixLength from the naming convention must be between 0 and 24, but was $EffectiveRandomSuffixLength."
            return
        }

        if ($NoClean) {
            $CleanPrefix = $EffectivePrefix
            $CleanSuffix = $EffectiveSuffix
            $CleanRegionSlug = $EffectiveRegionSlug
        }
        else {
            $CleanPrefix = Get-CleanString -InputObject $EffectivePrefix -Pattern $definition.InvalidCharsRegex
            $CleanSuffix = Get-CleanString -InputObject $EffectiveSuffix -Pattern $definition.InvalidCharsRegex
            $CleanRegionSlug = Get-CleanString -InputObject $EffectiveRegionSlug -Pattern $definition.InvalidCharsRegex
        }

        $ActualSeparator = if ($definition.Dashes) { $EffectiveSeparator } else { '' }

        $HasInstanceNumber = $PSBoundParameters.ContainsKey('InstanceNumber') -or $Count -gt 1
        $StartInstanceNumber = if ($PSBoundParameters.ContainsKey('InstanceNumber')) { $InstanceNumber } else { 1 }
        $EndInstanceNumber = $StartInstanceNumber + $Count - 1

        # Reserve space based on the longest instance number in the sequence, so the trimmed base name
        # (prefix/slug/region/suffix) is the same length for every generated name, not just the first one.
        $InstanceSegment = if ($HasInstanceNumber) { $EndInstanceNumber.ToString().PadLeft($EffectiveInstanceNumberPadding, '0') } else { $null }

        $reservedLength = 0
        $reservedDescriptions = @()

        if ($InstanceSegment) {
            $reservedLength += $InstanceSegment.Length + $ActualSeparator.Length
            $reservedDescriptions += 'the instance number'
        }

        if ($EffectiveRandomSuffixLength -gt 0) {
            $reservedLength += $EffectiveRandomSuffixLength + $ActualSeparator.Length
            $reservedDescriptions += 'the random suffix'
        }

        $mainBudget = $definition.MaxLength - $reservedLength

        if ($reservedDescriptions.Count -gt 0) {
            Write-Verbose "Reserving $reservedLength character(s) for $($reservedDescriptions -join ' and '); $mainBudget of $($definition.MaxLength) remain for the prefix/slug/region/suffix."
        }
        else {
            Write-Verbose "No reserved segments; the full $mainBudget character limit is available for the prefix/slug/region/suffix."
        }

        if ($mainBudget -lt $definition.Slug.Length) {
            Write-Error "Not enough room within the $($definition.MaxLength) character limit for $($definition.ResourceType): '$($definition.Slug)' needs $($definition.Slug.Length) characters, but only $mainBudget are available after reserving space for $($reservedDescriptions -join ' and ')."
            return
        }

        $resourceArr = @($CleanPrefix) + @($definition.Slug) + @($CleanRegionSlug) + @($CleanSuffix) | Where-Object { $_ }

        $resourceName = $resourceArr -join $ActualSeparator
        Write-Verbose "Joined '$($resourceArr -join "', '")' with separator '$ActualSeparator' -> '$resourceName' ($($resourceName.Length) characters)."

        if ($resourceName.Length -gt $mainBudget) {
            $beforeTrimLength = $resourceName.Length
            $resourceName = Get-TrimString -InputObject $resourceName -MaxLength $mainBudget
            $reservedNote = if ($reservedDescriptions.Count -gt 0) { " (after reserving space for $($reservedDescriptions -join ' and '))" } else { '' }
            Write-Warning "Resource name has been trimmed to $mainBudget characters due to the $($definition.MaxLength) character max length restriction$reservedNote."
            Write-Verbose "Trimmed from $beforeTrimLength to $mainBudget characters -> '$resourceName'."
        }

        # A single shared Random, created once per pipeline item rather than per generated name, so a
        # -RandomSuffixSeed produces a reproducible sequence of distinct suffixes across the whole -Count batch.
        $Random = if ($EffectiveRandomSuffixLength -gt 0) {
            if ($PSBoundParameters.ContainsKey('RandomSuffixSeed')) { [System.Random]::new($RandomSuffixSeed) } else { [System.Random]::new() }
        }
        else {
            $null
        }
        $CharPool = 'abcdefghijklmnopqrstuvwxyz0123456789'

        for ($InstanceCounter = $StartInstanceNumber; $InstanceCounter -le $EndInstanceNumber; $InstanceCounter++) {
            $FinalName = $resourceName

            if ($HasInstanceNumber) {
                $CurrentInstanceSegment = $InstanceCounter.ToString().PadLeft($EffectiveInstanceNumberPadding, '0')
                $FinalName = $FinalName + $ActualSeparator + $CurrentInstanceSegment
                Write-Verbose "Appended instance segment '$CurrentInstanceSegment' -> '$FinalName'."
            }

            if ($EffectiveRandomSuffixLength -gt 0) {
                # Always lowercase alphanumeric, even for resource types that allow uppercase - the
                # random suffix exists for uniqueness, not communication, and 36^RandomSuffixLength is
                # already far more entropy than a uniqueness suffix needs.
                $RandomSuffix = -join (1..$EffectiveRandomSuffixLength | ForEach-Object { $CharPool[$Random.Next($CharPool.Length)] })
                $FinalName = $FinalName + $ActualSeparator + $RandomSuffix
                Write-Verbose "Appended random suffix '$RandomSuffix' -> '$FinalName'."
            }

            if ($definition.Lowercase -and $FinalName -cmatch '[A-Z]') {
                $FinalName = $FinalName.ToLower()
                Write-Warning 'Resource name has been converted to lower case due to naming requirement.'
                Write-Verbose "Converted to lowercase -> '$FinalName'."
            }

            if ($FinalName -notmatch $definition.ValidationRegex) {
                Write-Error "The generated name '$FinalName' is not valid for $($definition.ResourceType) (pattern: $($definition.ValidationRegex))."
            }
            else {
                Write-Verbose "Final result: '$FinalName'."
                [pscustomobject]@{
                    Name           = $FinalName
                    ResourceType   = $definition.ResourceType
                    Resource       = $definition.Resource
                    Slug           = $definition.Slug
                    InstanceNumber = if ($HasInstanceNumber) { $InstanceCounter } else { $null }
                    RandomSuffix   = if ($EffectiveRandomSuffixLength -gt 0) { $RandomSuffix } else { $null }
                }
            }
        }
    }
}
function Test-CafResourceName {
    <#
    .SYNOPSIS
        Tests whether a name complies with the Cloud Adoption Framework naming rules for an Azure
        resource type.
    .DESCRIPTION
        Checks a resource name against the same rules Get-CafResourceName enforces when building one:
        minimum/maximum length, required casing, whether the resource type allows a dash, and the
        overall set of valid characters. Returns an object showing the pass/fail result of each rule, a
        plain-English Reason when it's invalid, and an overall IsValid result - or just the overall
        true/false with -Quiet.
 
        IsValid is the authoritative result (the same validation pattern Get-CafResourceName itself checks
        names against); the other fields are a diagnostic breakdown to help pinpoint why a name failed.
        For a small number of resource types the breakdown can't perfectly add up to IsValid - for
        example a character that's only disallowed as the *first* character of a name is flagged as
        invalid by -ValidCharacters wherever it appears, even though IsValid may still be true if it
        appears elsewhere. Treat IsValid as the source of truth.
    .PARAMETER Name
        The resource name to test, e.g. 'contoso-prod-vm-001'.
    .PARAMETER ResourceType
        The ARM resource type to test the name against, e.g. 'Microsoft.Compute/virtualMachines'.
        Matches as a substring by default. Normally used on its own (or -Alias, or -Resource, used
        instead); takes precedence if more than one happens to be specified, e.g. by piping in an
        object that has ResourceType, Alias and Resource properties all describing the same entry.
    .PARAMETER Alias
        The Cloud Adoption Framework abbreviation to test the name against, e.g. 'vmss', used as a
        short alias for the ARM resource type. Matches as a substring by default, and also accepts
        -Slug as an alias. Normally used on its own, instead of -ResourceType.
    .PARAMETER Resource
        The friendly name of the resource to test against, e.g. 'Key vault' - every such name in the
        module's data is unique, so on its own this works as a third alternative to -ResourceType/
        -Alias (also matching as a substring). It also doubles as a disambiguator when combined with
        -ResourceType/-Alias, for the handful of ARM types used by more than one kind of resource (for
        example 'Microsoft.Web/sites' covers both a "Web app" and a "Function app") - required in that
        case, since -ResourceType/-Alias alone would otherwise be ambiguous.
    .PARAMETER Quiet
        Return only an overall $true/$false instead of the full breakdown object.
    .EXAMPLE
        Test-CafResourceName -Name 'contoso-prod-vm-001' -ResourceType Microsoft.Compute/virtualMachines
 
        Returns an object showing which naming rules the name passes or fails, and an overall IsValid.
    .EXAMPLE
        (Test-CafResourceName -Name 'CONTOSO' -ResourceType Microsoft.Storage/storageAccounts).Reason
 
        Returns 'Name must be all lowercase for this resource type.' - Reason explains an invalid
        result in plain English; it's $null when IsValid is $true.
    .EXAMPLE
        Test-CafResourceName -Name 'Contoso_VM!' -Alias vm -Quiet
 
        Returns $false - the name is uppercase, contains an underscore and an exclamation mark, none of
        which are valid for a virtual machine name.
    .EXAMPLE
        Test-CafResourceName -Name 'contoso-kv' -Resource 'Key vault'
 
        -Resource alone works too, since every friendly name is unique - a third alternative to
        -ResourceType/-Alias for when you only remember the resource's plain-English name.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Storage/storageAccounts -Prefix contoso -RandomSuffixLength 6 |
            Test-CafResourceName -ResourceType Microsoft.Storage/storageAccounts -Quiet
 
        Round-trips a name generated by Get-CafResourceName back through Test-CafResourceName.
    .LINK
        https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,

        [Parameter(Position = 1, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]
        $ResourceType,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('Slug')]
        [ValidateNotNullOrEmpty()]
        [string]
        $Alias,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Resource,

        [switch]
        $Quiet
    )

    process {
        if (-not $ResourceType -and -not $Alias -and -not $Resource) {
            Write-Error 'Specify -ResourceType, -Alias or -Resource to identify the resource type.'
            return
        }

        $definition = Resolve-CafResourceDefinition -ResourceType $ResourceType -Alias $Alias -Resource $Resource

        if (-not $definition) {
            return
        }

        $MinLength = $Name.Length -ge $definition.MinLength
        $MaxLength = $Name.Length -le $definition.MaxLength
        $Casing = -not ($definition.Lowercase -and $Name -cmatch '[A-Z]')
        $Dashes = $definition.Dashes -or $Name -cnotmatch '-'
        $ValidCharacters = $Name -cnotmatch $definition.InvalidCharsRegex
        $IsValid = $Name -cmatch $definition.ValidationRegex

        if ($Quiet) {
            return $IsValid
        }

        $Reasons = [System.Collections.Generic.List[string]]::new()

        if ($IsValid) {
            # Even if every breakdown field is individually true, IsValid is the authoritative check -
            # there's nothing to explain when it passed, regardless of what the breakdown says.
        }
        else {
            if (-not $MinLength) {
                $Reasons.Add("Name is $($Name.Length) character$(if ($Name.Length -ne 1) { 's' }), which is shorter than the $($definition.MinLength) character minimum.")
            }

            if (-not $MaxLength) {
                $Reasons.Add("Name is $($Name.Length) characters, which exceeds the $($definition.MaxLength) character maximum by $($Name.Length - $definition.MaxLength).")
            }

            if (-not $Casing) {
                $Reasons.Add('Name must be all lowercase for this resource type.')
            }

            if (-not $Dashes) {
                $Reasons.Add("Name contains a dash ('-'), which this resource type does not allow.")
            }

            if (-not $ValidCharacters) {
                $Reasons.Add('Name contains characters that are not allowed for this resource type.')
            }

            if ($Reasons.Count -eq 0) {
                # None of the simple per-rule checks explain it - some resource types have additional
                # positional rules (e.g. must start/end with a letter) that aren't broken out above.
                $Reasons.Add("Name does not match the required pattern for this resource type (pattern: $($definition.ValidationRegex)).")
            }
        }

        [pscustomobject]@{
            Name            = $Name
            ResourceType    = $definition.ResourceType
            Resource        = $definition.Resource
            IsValid         = $IsValid
            Reason          = if ($Reasons.Count -gt 0) { $Reasons -join ' ' } else { $null }
            MinLength       = $MinLength
            MaxLength       = $MaxLength
            Casing          = $Casing
            Dashes          = $Dashes
            ValidCharacters = $ValidCharacters
        }
    }
}
# The JSON data files live alongside this one (Private/) so $PSScriptRoot finds them correctly in both
# layouts this file runs in: dot-sourced here in dev mode ($PSScriptRoot = this Private folder), and
# inlined into the combined/staged .psm1 (see Build/Tasks/CombineFunctionsAndStage.ps1), where the
# build copies these same two files to the staged module's root - $PSScriptRoot there resolves to that
# root too, since the combined file *is* the module's root script at that point.
$Script:ResourceDefinitionPath = Join-Path $PSScriptRoot 'resourceDefinition.json'
$Script:RegionDefinitionPath = Join-Path $PSScriptRoot 'regionDefinition.json'

# Registered here (rather than via the [ArgumentCompleter(...)] attribute on each parameter) because an
# attribute scriptblock isn't bound to this module's session state, so it can't see private functions
# like Get-CafResourceDefinition - it would silently fail and PowerShell would fall back to file
# completion. A scriptblock defined directly in a module file (dot-sourced here, inlined in the
# combined/staged build) doesn't have that problem.
$ResourceTypeCompleter = {
    param($CommandName, $ParameterName, $WordToComplete, $CommandAst, $FakeBoundParameters)
    $null = $CommandName, $ParameterName, $CommandAst, $FakeBoundParameters
    # Substring match, same as the cmdlets' own -ResourceType/-Alias partial matching, so what
    # tab-completes is consistent with what actually resolves.
    $Pattern = Get-CafWildcardPattern -Value $WordToComplete
    (Get-CafResourceDefinition).ResourceType |
        Select-Object -Unique |
        Where-Object { $_ -like $Pattern } |
        ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
}

$SlugCompleter = {
    param($CommandName, $ParameterName, $WordToComplete, $CommandAst, $FakeBoundParameters)
    $null = $CommandName, $ParameterName, $CommandAst, $FakeBoundParameters
    $Pattern = Get-CafWildcardPattern -Value $WordToComplete
    (Get-CafResourceDefinition).Slug |
        Select-Object -Unique |
        Where-Object { $_ -like $Pattern } |
        ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
}

$ResourceCompleter = {
    param($CommandName, $ParameterName, $WordToComplete, $CommandAst, $FakeBoundParameters)
    $null = $CommandName, $ParameterName, $CommandAst

    $Definitions = @(Get-CafResourceDefinition)

    # Narrow to whichever resource type the caller has already typed, if any, so -Resource only
    # offers the friendly names that actually apply to it (e.g. just 'Web app'/'Function app' once
    # -ResourceType Microsoft.Web/sites is on the line), rather than every friendly name there is.
    # $FakeBoundParameters is always keyed by the canonical parameter name, even if the caller typed
    # the -Slug alias instead of -Alias, so there's no separate case to handle for that.
    if ($FakeBoundParameters.ResourceType) {
        $Definitions = Select-CafMatch -InputObject $Definitions -Property ResourceType -Value $FakeBoundParameters.ResourceType
    }
    elseif ($FakeBoundParameters.Alias) {
        $Definitions = Select-CafMatch -InputObject $Definitions -Property Slug -Value $FakeBoundParameters.Alias
    }

    $Pattern = Get-CafWildcardPattern -Value $WordToComplete
    $Definitions.Resource |
        Select-Object -Unique |
        Where-Object { $_ -like $Pattern } |
        Sort-Object |
        ForEach-Object {
            # Friendly names routinely contain spaces/parentheses (e.g. 'Managed disk (OS)'), so the
            # completion text needs quoting to stay a single valid token once it's inserted.
            $Quoted = "'" + $_.Replace("'", "''") + "'"
            [System.Management.Automation.CompletionResult]::new($Quoted, $_, 'ParameterValue', $_)
        }
}

$RegionCompleter = {
    param($CommandName, $ParameterName, $WordToComplete, $CommandAst, $FakeBoundParameters)
    $null = $CommandName, $ParameterName, $CommandAst, $FakeBoundParameters
    # Matches against the ARM region name, its display name or its slug, same fields Resolve-CafRegion
    # accepts, so typing 'east', 'East US' or 'eus' all offer a completion - but always completes to the
    # ARM name, since that's the unambiguous value every other Azure PowerShell -Location parameter
    # expects.
    $Pattern = Get-CafWildcardPattern -Value $WordToComplete
    (Get-CafRegionDefinition) |
        Where-Object { $_.Region -like $Pattern -or $_.DisplayName -like $Pattern -or $_.Slug -like $Pattern } |
        Sort-Object -Property Region -Unique |
        ForEach-Object { [System.Management.Automation.CompletionResult]::new($_.Region, "$($_.Region) ($($_.DisplayName))", 'ParameterValue', $_.DisplayName) }
}

Register-ArgumentCompleter -CommandName 'Get-CafResourceName', 'New-CafResourceName', 'Test-CafResourceName', 'Get-CafResourceType' -ParameterName 'ResourceType' -ScriptBlock $ResourceTypeCompleter
Register-ArgumentCompleter -CommandName 'Get-CafResourceName', 'New-CafResourceName', 'Test-CafResourceName', 'Get-CafResourceType' -ParameterName 'Alias' -ScriptBlock $SlugCompleter
Register-ArgumentCompleter -CommandName 'Get-CafResourceName', 'New-CafResourceName', 'Test-CafResourceName', 'Get-CafResourceType' -ParameterName 'Resource' -ScriptBlock $ResourceCompleter
Register-ArgumentCompleter -CommandName 'Get-CafResourceName', 'New-CafResourceName', 'Get-CafRegion' -ParameterName 'Region' -ScriptBlock $RegionCompleter
function Get-CafRegionDefinition {
    <#
    .SYNOPSIS
        Returns the cached set of Azure region abbreviation definitions.
    .DESCRIPTION
        Loads regionDefinition.json from the module root the first time it's needed and caches it for
        the lifetime of the module session, so repeated calls to Get-CafResourceName/Get-CafRegion don't
        re-read and re-parse the file from disk every time.
    .EXAMPLE
        Get-CafRegionDefinition
    #>

    [CmdletBinding()]
    param()

    if (-not $Script:CafRegionDefinitions) {
        $Script:CafRegionDefinitions = Get-Content -Path $Script:RegionDefinitionPath -Raw | ConvertFrom-Json
    }

    $Script:CafRegionDefinitions
}
function Get-CafResourceDefinition {
    <#
    .SYNOPSIS
        Returns the cached set of CAF resource naming definitions.
    .DESCRIPTION
        Loads resourceDefinition.json from the module root the first time it's needed and caches it for
        the lifetime of the module session, so repeated calls to Get-CafResourceName/Get-CafResourceType don't
        re-read and re-parse the file from disk every time.
    .EXAMPLE
        Get-CafResourceDefinition
    #>

    [CmdletBinding()]
    param()

    if (-not $Script:CafResourceDefinitions) {
        $Script:CafResourceDefinitions = Get-Content -Path $Script:ResourceDefinitionPath -Raw | ConvertFrom-Json
    }

    $Script:CafResourceDefinitions
}
function Get-CafWildcardPattern {
    <#
    .SYNOPSIS
        Turns a plain string into a partial ('contains') wildcard pattern.
    .DESCRIPTION
        Used by Get-CafResourceName/Get-CafResourceType so that -ResourceType and -Resource match as a
        substring by default (e.g. 'virtualMachines' matches 'Microsoft.Compute/virtualMachines'),
        while still respecting an explicit wildcard pattern (e.g. '*storageAccount*' or 'disk?') if the
        caller already supplied one, rather than wrapping it a second time.
    .EXAMPLE
        Get-CafWildcardPattern -Value 'virtualMachines'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]
        $Value
    )

    if ($Value -match '[*?\[\]]') {
        $Value
    }
    else {
        "*$Value*"
    }
}
function Resolve-CafRegion {
    <#
    .SYNOPSIS
        Resolves a -Region value to exactly one regionDefinition.json entry.
    .DESCRIPTION
        Shared resolution logic for Get-CafResourceName and Get-CafRegion, so both cmdlets identify a
        region the same way and report identical errors for an unknown/ambiguous value.
 
        Checks for an exact (case-insensitive) match against the ARM region name (e.g. 'eastus'), its
        display name (e.g. 'East US') or its CAF slug (e.g. 'eus') first - checking all three up front,
        rather than one at a time, matters for a short value like a slug: 'ae' (Australia East's slug)
        is also a substring of several other regions' ARM names (e.g. 'canadaeast', 'israelcentral'), so
        falling through to a substring search would make it ambiguous instead of resolving it.
 
        Only once no exact match exists does it fall back to a substring ('contains') search, same as
        -ResourceType/-Resource elsewhere in the module - against the ARM region name first, then the
        display name. The slug isn't included in that substring fallback: it's short enough that partial
        matching on it would mostly just produce ambiguous results, and a caller who doesn't already know
        the exact slug should be matching on the region name/display name instead.
 
        Writes a non-terminating error and returns nothing if the value doesn't resolve to exactly one
        entry - callers should check for a $null/empty result and return without continuing.
    .EXAMPLE
        Resolve-CafRegion -Region 'eastus'
    .EXAMPLE
        Resolve-CafRegion -Region 'East US'
    .EXAMPLE
        Resolve-CafRegion -Region 'eus'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]
        $Region
    )

    $definitions = @(Get-CafRegionDefinition)

    $candidates = @($definitions | Where-Object { $_.Region -eq $Region -or $_.DisplayName -eq $Region -or $_.Slug -eq $Region })

    if ($candidates.Count -eq 0) {
        $candidates = Select-CafMatch -InputObject $definitions -Property Region -Value $Region

        if ($candidates.Count -eq 0) {
            $candidates = Select-CafMatch -InputObject $definitions -Property DisplayName -Value $Region
        }
    }

    if ($candidates.Count -eq 0) {
        Write-Error "No Azure region matches '$Region'. Use Get-CafRegion to search for a supported region."
        return
    }

    if ($candidates.Count -gt 1) {
        $options = ($candidates | ForEach-Object { "$($_.Region) [$($_.DisplayName)]" }) -join "', '"
        Write-Error "'$Region' matches more than one Azure region. Narrow it down with the ARM region name: '$options'."
        return
    }

    $candidates[0]
}
function Resolve-CafResourceDefinition {
    <#
    .SYNOPSIS
        Resolves a -ResourceType, -Alias or -Resource value to exactly one resourceDefinition.json
        entry.
    .DESCRIPTION
        Shared resolution logic for Get-CafResourceName and Test-CafResourceName, so all three cmdlets
        identify a resource type the same way and report identical errors for an unknown/ambiguous
        value. Writes a non-terminating error and returns nothing if the value doesn't resolve to
        exactly one entry - callers should check for a $null/empty result and return without
        continuing.
 
        -ResourceType takes priority as the primary identifier if given, then -Alias, with -Resource
        narrowing *within* whichever of those matched (for the handful of types used by more than one
        kind of resource). When neither -ResourceType nor -Alias is given, -Resource is used as the
        primary identifier instead - every "Resource" friendly name in resourceDefinition.json happens
        to be globally unique today, so this only fails if that ever stops being true upstream.
 
        Callers don't need to guard against more than one of -ResourceType/-Alias/-Resource being
        supplied - e.g. piping Get-CafResourceType's output through by property name binds ResourceType,
        Resource and (via its Slug alias) Alias all at once, since they're all present on that object
        and all describe the same entry. Precedence just needs to be deterministic for that case, not
        an error - it's not a conflict unless the caller explicitly typed contradictory values, which
        isn't something this function can distinguish from the pipeline case anyway.
    .EXAMPLE
        Resolve-CafResourceDefinition -ResourceType 'Microsoft.Compute/virtualMachines'
    .EXAMPLE
        Resolve-CafResourceDefinition -Alias 'afd' -Resource 'classic'
    .EXAMPLE
        Resolve-CafResourceDefinition -Resource 'Key vault'
    #>

    [CmdletBinding()]
    param(
        [string]
        $ResourceType,

        [string]
        $Alias,

        [string]
        $Resource
    )

    $ResourceIsPrimary = -not $ResourceType -and -not $Alias

    if ($ResourceType) {
        $identifier = "'$ResourceType'"
        $candidates = Select-CafMatch -InputObject @(Get-CafResourceDefinition) -Property ResourceType -Value $ResourceType
    }
    elseif ($Alias) {
        $identifier = "alias '$Alias'"
        $candidates = Select-CafMatch -InputObject @(Get-CafResourceDefinition) -Property Slug -Value $Alias
    }
    else {
        $identifier = "resource '$Resource'"
        $candidates = Select-CafMatch -InputObject @(Get-CafResourceDefinition) -Property Resource -Value $Resource
    }

    if ($candidates.Count -eq 0) {
        Write-Error "No resource type matches $identifier. Use Get-CafResourceType to search for a supported resource type."
        return
    }

    if ($Resource -and -not $ResourceIsPrimary) {
        $allCandidates = $candidates
        $candidates = Select-CafMatch -InputObject $candidates -Property Resource -Value $Resource

        if ($candidates.Count -eq 0) {
            $options = ($allCandidates | ForEach-Object { "$($_.ResourceType) [$($_.Resource)]" }) -join "', '"
            Write-Error "'$Resource' doesn't match any resource for $identifier. Available: '$options'."
            return
        }
    }

    if ($candidates.Count -gt 1) {
        $options = ($candidates | ForEach-Object { "$($_.ResourceType) [$($_.Resource)]" }) -join "', '"
        $suggestion = if ($ResourceIsPrimary) { '-ResourceType' } else { '-Resource' }
        Write-Error "$identifier matches more than one resource type. Narrow it down with $suggestion`: '$options'."
        return
    }

    $candidates[0]
}
function Select-CafMatch {
    <#
    .SYNOPSIS
        Filters a collection to the items whose property exactly matches a value, falling back to a
        partial ('contains') match only when nothing matches exactly.
    .DESCRIPTION
        Used by Get-CafResourceName/Get-CafResourceType to make -ResourceType/-Resource match as a substring
        for convenience, without that breaking exact lookups: partial matching alone is ambiguous for
        values like 'Microsoft.Compute/virtualMachines', since (case-insensitively) it's also a
        substring of 'Microsoft.Compute/virtualMachineScaleSets'. Checking for an exact match first means
        a fully-qualified value always resolves to itself, and the substring fallback only kicks in when
        there was no exact match to begin with.
    .EXAMPLE
        Select-CafMatch -InputObject $definitions -Property ResourceType -Value 'Microsoft.Compute/virtualMachines'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object[]]
        $InputObject,

        [Parameter(Mandatory)]
        [string]
        $Property,

        [Parameter(Mandatory)]
        [string]
        $Value
    )

    $exact = @($InputObject | Where-Object { $_.$Property -eq $Value })
    if ($exact.Count -gt 0) {
        return $exact
    }

    $pattern = Get-CafWildcardPattern -Value $Value
    @($InputObject | Where-Object { $_.$Property -like $pattern })
}