Modules/businessdev.ALbuild.Apps/Public/Convert-BcCodeCoverage.ps1

function Convert-BcCodeCoverage {
    <#
    .SYNOPSIS
        Converts raw Business Central code coverage (.dat CSV from Invoke-BcContainerTest -CodeCoverage)
        into ALbuild JSON, Cobertura XML and/or a Markdown summary.
 
    .DESCRIPTION
        The raw rows are VariableText CSV: ObjectType, ObjectID, LineNo, CoverageStatus, NoOfHits. This
        aggregates them per object (max hit count per line - union semantics across chunks), attaches each
        object to its source file via the workspace AL object map, computes line coverage, and emits the
        requested formats. With -WorkspaceRoot the result is restricted to objects whose source is in the
        workspace (your app code); without it, every tracked object is included.
 
    .PARAMETER CoveragePath
        A folder of raw *.dat files (or a single .dat file) produced by the coverage-enabled test run.
 
    .PARAMETER WorkspaceRoot
        AL source root. Restricts/maps coverage to your app objects and sets file paths.
 
    .PARAMETER Format
        One or more of ALbuildJson, Cobertura, Markdown.
 
    .PARAMETER OutputFolder
        Where to write the output files. Default: the current location.
 
    .OUTPUTS
        PSCustomObject: Summary, Objects, Outputs (the written file paths).
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $CoveragePath,
        [string] $WorkspaceRoot,
        [ValidateSet('ALbuildJson', 'Cobertura', 'Markdown')] [string[]] $Format = @('ALbuildJson'),
        [ValidateSet('Auto', 'Source', 'CoveredOnly')] [string] $DenominatorMode = 'Auto',
        [string] $OutputFolder = (Get-Location).Path
    )

    function Get-Pct([int] $covered, [int] $total) { if ($total -le 0) { return 0.0 } return [math]::Round(($covered / $total) * 100, 2) }

    $datFiles = @(if (Test-Path -LiteralPath $CoveragePath -PathType Container) {
            Get-ChildItem -LiteralPath $CoveragePath -Filter '*.dat' -File -Recurse
        }
        else { Get-Item -LiteralPath $CoveragePath })
    if ($datFiles.Count -eq 0) { throw "No coverage .dat files found at '$CoveragePath'." }

    # Parse rows: Type, Id, LineNo, Status, Hits.
    $byObject = @{}
    foreach ($file in $datFiles) {
        foreach ($line in (Get-Content -LiteralPath $file.FullName -ErrorAction SilentlyContinue)) {
            $p = $line -split ','
            if ($p.Count -lt 5 -or $p[1] -notmatch '^\d+$' -or $p[2] -notmatch '^\d+$') { continue }
            $type = (Get-Culture).TextInfo.ToTitleCase($p[0].Trim().ToLowerInvariant())
            $id = [int]$p[1]; $lineNo = [int]$p[2]; $hits = [int]$p[4]
            $key = "$type`:$id"
            if (-not $byObject.ContainsKey($key)) { $byObject[$key] = @{ Type = $type; Id = $id; Lines = @{} } }
            $lines = $byObject[$key].Lines
            if (-not $lines.ContainsKey($lineNo) -or $hits -gt $lines[$lineNo]) { $lines[$lineNo] = $hits }
        }
    }

    $map = if ($WorkspaceRoot) { Get-BcAlObjectMap -WorkspaceRoot $WorkspaceRoot } else { @{} }
    $rootPath = if ($WorkspaceRoot) { (Resolve-Path -LiteralPath $WorkspaceRoot).Path } else { '' }

    # Honest denominator: BC's export lists only Covered/Partial lines, so the raw CSV always reads ~100%.
    # In Source mode (default when the object's AL source is in the workspace) the *total* executable lines come
    # from the source instead; covered lines come from the CSV; the union guarantees covered <= executable.
    $useSource = $DenominatorMode -ne 'CoveredOnly' -and [bool]$WorkspaceRoot
    $execCache = @{}

    $objects = [System.Collections.Generic.List[object]]::new()
    foreach ($key in ($byObject.Keys | Sort-Object)) {
        $o = $byObject[$key]
        $info = $map[$key]
        if ($WorkspaceRoot -and -not $info) { continue }   # only our app's objects when a workspace is given
        $coveredNos = @($o.Lines.Keys | Where-Object { $o.Lines[$_] -gt 0 } | Sort-Object)

        # Determine this object's executable-line universe (the denominator).
        $sourced = $false
        $execNos = $coveredNos
        if ($useSource -and $info -and $info.File -and (Test-Path -LiteralPath $info.File)) {
            if (-not $execCache.ContainsKey($info.File)) { $execCache[$info.File] = @(Get-BcAlExecutableLines -Path $info.File) }
            $srcExec = $execCache[$info.File]
            if ($srcExec.Count -gt 0) {
                $execNos = @(($srcExec + $coveredNos) | Sort-Object -Unique)   # union: covered is always counted
                $sourced = $true
            }
        }
        # CoveredOnly fallback (or no source): denominator is the CSV lines themselves.
        if (-not $sourced) { $execNos = @($o.Lines.Keys | Sort-Object) }

        $coveredSet = @{}; foreach ($n in $coveredNos) { $coveredSet[$n] = $true }
        $total = @($execNos).Count
        $covered = @($execNos | Where-Object { $coveredSet.ContainsKey($_) }).Count
        $relPath = if ($info -and $rootPath -and $info.File.StartsWith($rootPath)) { $info.File.Substring($rootPath.Length).TrimStart('\', '/').Replace('\', '/') } elseif ($info) { $info.File } else { '' }
        $objects.Add([PSCustomObject]@{
                objectType           = $o.Type
                objectId             = $o.Id
                objectName           = if ($info) { $info.Name } else { '' }
                filePath             = $relPath
                denominator          = if ($sourced) { 'Source' } else { 'CoveredOnly' }
                totalExecutableLines = $total
                coveredLines         = $covered
                uncoveredLines       = @($execNos | Where-Object { -not $coveredSet.ContainsKey($_) })
                lineCoverage         = Get-Pct $covered $total
                lines                = @($execNos | ForEach-Object {
                        $h = if ($o.Lines.ContainsKey($_)) { $o.Lines[$_] } else { 0 }
                        [PSCustomObject]@{ lineNo = $_; hits = $h; status = if ($coveredSet.ContainsKey($_)) { 'Covered' } else { 'NotCovered' } }
                    })
            })
    }

    $totalLines = ($objects | Measure-Object -Property totalExecutableLines -Sum).Sum
    $coveredLines = ($objects | Measure-Object -Property coveredLines -Sum).Sum
    if ($null -eq $totalLines) { $totalLines = 0 }; if ($null -eq $coveredLines) { $coveredLines = 0 }
    $sourcedCount = @($objects | Where-Object { $_.denominator -eq 'Source' }).Count
    $summary = [PSCustomObject]@{
        lineCoverage         = Get-Pct $coveredLines $totalLines
        coveredLines         = $coveredLines
        totalExecutableLines = $totalLines
        objectCount          = $objects.Count
        denominatorMode      = if ($sourcedCount -eq $objects.Count -and $objects.Count -gt 0) { 'Source' } elseif ($sourcedCount -gt 0) { 'Mixed' } else { 'CoveredOnly' }
        sourcedObjectCount   = $sourcedCount
    }

    New-Item -ItemType Directory -Force -Path $OutputFolder | Out-Null
    $outputs = [System.Collections.Generic.List[string]]::new()

    if ($Format -contains 'ALbuildJson') {
        $doc = [PSCustomObject]@{
            schemaVersion = '1.0'; tool = 'ALbuild'; operation = 'coverage.collect'
            createdAt     = (Get-Date).ToUniversalTime().ToString('o')
            summary       = $summary
            objects       = @($objects)
        }
        $path = Join-Path $OutputFolder 'coverage-summary.json'
        $doc | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $path -Encoding UTF8
        $outputs.Add($path)
    }
    if ($Format -contains 'Cobertura') {
        $path = Join-Path $OutputFolder 'cobertura.xml'
        Write-CoberturaXml -Objects $objects -Summary $summary -SourceRoot $rootPath -Path $path
        $outputs.Add($path)
    }
    if ($Format -contains 'Markdown') {
        $path = Join-Path $OutputFolder 'coverage.md'
        Write-CoverageMarkdown -Objects $objects -Summary $summary -Path $path
        $outputs.Add($path)
    }

    Write-ALbuildLog -Level Success ("Code coverage: {0}% ({1}/{2} lines across {3} object(s))." -f $summary.lineCoverage, $coveredLines, $totalLines, $objects.Count)
    return [PSCustomObject]@{ Summary = $summary; Objects = @($objects); Outputs = @($outputs) }
}