ModuleTools.psm1

function Get-MTProjectInfo {
    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$Path = (Get-Location).Path
    )
    $ProjectRoot = (Resolve-Path -LiteralPath $Path).Path
    $ProjectJson = [System.IO.Path]::Join($ProjectRoot, 'project.json')
    
    if (-not (Test-Path -LiteralPath $projectJson)) {
        throw "Not a project folder. project.json not found: $projectJson"
    }
    
    $jsonData = Get-Content -LiteralPath $projectJson -Raw | ConvertFrom-Json -AsHashtable

    $Out = @{}
    $Out['ProjectJSON'] = $ProjectJson

    foreach ($key in $jsonData.Keys) {
        $Out[$key] = $jsonData[$key]
    }
    $Out.ProjectJson = $projectJson
    $Out.PSTypeName = 'MTProjectInfo'
    $ProjectName = $jsonData.ProjectName

    ## Folders
    $Out['ProjectRoot'] = $ProjectRoot
    $Out['PublicDir'] = [System.IO.Path]::Join($ProjectRoot, 'src', 'public')
    $Out['PrivateDir'] = [System.IO.Path]::Join($ProjectRoot, 'src', 'private')
    $Out['ClassesDir'] = [System.IO.Path]::Join($ProjectRoot, 'src', 'classes')
    $Out['ResourcesDir'] = [System.IO.Path]::Join($ProjectRoot, 'src', 'resources')
    $Out['DocsDir'] = [System.IO.Path]::Join($ProjectRoot, 'docs')
    $Out['OutputDir'] = [System.IO.Path]::Join($ProjectRoot, 'dist')  
    $Out['OutputModuleDir'] = [System.IO.Path]::Join($Out.OutputDir, $ProjectName)  
    $Out['ModuleFilePSM1'] = [System.IO.Path]::Join($Out.OutputModuleDir, "$ProjectName.psm1")   
    $Out['ManifestFilePSD1'] = [System.IO.Path]::Join($Out.OutputModuleDir, "$ProjectName.psd1")  

    return [pscustomobject]$out
}
function Invoke-MTBuild {
    [CmdletBinding()]
    param (
    )
    $ErrorActionPreference = 'Stop'
    Reset-ProjectDist
    Build-Module
    Build-Manifest
    Build-Help
    Copy-ProjectResource
}
function Invoke-MTTest {
    [CmdletBinding()]
    param (
        [string[]]$TagFilter,
        [string[]]$ExcludeTagFilter
    )
    Test-ProjectSchema Pester | Out-Null
    $Script:data = Get-MTProjectInfo 
    $pesterConfig = New-PesterConfiguration -Hashtable $data.Pester

    $testPath = './tests' 
    $pesterConfig.Run.Path = $testPath
    $pesterConfig.Run.PassThru = $true
    $pesterConfig.Run.Exit = $true
    $pesterConfig.Run.Throw = $true
    $pesterConfig.Filter.Tag = $TagFilter 
    $pesterConfig.Filter.ExcludeTag = $ExcludeTagFilter 
    $pesterConfig.TestResult.OutputPath = './dist/TestResults.xml'
    $TestResult = Invoke-Pester -Configuration $pesterConfig
    if ($TestResult.Result -ne 'Passed') {
        Write-Error 'Tests failed' -ErrorAction Stop 
        return $LASTEXITCODE
    }
}
function New-MTModule {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [string]$Path = (Get-Location).Path
    )
    $ErrorActionPreference = 'Stop'
    Push-Location
    if (-not(Test-Path $Path)) { Write-Error 'Not a valid path' }
    $Questions = [ordered]@{
        ProjectName           = @{
            Caption = 'Module Name'
            Message = 'Enter Module name of your choice, should be single word with no special characters'
            Prompt  = 'Name'
            Default = 'MANDATORY'
        }
        Description           = @{
            Caption = 'Module Description'
            Message = 'What does your module do? Describe in simple words'
            Prompt  = 'Description'
            Default = 'ModuleTools Module'
        }
        Version               = @{
            Caption = 'Semantic Version'
            Message = 'Starting Version of the module (Default: 0.0.1)'
            Prompt  = 'Version'
            Default = '0.0.1'
        }
        Author                = @{
            Caption = 'Module Author'
            Message = 'Enter Author or company name'
            Prompt  = 'Name'
            Default = 'PS'
        }
        PowerShellHostVersion = @{
            Caption = 'Supported PowerShell Version'
            Message = 'What is minimum supported version of PowerShell for this module (Default: 7.4)'
            Prompt  = 'Version'
            Default = '7.4'
        }
        EnableGit             = @{
            Caption = 'Git Version Control'
            Message = 'Do you want to enable version controlling using Git'
            Prompt  = 'EnableGit'
            Default = 'No'
            Choice  = @{
                Yes = 'Enable Git'
                No  = 'Skip Git initialization'
            }
        }
        EnablePester          = @{
            Caption = 'Pester Testing'
            Message = 'Do you want to enable basic Pester Testing'
            Prompt  = 'EnablePester'
            Default = 'No'
            Choice  = @{
                Yes = 'Enable pester to perform testing'
                No  = 'Skip pester testing'
            }
        }
    }
    $Answer = @{}
    $Questions.Keys | ForEach-Object {
        $Answer.$_ = Read-AwesomeHost -Ask $Questions.$_
    }

    # TODO check other components
    if ($Answer.ProjectName -notmatch '^[A-Za-z][A-Za-z0-9_.]*$') {
        Write-Error 'Module Name invalid. Module should be one word and contain only Letters,Numbers and ' 
    }
  
    $DirProject = Join-Path -Path $Path -ChildPath $Answer.ProjectName
    $DirSrc = Join-Path -Path $DirProject -ChildPath 'src'
    $DirPrivate = Join-Path -Path $DirSrc -ChildPath 'private'
    $DirPublic = Join-Path -Path $DirSrc -ChildPath 'public'
    $DirResources = Join-Path -Path $DirSrc -ChildPath 'resources'
    $DirClasses = Join-Path -Path $DirSrc -ChildPath 'classes'
    $DirTests = Join-Path -Path $DirProject -ChildPath 'tests'
    $ProjectJSONFile = Join-Path $DirProject -ChildPath 'project.json'

    if (Test-Path $DirProject) {
        Write-Error 'Project already exists, aborting' | Out-Null
    }
    # Setup Module

    Write-Message "`nStarted Module Scaffolding" -color Green
    Write-Message 'Setting up Directories'
    ($DirProject, $DirSrc, $DirPrivate, $DirPublic, $DirResources, $DirClasses) | ForEach-Object {
        'Creating Directory: {0}' -f $_ | Write-Verbose
        New-Item -ItemType Directory -Path $_ | Out-Null
    }
    if ( $Answer.EnablePester -eq 'Yes') {
        Write-Message 'Include Pester Configs'
        New-Item -ItemType Directory -Path $DirTests | Out-Null
    }
    if ( $Answer.EnableGit -eq 'Yes') {
        Write-Message 'Initialize Git Repo'
        New-InitiateGitRepo -DirectoryPath $DirProject
    }

    ## Create ProjectJSON
    $JsonData = Get-Content "$PSScriptRoot\resources\ProjectTemplate.json" -Raw | ConvertFrom-Json -AsHashtable

    $JsonData.ProjectName = $Answer.ProjectName
    $JsonData.Description = $Answer.Description
    $JsonData.Version = $Answer.version
    $JsonData.Manifest.Author = $Answer.Author
    $JsonData.Manifest.PowerShellHostVersion = $Answer.PowerShellHostVersion
    $JsonData.Manifest.GUID = (New-Guid).GUID
    if ($Answer.EnablePester -eq 'No') { $JsonData.Remove('Pester') }

    Write-Verbose $JsonData
    $JsonData | ConvertTo-Json | Out-File $ProjectJSONFile

    'Module {0} scaffolding complete' -f $Answer.ProjectName | Write-Message -color Green
}
function Publish-MTLocal {
    [CmdletBinding()]
    param(
        [string]$ModuleDirectoryPath
    )

    if ($ModuleDirectoryPath) {
        if (-not (Test-Path $ModuleDirectoryPath -PathType Container)) {
            New-Item $ModuleDirectoryPath -ItemType Directory -Force | Out-Null
        }
    } else {
        $ModuleDirectoryPath = Get-LocalModulePath
    }

    Write-Verbose "Using $ModuleDirectoryPath as path"

    $ProjectInfo = Get-MTProjectInfo

    # Ensure module is locally built and ready
    if (-not (Test-Path $ProjectInfo.OutputModuleDir)) {
        throw 'Dist folder is empty, build the module before running publish command'
    }

    # Cleanup old files
    $OldModule = Join-Path -Path $ModuleDirectoryPath -ChildPath $ProjectInfo.ProjectName
    if (Test-Path -Path $OldModule) {
        Write-Verbose 'Removing old module files'
        Remove-Item -Recurse $OldModule -Force
    }

    # Copy New Files
    Write-Verbose 'Copying new Files'
    Copy-Item -Path $ProjectInfo.OutputModuleDir -Destination $ModuleDirectoryPath -Recurse -ErrorAction Stop
    Write-Verbose 'Module copy to local path complete, Refresh session or import module manually'
}
function Update-MTModuleVersion {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [ValidateSet('Major', 'Minor', 'Patch')]
        [string]$Label = 'Patch',
        [switch]$PreviewRelease,
        [switch]$StableRelease
    )
    Write-Verbose 'Running Version Update'

    $data = Get-MTProjectInfo
    $jsonContent = Get-Content -Path $data.ProjectJSON | ConvertFrom-Json

    [semver]$CurrentVersion = $jsonContent.Version
    $Major = $CurrentVersion.Major
    $Minor = $CurrentVersion.Minor
    
    if ($Label -eq 'Major') {
        $Major = $CurrentVersion.Major + 1
        $Minor = 0
        $Patch = 0
    } elseif ($Label -eq 'Minor') {
        $Minor = $CurrentVersion.Minor + 1
        $Patch = 0
    } elseif ($Label -eq 'Patch') {
        $Patch = $CurrentVersion.Patch + 1
    }

    if ($PreviewRelease) {
        $ReleaseType = 'preview' 
    } elseif ($StableRelease) { 
        $ReleaseType = $null
    } else {
        $ReleaseType = $CurrentVersion.PreReleaseLabel
    }
    
    $newVersion = [semver]::new($Major, $Minor, $Patch, $ReleaseType, $null)

    # Update the version in the JSON object
    $jsonContent.Version = $newVersion.ToString()
    Write-Host "Version bumped to : $newVersion"

    # Convert the JSON object back to JSON format
    $newJsonContent = $jsonContent | ConvertTo-Json

    # Write the updated JSON back to the file
    $newJsonContent | Set-Content -Path $data.ProjectJSON
}
function Build-Help {
    [CmdletBinding()]
    param(
    )
    Write-Verbose 'Running Help update'

    $data = Get-MTProjectInfo
    $helpMarkdownFiles = Get-ChildItem -Path $data.DocsDir -Filter '*.md' -Recurse 

    if (-not $helpMarkdownFiles) {
        Write-Verbose 'No help markdown files in docs directory, skipping building help' 
        return
    }
    
    if (-not (Get-Module -Name Microsoft.PowerShell.PlatyPS -ListAvailable)) {
        throw 'The module Microsoft.PowerShell.PlatyPS must be installed for Markdown documentation to be generated.'
    }

    $AllCommandHelpFiles = $helpMarkdownFiles | Measure-PlatyPSMarkdown | Where-Object FileType -Match CommandHelp

    # Export to Dist folder
    $AllCommandHelpFiles | Import-MarkdownCommandHelp -Path { $_.FilePath } |
    Export-MamlCommandHelp -OutputFolder $data.OutputModuleDir | Out-Null

    # Rename the directory to match locale
    $HelpDirOld = Join-Path $data.OutputModuleDir $Data.ProjectName
    #TODO: hardcoded locale to en-US, change it based on Doc type
    $languageLocale = 'en-US'
    $HelpDirNew = Join-Path $data.OutputModuleDir $languageLocale
    Write-Verbose "Renamed folder to locale: $languageLocale"

    Rename-Item -Path $HelpDirOld -NewName $HelpDirNew
}
function Build-Manifest {
    Write-Verbose 'Building psd1 data file Manifest'
    $data = Get-MTProjectInfo

    ## TODO - DO schema check

    $PubFunctionFiles = Get-ChildItem -Path $data.PublicDir -Filter *.ps1
    $functionToExport = @()
    $aliasToExport = @()
    $PubFunctionFiles.FullName | ForEach-Object {
        $functionToExport += Get-FunctionNameFromFile -filePath $_
        $aliasToExport += Get-AliasInFunctionFromFile -filePath $_
    }

    ## Import Format.ps1xml (if any)
    $FormatsToProcess = @()
    Get-ChildItem -Path $data.ResourcesDir -File -Filter '*Format.ps1xml' -ErrorAction SilentlyContinue | ForEach-Object {
        if ($data.copyResourcesToModuleRoot) { 
            $FormatsToProcess += $_.Name
        } else {
            $FormatsToProcess += Join-Path -Path 'resources' -ChildPath $_.Name
        }
    }

    ## Import Types.ps1xml1 (if any)
    $TypesToProcess = @()
    Get-ChildItem -Path $data.ResourcesDir -File -Filter '*Types.ps1xml' -ErrorAction SilentlyContinue | ForEach-Object {
        if ($data.copyResourcesToModuleRoot) { 
            $TypesToProcess += $_.Name
        } else {
            $TypesToProcess += Join-Path -Path 'resources' -ChildPath $_.Name
        }
    }

    $ManfiestAllowedParams = (Get-Command New-ModuleManifest).Parameters.Keys
    $sv = [semver]$data.Version
    $ParmsManifest = @{
        Path              = $data.ManifestFilePSD1
        Description       = $data.Description
        FunctionsToExport = $functionToExport
        AliasesToExport   = $aliasToExport
        RootModule        = "$($data.ProjectName).psm1"
        ModuleVersion     = [version]$sv
        FormatsToProcess  = $FormatsToProcess
        TypesToProcess    = $TypesToProcess
    }
      
    ## Release lable
    if ($sv.PreReleaseLabel) {
        $ParmsManifest['Prerelease'] = $sv.PreReleaseLabel 
    } 

    # Accept only valid Manifest Parameters
    $data.Manifest.Keys | ForEach-Object {
        if ( $ManfiestAllowedParams -contains $_) {
            if ($data.Manifest.$_) {
                $ParmsManifest.add($_, $data.Manifest.$_ )
            }
        } else {
            Write-Warning "Unknown parameter $_ in Manifest"
        }
    }

    try {
        New-ModuleManifest @ParmsManifest -ErrorAction Stop
    } catch {
        'Failed to create Manifest: {0}' -f $_.Exception.Message | Write-Error -ErrorAction Stop
    }
}
function Build-Module {
    $data = Get-MTProjectInfo
    $MTBuildVersion = (Get-Command Invoke-MTBuild).Version
    Write-Verbose "Running ModuleTols Version: $MTBuildVersion"
    Write-Verbose 'Buidling module psm1 file'
    Test-ProjectSchema -Schema Build | Out-Null

    $sb = [System.Text.StringBuilder]::new()

    # Classes Folder
    $files = Get-ChildItem -Path $data.ClassesDir -Filter *.ps1 -ErrorAction SilentlyContinue
    $files | ForEach-Object {
        $sb.AppendLine([IO.File]::ReadAllText($_.FullName)) | Out-Null
    }

    # Public Folder
    $files = Get-ChildItem -Path $data.PublicDir -Filter *.ps1
    $files | ForEach-Object {
        $sb.AppendLine([IO.File]::ReadAllText($_.FullName)) | Out-Null
    }

    # Private Folder
    $files = Get-ChildItem -Path $data.PrivateDir -Filter *.ps1 -ErrorAction SilentlyContinue
    if ($files) {
        $files | ForEach-Object {
            $sb.AppendLine([IO.File]::ReadAllText($_.FullName)) | Out-Null
        }
    }
    try {
        Set-Content -Path $data.ModuleFilePSM1 -Value $sb.ToString() -Encoding 'UTF8' -ErrorAction Stop # psm1 file
    } catch {
        Write-Error 'Failed to create psm1 file' -ErrorAction Stop
    }
}
function Copy-ProjectResource {
    $data = Get-MTProjectInfo
    $resFolder = [System.IO.Path]::Join($data.ProjectRoot, 'src', 'resources')
    if (Test-Path $resFolder) {
        ## Copy to root folder instead of creating Resource Folder in module root
        if ($data.copyResourcesToModuleRoot) {
            # Copy the resources folder content to the OutputModuleDir
            $items = Get-ChildItem -Path $resFolder -ErrorAction SilentlyContinue
            if ($items) {
                Write-Verbose 'Files found in resource folder, copying resource folder content'
                foreach ($item in $items) {
                    Copy-Item -Path $item.FullName -Destination ($data.OutputModuleDir) -Recurse -Force -ErrorAction Stop
                }
            }
        } else {
            # Copy the resources folder to the OutputModuleDir
            if (Get-ChildItem $resFolder -ErrorAction SilentlyContinue) {
                Write-Verbose 'Files found in resource folder, Copying resource folder'
                Copy-Item -Path $resFolder -Destination ($data.OutputModuleDir) -Recurse -Force -ErrorAction Stop
            }
        }
    }
}
<#
.SYNOPSIS
Retrieves information about alias in a given function/file so it can be added to module manifest
 
.DESCRIPTION
Adding alias to module manifest and exporting it will ensure that functions can be called using alias without importing explicitly
#>

function Get-AliasInFunctionFromFile {
    param($filePath)
    try {
        $ast = [System.Management.Automation.Language.Parser]::ParseFile($filePath, [ref]$null, [ref]$null)

        $functionNodes = $ast.FindAll({
                param($node)
                $node -is [System.Management.Automation.Language.FunctionDefinitionAst]
            }, $true)

        $function = $functionNodes[0]
        $paramsAttributes = $function.Body.ParamBlock.Attributes 

        $aliases = ($paramsAttributes | Where-Object { $_.TypeName -like 'Alias' } | ForEach-Object PositionalArguments).Value
        $aliases
    } catch {
        return
    }
}
function Get-FunctionNameFromFile {
    param($filePath)
    try {
        $moduleContent = Get-Content -Path $filePath -Raw
        $ast = [System.Management.Automation.Language.Parser]::ParseInput($moduleContent, [ref]$null, [ref]$null)
        $functionName = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false) | ForEach-Object { $_.Name } 
        return $functionName
    }
    catch { return '' }
}
function Get-LocalModulePath {
    $sep = [IO.Path]::PathSeparator
    
    $ModulePaths = $env:PSModulePath -split $sep | ForEach-Object { $_.Trim() } | Select-Object -Unique

    if ($IsWindows) {
        $MatchPattern = '\\Documents\\PowerShell\\Modules'
        $Result = $ModulePaths | Where-Object { $_ -match $MatchPattern } | Select-Object -First 1
        if ($Result -and (Test-Path $Result)) { 
            return $Result 
        } else { 
            throw "No windows module path matching $MatchPattern found" 
        }
    } else {
        # For Mac and Linux
        $MatchPattern = '/\.local/share/powershell/Modules$'
        $Result = $ModulePaths | Where-Object { $_ -match $MatchPattern } | Select-Object -First 1
        if ($Result -and (Test-Path $Result)) {
            return $Result 
        } else {
            throw "No macOS/Linux module path matching $MatchPattern found in PSModulePath."
        }
    }
}
function New-InitiateGitRepo {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory = $true)]
        [string]$DirectoryPath
    )

    # Check if Git is installed
    if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
        Write-Warning 'Git is not installed. Please install Git and initialize repo manually' 
        return
    }
    Push-Location -StackName 'GitInit'
    # Navigate to the specified directory
    Set-Location $DirectoryPath

    # Check if a Git repository already exists
    if (Test-Path -Path '.git') {
        Write-Warning 'A Git repository already exists in this directory.'
        return
    }
    if ($PSCmdlet.ShouldProcess($DirectoryPath, ("Initiating git on $DirectoryPath"))) {
        try {
            git init | Out-Null
        } catch {
            Write-Error 'Failed to initialize Git repo'
        }
    }
    Write-Verbose 'Git repository initialized successfully'
    Pop-Location -StackName 'GitInit'
}

function Read-AwesomeHost {
    [CmdletBinding()]
    param (
        [Parameter()]
        [pscustomobject]
        $Ask
    )
    ## For standard questions
    if ($null -eq $Ask.Choice) {
        do {
            $response = $Host.UI.Prompt($Ask.Caption, $Ask.Message, $Ask.Prompt)
        } while ($Ask.Default -eq 'MANDATORY' -and [string]::IsNullOrEmpty($response.Values))

        if ([string]::IsNullOrEmpty($response.Values)) {
            $result = $Ask.Default
        } else {
            $result = $response.Values
        }
    }
    ## For Choice based
    if ($Ask.Choice) {
        $Cs = @()
        $Ask.Choice.Keys | ForEach-Object {
            $Cs += New-Object System.Management.Automation.Host.ChoiceDescription "&$_", $($Ask.Choice.$_)
        }
        $options = [System.Management.Automation.Host.ChoiceDescription[]]($Cs)
        $IndexOfDefault = $Cs.Label.IndexOf('&' + $Ask.Default)
        $response = $Host.UI.PromptForChoice($Ask.Caption, $Ask.Message, $options, $IndexOfDefault)
        $result = $Cs.Label[$response] -replace '&'
    }
    return $result
}
function Reset-ProjectDist {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
    )
    $ErrorActionPreference = 'Stop'
    $data = Get-MTProjectInfo
    try {
        Write-Verbose 'Running dist folder reset'
        if (Test-Path $data.OutputDir) {
            Remove-Item -Path $data.OutputDir -Recurse -Force
        }
        # Setup Folders
        New-Item -Path $data.OutputDir -ItemType Directory -Force | Out-Null # Dist folder
        New-Item -Path $data.OutputModuleDir -Type Directory -Force | Out-Null # Module Folder
    } catch {
        Write-Error 'Failed to reset Dist folder'
    }
}
function Test-ProjectSchema {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('Build', 'Pester')]
        [string]
        $Schema
    )
    Write-Verbose "Running Schema test against using $Schema schema"
    $SchemaPath = @{
        Build  = "$PSScriptRoot\resources\Schema-Build.json"
        Pester = "$PSScriptRoot\resources\Schema-Pester.json"
    }
    $result = switch ($Schema) {
        'Build' { Test-Json -Path 'project.json' -Schema (Get-Content $SchemaPath.Build -Raw) -ErrorAction Stop }
        'Pester' { Test-Json -Path 'project.json' -Schema (Get-Content $SchemaPath.Pester -Raw) -ErrorAction Stop }
        Default { $false }
    }
    return $result
}
function Write-Message {
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline)]
        [string]
        $Text,
        [ValidateSet('Yello', 'Blue', 'Green')]
        [string]
        $color = 'Blue'
    )
    PROCESS {
        Write-Host $Text -ForegroundColor $color
    }
}