Private/Format-DFToolInfo.ps1

#Requires -Version 7.0

# Pure renderers for DotForge.ToolInfo — same "pure function + [bool]$Color"
# pattern as Format-DFCliHelpText. Find-DFPackage decides card vs table vs raw
# objects; these functions only turn objects into strings.

function Format-DFToolInfoAge {
    <#
    .SYNOPSIS
        Formats a cache age in minutes as a compact '5m' / '3h' / '2d' string.
    .PARAMETER Minutes
        Age in minutes.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [int]$Minutes
    )

    if ($Minutes -lt 1) { return '<1m' }
    if ($Minutes -lt 60) { return "${Minutes}m" }
    if ($Minutes -lt 1440) { return "$([math]::Round($Minutes / 60))h" }
    "$([math]::Round($Minutes / 1440))d"
}

function Format-DFToolInfoCard {
    <#
    .SYNOPSIS
        Renders a single DotForge.ToolInfo as a rich info card (string array).
    .PARAMETER Info
        The merged tool info object.
    .PARAMETER Color
        When false, plain text (NO_COLOR / non-VT passthrough).
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)]
        $Info,

        [Parameter(Mandatory)]
        [bool]$Color
    )

    $bold   = $Color ? "`e[1;36m" : ''
    $faint  = $Color ? "`e[2m"    : ''
    $green  = $Color ? "`e[32m"   : ''
    $reset  = $Color ? "`e[0m"    : ''

    $label = { param($text) "$faint$($text.PadRight(9))$reset " }

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

    $title = "$bold$($Info.Name)$reset"
    if ($Info.Description) { $title += " — $($Info.Description)" }
    $lines.Add($title)
    $lines.Add([string]('─' * 60))

    if ($Info.Installed) {
        $version = $Info.InstalledVersion ? " ($($Info.InstalledVersion))" : ''
        $lines.Add((& $label 'Installed') + "$green✓$reset $($Info.InstalledVia -join ', ')$version")
    } else {
        $lines.Add((& $label 'Installed') + '✗ not installed')
    }

    $sources = @($Info.Sources)
    if ($sources) {
        $srcW = ($sources.Source | Measure-Object Length -Maximum).Maximum
        $idW  = ($sources.PackageId | Measure-Object Length -Maximum).Maximum
        $indent = ' ' * 11
        for ($i = 0; $i -lt $sources.Count; $i++) {
            $s = $sources[$i]
            $prefix = $i -eq 0 ? (& $label 'Sources') : $indent
            $version = $s.LatestVersion ? "$green$($s.LatestVersion)$reset" : ''
            $lines.Add("$prefix$($s.Source.PadRight($srcW)) $($s.PackageId.PadRight($idW)) $version")
        }
    }

    if ($Info.Homepage) { $lines.Add((& $label 'Homepage') + $Info.Homepage) }
    if ($Info.License)  { $lines.Add((& $label 'License') + $Info.License) }

    $updated = @($sources | Where-Object PublishedAt | ForEach-Object {
        "$($_.Source) $($_.PublishedAt.ToString('yyyy-MM-dd'))"
    })
    if ($updated) { $lines.Add((& $label 'Updated') + ($updated -join ' · ')) }

    $ages = @($sources | ForEach-Object {
        "$($_.Source) $(Format-DFToolInfoAge -Minutes $_.CacheAgeMinutes)"
    })
    if ($ages) { $lines.Add((& $label 'Cache') + ($ages -join ' · ')) }

    $lines
}

function Format-DFToolDetailCount {
    <#
    .SYNOPSIS
        Formats a count as a compact '998' / '12.3k' / '1.2M' string.
    .PARAMETER Count
        The raw count.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [long]$Count
    )

    if ($Count -lt 1000) { return [string]$Count }
    $k = [math]::Round($Count / 1000, 1)
    if ($k -lt 1000) { return "${k}k" }
    "$([math]::Round($Count / 1000000, 1))M"
}

function Format-DFToolDetailCard {
    <#
    .SYNOPSIS
        Renders a DotForge.ToolInfo WITH populated Details/GitHub as the full
        detail card: the basic card followed by Publisher / Deps / Tags /
        Downloads / Install / Notes / GitHub sections and an optional
        more-matches footer. Sections without data are omitted.
    .PARAMETER Info
        The merged tool info (Details: ordered dict source → detail, values
        may be $null for failed fetches; GitHub: DotForge.RepoInfo or $null).
    .PARAMETER Color
        When false, plain text.
    .PARAMETER MoreMatches
        Count of suppressed keyword matches (renders the -All footer when > 0).
    .PARAMETER QueryText
        The original query, for the footer's suggested command.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)]
        $Info,

        [Parameter(Mandatory)]
        [bool]$Color,

        [int]$MoreMatches = 0,

        [string]$QueryText = ''
    )

    $faint = $Color ? "`e[2m" : ''
    $reset = $Color ? "`e[0m" : ''
    $label = { param($text) ($Color ? "`e[2m" : '') + $text.PadRight(9) + ($Color ? "`e[0m" : '') + ' ' }
    $indent = ' ' * 11

    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.AddRange([string[]](Format-DFToolInfoCard -Info $Info -Color $Color))

    $details = @()
    $failed = @()
    if ($Info.Details) {
        foreach ($key in $Info.Details.Keys) {
            if ($null -ne $Info.Details[$key]) { $details += $Info.Details[$key] }
            else { $failed += $key }
        }
    }

    $publisher = @($details | ForEach-Object { $_.Publisher } | Where-Object { $_ }) | Select-Object -First 1
    if (-not $publisher) {
        $publisher = @($details | ForEach-Object { @($_.Maintainers) -join ', ' } | Where-Object { $_ }) | Select-Object -First 1
    }
    if ($publisher) { $lines.Add((& $label 'Publisher') + $publisher) }

    $first = $true
    foreach ($d in $details) {
        $deps = @($d.Dependencies) -ne '' -ne $null
        if (-not $deps) { continue }
        $shown = @($deps | Select-Object -First 8)
        $suffix = $deps.Count -gt 8 ? " +$($deps.Count - 8) more" : ''
        $prefix = $first ? (& $label 'Deps') : $indent
        $lines.Add("$prefix$($d.Source): $($shown -join ', ')$suffix")
        $first = $false
    }

    $tags = @($details | ForEach-Object { @($_.Tags) } | Where-Object { $_ } | Select-Object -Unique -First 10)
    if ($tags) { $lines.Add((& $label 'Tags') + ($tags -join ' · ')) }

    $downloads = @($details | Where-Object { $_.Downloads } | ForEach-Object {
        "$($_.Source) $(Format-DFToolDetailCount -Count $_.Downloads)"
    })
    if ($downloads) { $lines.Add((& $label 'Downloads') + ($downloads -join ' · ')) }

    $hints = @($details | ForEach-Object { $_.InstallHint } | Where-Object { $_ })
    for ($i = 0; $i -lt $hints.Count; $i++) {
        $lines.Add(($i -eq 0 ? (& $label 'Install') : $indent) + $hints[$i])
    }

    $notes = @($details | ForEach-Object { $_.Notes } | Where-Object { $_ }) | Select-Object -First 1
    if ($notes) { $lines.Add((& $label 'Notes') + $notes) }

    if ($Info.Category) {
        $cat = $Info.Category.Entry
        $catParts = [System.Collections.Generic.List[string]]::new()
        $catParts.AddRange([string[]]@($cat.function))
        if ($cat.worksWith) { $catParts.Add("works with: $(@($cat.worksWith) -join ', ')") }
        $lines.Add((& $label 'Category') + ($catParts -join ' · '))

        $related = @($Info.Category.Related)
        if ($related) { $lines.Add((& $label 'Related') + ($related -join ' · ')) }

        if ($cat.alternativeTo) { $lines.Add((& $label 'Alt to') + (@($cat.alternativeTo) -join ', ')) }
    }

    if ($Info.GitHub) {
        $gh = $Info.GitHub
        $parts = [System.Collections.Generic.List[string]]::new()
        $parts.Add("★ $(Format-DFToolDetailCount -Count $gh.Stars)")
        if ($gh.PushedAt) { $parts.Add("updated $($gh.PushedAt.ToString('yyyy-MM-dd'))") }
        if ($gh.LatestRelease) {
            $when = $gh.LatestReleaseAt ? " ($($gh.LatestReleaseAt.ToString('yyyy-MM-dd')))" : ''
            $parts.Add("release $($gh.LatestRelease)$when")
        }
        $parts.Add("$(Format-DFToolDetailCount -Count $gh.OpenIssues) issues")
        if ($gh.Archived) { $parts.Add('ARCHIVED') }
        $lines.Add((& $label 'GitHub') + ($parts -join ' · '))
        if ($gh.Description) { $lines.Add($indent + "$faint$($gh.Description)$reset") }
    }

    if ($failed) {
        $lines.Add("$faint(details unavailable: $($failed -join ', '))$reset")
    }

    if ($MoreMatches -gt 0) {
        $lines.Add("$faint+ $MoreMatches more matches — trifle $QueryText -All$reset")
    }

    $lines
}

function Format-DFToolInfoTable {
    <#
    .SYNOPSIS
        Renders multiple DotForge.ToolInfo objects as a compact match table
        (string array), width-truncated.
    .PARAMETER Infos
        The merged tool info objects.
    .PARAMETER Color
        When false, plain text.
    .PARAMETER Width
        Maximum row width (defaults to 120).
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$Infos,

        [Parameter(Mandatory)]
        [bool]$Color,

        [int]$Width = 120
    )

    $faint = $Color ? "`e[2m" : ''
    $green = $Color ? "`e[32m" : ''
    $reset = $Color ? "`e[0m" : ''

    $nameW = [math]::Min(25, [math]::Max(4, (@($Infos.Name) + 'Name' | Measure-Object Length -Maximum).Maximum))
    $idStrings = @($Infos | ForEach-Object {
        $s = @($_.Sources) | Select-Object -First 1
        $s ? "$($s.Source):$($s.PackageId)" : ''
    })
    $idW = [math]::Min(34, [math]::Max(2, (@($idStrings) + 'Id' | Measure-Object Length -Maximum).Maximum))
    $srcStrings = @($Infos | ForEach-Object { @($_.Sources.Source) -join ',' })
    $srcW = [math]::Min(30, [math]::Max(7, (@($srcStrings) + 'Sources' | Measure-Object Length -Maximum).Maximum))
    $verW = 12

    $rows = [System.Collections.Generic.List[string]]::new()
    $rows.Add("$faint$('Name'.PadRight($nameW)) In $('Sources'.PadRight($srcW)) $('Id'.PadRight($idW)) $('Latest'.PadRight($verW)) Description$reset")

    for ($i = 0; $i -lt $Infos.Count; $i++) {
        $info = $Infos[$i]
        $name = $info.Name.Length -gt $nameW ? $info.Name.Substring(0, $nameW) : $info.Name.PadRight($nameW)
        $inst = $info.Installed ? "$green✓$reset " : ' '
        $src = $srcStrings[$i]
        $src = $src.Length -gt $srcW ? $src.Substring(0, $srcW) : $src.PadRight($srcW)
        $id = $idStrings[$i]
        $id = $id.Length -gt $idW ? $id.Substring(0, $idW) : $id.PadRight($idW)
        $latest = ''
        if ($info.Latest -and $info.Latest.Count -gt 0) {
            $latest = [string]@($info.Latest.Values)[0]
        }
        $latest = $latest.Length -gt $verW ? $latest.Substring(0, $verW) : $latest.PadRight($verW)

        $row = "$name $inst $src $id $latest $($info.Description)"
        if ($row.Length -gt $Width) { $row = $row.Substring(0, $Width) }
        $rows.Add($row)
    }

    $rows
}