NovaModuleTools.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]
    }

    foreach ($boolKey in @('BuildRecursiveFolders', 'FailOnDuplicateFunctionNames', 'SetSourcePath')) {
        if (-not $Out.ContainsKey($boolKey)) {
            $Out[$boolKey] = $true
            continue
        }

        $Out[$boolKey] = [bool]$Out[$boolKey]
    }

    $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['TestsDir'] = [System.IO.Path]::Join($ProjectRoot, 'tests')
    $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

    $data = Get-MTProjectInfo
    if ($data.FailOnDuplicateFunctionNames) {
        Assert-BuiltModuleHasNoDuplicateFunctionName -ProjectInfo $data
    }

    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 = if ($data.BuildRecursiveFolders) {
        $data.TestsDir
    }
    else {
        [System.IO.Path]::Join($data.TestsDir, '*.Tests.ps1')
    }

    $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 = 'NovaModuleTools 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 (Get-ResourceFilePath -FileName '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 Add-ScriptFileContentToModuleBuilder {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][System.Text.StringBuilder]$Builder,
        [Parameter(Mandatory)][pscustomobject]$ProjectInfo,
        [Parameter(Mandatory)][System.IO.FileInfo]$File
    )

    if ($ProjectInfo.SetSourcePath) {
        $relativePath = Get-NormalizedRelativePath -Root $ProjectInfo.ProjectRoot -FullName $File.FullName
        $Builder.AppendLine("# Source: $relativePath") | Out-Null
    }

    $Builder.AppendLine([IO.File]::ReadAllText($File.FullName)) | Out-Null
    $Builder.AppendLine() | Out-Null
}

function Assert-BuiltModuleHasNoDuplicateFunctionName {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][pscustomobject]$ProjectInfo
    )

    $psm1Path = $ProjectInfo.ModuleFilePSM1
    if (-not (Test-Path -LiteralPath $psm1Path)) {
        throw "Built module file not found: $psm1Path"
    }

    $parsed = Get-PowerShellAstFromFile -Path $psm1Path
    if ($parsed.Errors -and $parsed.Errors.Count -gt 0) {
        $messages = @($parsed.Errors | ForEach-Object { $_.Message }) -join '; '
        throw "Built module contains parse errors and cannot be validated for duplicates. File: $psm1Path. Errors: $messages"
    }

    $topLevelFunctions = Get-TopLevelFunctionAst -Ast $parsed.Ast
    $duplicates = Get-DuplicateFunctionGroup -FunctionAst $topLevelFunctions

    if (-not $duplicates) {
        return
    }

    $sourceFiles = Get-ProjectScriptFile -ProjectInfo $ProjectInfo
    $sourceIndex = Get-FunctionSourceIndex -File $sourceFiles

    $errorText = Format-DuplicateFunctionErrorMessage -Psm1Path $psm1Path -DuplicateGroup $duplicates -SourceIndex $sourceIndex
    throw $errorText
}

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()

    $files = Get-ProjectScriptFile -ProjectInfo $data
    foreach ($file in $files) {
        Add-ScriptFileContentToModuleBuilder -Builder $sb -ProjectInfo $data -File $file
    }
    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
            }
        }
    }
}
function Format-DuplicateFunctionErrorMessage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Psm1Path,
        [Parameter(Mandatory)][object[]]$DuplicateGroup,
        [hashtable]$SourceIndex
    )

    $lines = New-Object 'System.Collections.Generic.List[string]'
    $lines.Add("Duplicate top-level function names detected in built module: $Psm1Path")

    foreach ($dup in ($DuplicateGroup | Sort-Object -Property Name)) {
        $key = '' + $dup.Name
        $displayName = $dup.Group[0].Name

        $lines.Add('')
        $lines.Add("- $displayName")

        foreach ($occurrence in ($dup.Group | Sort-Object { $_.Extent.StartLineNumber })) {
            $lines.Add((" - dist line {0}" -f $occurrence.Extent.StartLineNumber))
        }

        foreach ($sourceLine in (Get-DuplicateFunctionSourceLine -Key $key -SourceIndex $SourceIndex)) {
            $lines.Add($sourceLine)
        }
    }

    return ($lines -join "`n")
}

<#
.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-DuplicateFunctionGroup {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][System.Management.Automation.Language.FunctionDefinitionAst[]]$FunctionAst
    )

    return @(
        $FunctionAst |
            Group-Object -Property { ('' + $_.Name).ToLowerInvariant() } |
            Where-Object { $_.Count -gt 1 }
    )
}

function Get-DuplicateFunctionSourceLine {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Key,
        [hashtable]$SourceIndex
    )

    if (-not $SourceIndex) {
        return @()
    }

    if (-not $SourceIndex.ContainsKey($Key)) {
        return @()
    }

    $lines = New-Object 'System.Collections.Generic.List[string]'
    $lines.Add(' - source files:')

    foreach ($src in ($SourceIndex[$Key] | Sort-Object Path, Line)) {
        $lines.Add((" - {0}:{1}" -f $src.Path, $src.Line))
    }

    return @($lines)
}

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-IndexableSourceFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][object[]]$File
    )

    return @($File | Where-Object { $_ -and -not [string]::IsNullOrWhiteSpace($_.FullName) })
}

function Get-IndexableFunctionAstFromFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path
    )

    return @(Get-TopLevelFunctionAstFromFile -Path $Path | Where-Object { $_ -and -not [string]::IsNullOrWhiteSpace($_.Name) })
}

function Add-FunctionSourceIndexEntry {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Index,
        [Parameter(Mandatory)][System.IO.FileInfo]$File,
        [Parameter(Mandatory)][System.Management.Automation.Language.FunctionDefinitionAst]$FunctionAst
    )

    $key = ('' + $FunctionAst.Name).ToLowerInvariant()
    $list = Get-OrCreateHashtableList -Index $Index -Key $key
    $list.Add([pscustomobject]@{
            Path = $File.FullName
            Line = $FunctionAst.Extent.StartLineNumber
        })
}

function Add-FunctionSourceIndexEntryFromFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Index,
        [Parameter(Mandatory)][System.IO.FileInfo]$File
    )

    foreach ($fn in (Get-IndexableFunctionAstFromFile -Path $File.FullName)) {
        Add-FunctionSourceIndexEntry -Index $Index -File $File -FunctionAst $fn
    }
}

function Get-FunctionSourceIndex {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][System.IO.FileInfo[]]$File
    )

    $index = @{}

    foreach ($f in (Get-IndexableSourceFile -File $File)) {
        Add-FunctionSourceIndexEntryFromFile -Index $index -File $f
    }

    return $index
}

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 Get-NormalizedRelativePath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Root,
        [Parameter(Mandatory)][string]$FullName
    )

    $rel = [System.IO.Path]::GetRelativePath($Root, $FullName)
    $rel = $rel -replace '\\', '/'
    return $rel
}

function Get-OrCreateHashtableList {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Index,
        [Parameter(Mandatory)][string]$Key
    )

    if (-not $Index.ContainsKey($Key)) {
        $Index[$Key] = New-Object 'System.Collections.Generic.List[object]'
    }

    return $Index[$Key]
}

function Get-OrderedScriptFileForDirectory {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Directory,
        [Parameter(Mandatory)][string]$ProjectRoot,
        [Parameter(Mandatory)][bool]$Recurse
    )

    if (-not (Test-Path -LiteralPath $Directory)) {
        return @()
    }

    $items = if ($Recurse) {
        Get-ChildItem -Path $Directory -Filter '*.ps1' -File -Recurse -ErrorAction SilentlyContinue
    }
    else {
        Get-ChildItem -Path $Directory -Filter '*.ps1' -File -ErrorAction SilentlyContinue
    }

    $root = $ProjectRoot

    return @(
        $items |
            Sort-Object -Stable -Property @(
                @{ Expression = { (Get-NormalizedRelativePath -Root $root -FullName $_.FullName).ToLowerInvariant() } },
                @{ Expression = { $_.FullName.ToLowerInvariant() } }
            )
    )
}

function Get-PowerShellAstFromFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path
    )

    $tokens = $null
    $errors = $null
    $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors)

    return [pscustomobject]@{
        Ast = $ast
        Errors = $errors
    }
}

function Get-ProjectScriptFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][pscustomobject]$ProjectInfo
    )

    $recurse = [bool]$ProjectInfo.BuildRecursiveFolders

    $ordered = New-Object 'System.Collections.Generic.List[System.IO.FileInfo]'

    $root = $ProjectInfo.ProjectRoot

    foreach ($f in (Get-OrderedScriptFileForDirectory -Directory $ProjectInfo.ClassesDir -ProjectRoot $root -Recurse:$recurse)) {
        $ordered.Add($f)
    }

    foreach ($f in (Get-OrderedScriptFileForDirectory -Directory $ProjectInfo.PublicDir -ProjectRoot $root -Recurse:$false)) {
        $ordered.Add($f)
    }

    foreach ($f in (Get-OrderedScriptFileForDirectory -Directory $ProjectInfo.PrivateDir -ProjectRoot $root -Recurse:$recurse)) {
        $ordered.Add($f)
    }

    return @($ordered)
}

function Get-ResourceFilePath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$FileName
    )

    $candidates = @(
        [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "resources/$FileName")),
        [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "../resources/$FileName"))
    ) | Select-Object -Unique

    foreach ($candidate in $candidates) {
        if (Test-Path -LiteralPath $candidate) {
            return $candidate
        }
    }

    throw "Resource file not found: $FileName. Checked: $($candidates -join ', ')"
}


function Get-TopLevelFunctionAst {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][System.Management.Automation.Language.Ast]$Ast
    )

    $all = @($Ast.FindAll({
                param($n)
                $n -is [System.Management.Automation.Language.FunctionDefinitionAst]
            }, $true))

    $top = foreach ($candidate in $all) {
        $nested = $false
        foreach ($other in $all) {
            if ($other -eq $candidate) { continue }

            if ($other.Extent.StartOffset -lt $candidate.Extent.StartOffset -and $other.Extent.EndOffset -gt $candidate.Extent.EndOffset) {
                $nested = $true
                break
            }
        }

        if (-not $nested) {
            $candidate
        }
    }

    return @($top)
}

function Get-TopLevelFunctionAstFromFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path
    )

    $parsed = Get-PowerShellAstFromFile -Path $Path
    if ($parsed.Errors -and $parsed.Errors.Count -gt 0) {
        return @()
    }

    return @(Get-TopLevelFunctionAst -Ast $parsed.Ast)
}

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  = Get-ResourceFilePath -FileName 'Schema-Build.json'
        Pester = Get-ResourceFilePath -FileName '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
    }
}