Private/Common.ps1

function New-DtDirectory {
    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

        [switch]$Force
    )

    if (Test-Path -LiteralPath $Path) {
        Write-Verbose "Directory already exists: $Path"
        return $false
    }

    if (-not $PSCmdlet.ShouldProcess($Path, 'Create directory')) {
        return $false
    }

    New-Item -ItemType Directory -Path $Path -Force:$Force | Out-Null

    Write-Verbose "Created directory: $Path"
    return $true
}

function Resolve-DtFullPath {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    if ([System.IO.Path]::IsPathRooted($Path)) {
        return [System.IO.Path]::GetFullPath($Path)
    }

    return [System.IO.Path]::GetFullPath(
        (Join-Path -Path (Get-Location).Path -ChildPath $Path)
    )
}

function Resolve-DtContainedPath {
    <#
    .SYNOPSIS
    Resolves a child name against a base directory and rejects the result if it escapes that directory.

    .DESCRIPTION
    Mirrors the containment check used elsewhere in this portfolio
    (cockpit's MarlinAdapter._resolve_contained_path) - a user-supplied
    name must not be able to write outside the intended base directory via
    ".." segments, UNC paths, or similar.

    .PARAMETER BasePath
    The directory the result must stay inside.

    .PARAMETER ChildName
    The caller-supplied name to join onto BasePath.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$BasePath,

        [Parameter(Mandatory = $true)]
        [string]$ChildName
    )

    if ([System.IO.Path]::IsPathRooted($ChildName) -or
        $ChildName -match '^[A-Za-z]:' -or
        $ChildName -match '^[\\/]') {
        throw "'$ChildName' is not a valid name: rooted paths are not allowed."
    }

    $childSegments = $ChildName -split '[\\/]'
    if ($childSegments -contains '..') {
        throw "'$ChildName' is not a valid name: parent traversal is not allowed."
    }

    $resolvedBase = Resolve-DtFullPath -Path $BasePath
    $candidate = Join-Path -Path $resolvedBase -ChildPath $ChildName
    $resolvedCandidate = [System.IO.Path]::GetFullPath($candidate)

    $pathComparison = if ([System.IO.Path]::DirectorySeparatorChar -eq '\') {
        [System.StringComparison]::OrdinalIgnoreCase
    }
    else {
        [System.StringComparison]::Ordinal
    }
    $trimmedBase = $resolvedBase.TrimEnd('\', '/')
    $baseWithSep = $trimmedBase + [System.IO.Path]::DirectorySeparatorChar
    $isBasePath = $resolvedCandidate.Equals($trimmedBase, $pathComparison)
    $isContainedPath = $resolvedCandidate.StartsWith($baseWithSep, $pathComparison)
    if (-not $isBasePath -and -not $isContainedPath) {
        throw "'$ChildName' is not a valid name: it resolves outside the base directory '$resolvedBase'."
    }

    return $resolvedCandidate
}

function ConvertTo-DtSafeMarkdownText {
    <#
    .SYNOPSIS
    Strips characters from user-supplied text that could break generated Markdown structure.

    .DESCRIPTION
    Low-severity defense-in-depth for text interpolated into generated
    Markdown (README titles/descriptions, project names). Not full
    sanitization - this is a non-executable surface - just strips newlines
    and leading heading markers so a supplied value can't inject new
    Markdown sections or headings into generated files.

    .PARAMETER Text
    The value to sanitize.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Text
    )

    $singleLine = ($Text -replace '[\r\n]+', ' ').Trim()
    return ($singleLine -replace '^#+\s*', '')
}

function Set-DtFileContent {
    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Content,

        [switch]$Force
    )

    if ((Test-Path -LiteralPath $Path) -and -not $Force) {
        Write-Warning "Path already exists. Use -Force to overwrite: $Path"
        return $false
    }

    if (-not $PSCmdlet.ShouldProcess($Path, 'Write file content')) {
        return $false
    }

    $parent = Split-Path -Path $Path -Parent
    if ($parent -and -not (Test-Path -LiteralPath $parent)) {
        New-DtDirectory -Path $parent -Force:$Force -Confirm:$false | Out-Null
    }

    $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
    $contentWithNewline = $Content + [System.Environment]::NewLine
    [System.IO.File]::WriteAllText($Path, $contentWithNewline, $utf8NoBom)
    Write-Verbose "Wrote file: $Path"
    return $true
}