src/PowerShell/State.ps1

# cdp PowerShell domain: State.ps1
# Loaded by src/cdp.psm1; do not dot-source peer files.

function Get-CdpStatePath {
    if (-not [string]::IsNullOrWhiteSpace($env:CDP_STATE_PATH)) {
        return [Environment]::ExpandEnvironmentVariables($env:CDP_STATE_PATH)
    }

    Join-Path (Get-CdpUserHome) ".cdp\state.json"
}

function New-CdpState {
    [PSCustomObject]@{
        recentProjects = @()
    }
}

function Get-CdpState {
    $statePath = Get-CdpStatePath
    if (-not (Test-Path -LiteralPath $statePath)) {
        $script:CdpStateFingerprint = 'missing'
        $script:CdpStateWritable = $true
        return New-CdpState
    }

    try {
        $document = Read-CdpJsonDocument -LiteralPath $statePath
        $state = $document.Value

        if ($null -eq $state -or $state -is [array]) {
            $script:CdpStateFingerprint = $document.Fingerprint
            $script:CdpStateWritable = $false
            return New-CdpState
        }

        if ($state.PSObject.Properties.Name -notcontains 'recentProjects') {
            $state | Add-Member -MemberType NoteProperty -Name recentProjects -Value @()
        }

        if ($null -ne $state.recentProjects -and $state.recentProjects -isnot [System.Array]) {
            $script:CdpStateFingerprint = $document.Fingerprint
            $script:CdpStateWritable = $false
            return New-CdpState
        }

        $state.recentProjects = (ConvertTo-CdpJsonArrayValue -Value $state.recentProjects).Value
        $script:CdpStateFingerprint = $document.Fingerprint
        $script:CdpStateWritable = $true
        return $state
    } catch {
        $script:CdpStateFingerprint = Get-CdpFileFingerprint -LiteralPath $statePath
        $script:CdpStateWritable = $false
        return New-CdpState
    }
}

function Save-CdpState {
    param(
        [Parameter(Mandatory = $true)]
        [object]$State
    )

    $statePath = Get-CdpStatePath
    if (-not $script:CdpStateWritable) {
        throw "Refusing to overwrite an invalid cdp state document: $statePath"
    }
    $script:CdpStateFingerprint = Write-CdpJsonFile `
        -LiteralPath $statePath `
        -Value $State `
        -ExpectedFingerprint $script:CdpStateFingerprint
}

function Add-CdpRecentProject {
    param(
        [Parameter(Mandatory = $true)]
        [object]$Project,

        [Parameter(Mandatory = $false)]
        [int]$MaxCount = 20
    )

    try {
        $name = [string]$Project.name
        $rootPath = [string]$Project.rootPath
        if ([string]::IsNullOrWhiteSpace($name) -or [string]::IsNullOrWhiteSpace($rootPath)) {
            return
        }

        $state = Get-CdpState
        $recentProjects = @($state.recentProjects)
        $existing = @($recentProjects | Where-Object {
            [string]::Equals([string]$_.rootPath, $rootPath, [StringComparison]::Ordinal)
        }) | Select-Object -First 1

        $visitCount = 1
        if ($null -ne $existing -and $null -ne $existing.visitCount) {
            $visitCount = [int]$existing.visitCount + 1
        }

        $newEntry = [PSCustomObject]@{
            name = $name
            rootPath = $rootPath
            lastVisitedAt = [DateTimeOffset]::UtcNow.ToString("o")
            visitCount = $visitCount
        }
        if ($Project.PSObject.Properties['paths']) {
            $newEntry | Add-Member -NotePropertyName paths -NotePropertyValue $Project.paths
        }

        $state.recentProjects = @(
            @($recentProjects | Where-Object {
                -not [string]::Equals([string]$_.rootPath, $rootPath, [StringComparison]::Ordinal)
            }) + $newEntry |
                Sort-Object -Property @{
                    Expression = {
                        try {
                            [DateTimeOffset]::Parse([string]$_.lastVisitedAt)
                        } catch {
                            [DateTimeOffset]::MinValue
                        }
                    }
                } -Descending |
                Select-Object -First $MaxCount
        )

        Save-CdpState -State $state
    } catch {
        Write-Verbose "Failed to record recent project: $($_.Exception.Message)"
    }
}

function Get-CdpRecentProjects {
    <#
    .SYNOPSIS
        Show recently visited cdp projects.
 
    .DESCRIPTION
        Lists projects successfully opened through cdp, ordered by most recent
        visit. Recent state is stored separately from project configuration at
        ~/.cdp/state.json.
 
    .PARAMETER Count
        Maximum number of recent projects to display. Defaults to 10.
 
    .PARAMETER PassThru
        Returns recent project objects instead of only writing the table.
 
    .EXAMPLE
        cdp recent
        # Shows recently visited projects
    #>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false, Position = 0)]
        [int]$Count = 10,

        [Parameter(Mandatory = $false)]
        [switch]$PassThru
    )

    if ($Count -le 0) {
        $Count = 10
    }

    $state = Get-CdpState
    $recentProjects = @($state.recentProjects |
        Sort-Object -Property @{
            Expression = {
                try {
                    [DateTimeOffset]::Parse([string]$_.lastVisitedAt)
                } catch {
                    [DateTimeOffset]::MinValue
                }
            }
        } -Descending |
        Select-Object -First $Count)

    if ($PassThru) {
        return $recentProjects
    }

    if ($recentProjects.Count -eq 0) {
        Write-Host "No recent projects yet. Switch with cdp first." -ForegroundColor Yellow
        return
    }

    $nameWidth = 14
    foreach ($project in $recentProjects) {
        $projectName = [string]$project.name
        $nameWidth = [Math]::Max($nameWidth, $projectName.Length)
    }
    $nameWidth = [Math]::Min($nameWidth, 30)

    Write-Host "`ncdp recent " -ForegroundColor Cyan -NoNewline
    Write-Host "($($recentProjects.Count) shown)" -ForegroundColor DarkGray
    Write-Host ("-" * 110) -ForegroundColor DarkGray
    Write-Host (" {0,-4} " -f "#") -ForegroundColor DarkGray -NoNewline
    Write-Host (("{0,-$nameWidth} " -f "Project")) -ForegroundColor Cyan -NoNewline
    Write-Host ("{0,-24} " -f "Last used") -ForegroundColor DarkGray -NoNewline
    Write-Host ("{0,-7} " -f "Visits") -ForegroundColor DarkGray -NoNewline
    Write-Host "Path" -ForegroundColor DarkGray
    Write-Host ("-" * 110) -ForegroundColor DarkGray

    $index = 1
    foreach ($project in $recentProjects) {
        $number = "{0:00}" -f $index
        $projectName = Limit-CdpText -Text ([string]$project.name) -MaxLength $nameWidth
        $lastVisited = [string]$project.lastVisitedAt
        $visitCount = if ($null -eq $project.visitCount) { 1 } else { [int]$project.visitCount }

        Write-Host (" {0,-4} " -f $number) -ForegroundColor DarkGray -NoNewline
        Write-Host (("{0,-$nameWidth} " -f $projectName)) -ForegroundColor Green -NoNewline
        Write-Host ("{0,-24} " -f (Limit-CdpText -Text $lastVisited -MaxLength 24)) -ForegroundColor DarkGray -NoNewline
        Write-Host ("{0,-7} " -f $visitCount) -ForegroundColor Cyan -NoNewline
        $resolution = Resolve-CdpProjectPath -Project $project
        $pathText = if ($resolution.ErrorCode) { "<invalid $($resolution.Source)>" } else { $resolution.ResolvedPath }
        Write-Host $pathText -ForegroundColor DarkGray
        $index++
    }

    Write-Host ("-" * 110) -ForegroundColor DarkGray
    Write-Host "state: $(Get-CdpStatePath)" -ForegroundColor DarkGray
}

function Reset-CdpRecentProjects {
    <#
    .SYNOPSIS
        Clear cdp recent-project history.
 
    .DESCRIPTION
        Replaces only recentProjects in the cdp state document while preserving
        unknown top-level fields. Invalid state is never overwritten.
 
    .EXAMPLE
        Reset-CdpRecentProjects -WhatIf
        # Previews clearing recent-project history
    #>


    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param()

    $statePath = Get-CdpStatePath
    $state = Get-CdpState
    if (-not $script:CdpStateWritable) {
        return New-CdpActionResult -Action 'recent-reset' -Target $statePath `
            -Status 'failed' -Changed $false -Error 'invalid-state'
    }
    $recent = (ConvertTo-CdpJsonArrayValue -Value $state.recentProjects).Value
    if ($recent.Count -eq 0) {
        return New-CdpActionResult -Action 'recent-reset' -Target $statePath `
            -Status 'skipped' -Changed $false
    }
    if (-not $PSCmdlet.ShouldProcess($statePath, 'Clear recent project history')) {
        $status = if ($WhatIfPreference) { 'preview' } else { 'canceled' }
        return New-CdpActionResult -Action 'recent-reset' -Target $statePath `
            -Status $status -Changed $false
    }
    try {
        $state.recentProjects = [object[]]@()
        Save-CdpState -State $state
        New-CdpActionResult -Action 'recent-reset' -Target $statePath `
            -Status 'succeeded' -Changed $true
    } catch {
        New-CdpActionResult -Action 'recent-reset' -Target $statePath `
            -Status 'failed' -Changed $false -Error $_.Exception.Message
    }
}