TauriCraft.psm1

#!/usr/bin/env pwsh
using namespace System.Collections.Generic
using namespace System.Management.Automation

#Requires -Modules cliHelper.logger, PsModuleBase

#region Enums and Classes
enum FrameworkType {
  Vite = 0
  Next = 1
  SvelteKit = 2
}

# Target OS enum for GitHub Actions
enum TargetOS {
  Windows = 0
  MacOS = 1
  Linux = 2
}
class Framework {
  [string] $Name
  [string] $Display
  [string] $Color

  Framework([string]$name, [string]$display, [string]$color) {
    $this.Name = $name
    $this.Display = $display
    $this.Color = $color
  }
}

class TargetOSConfig {
  [string] $Title
  [string] $Value
  [bool] $Selected

  TargetOSConfig([string]$title, [string]$value, [bool]$selected = $true) {
    $this.Title = $title
    $this.Value = $value
    $this.Selected = $selected
  }
}

class ProjectConfig {
  [string] $ProjectName
  [string] $PackageName
  [Framework] $Framework
  [string[]] $ReleaseOS
  [bool] $Overwrite
  [string] $TargetDirectory
  [string] $PackageManager

  ProjectConfig() {
    $this.ReleaseOS = @()
    $this.Overwrite = $false
    $this.PackageManager = "npm"
  }
}

# Main class
class TauriCraft : PsModuleBase {
  static [object] $Logger = $null

  static [Framework[]] $Frameworks = @(
    [Framework]::new("vite", "⚡Vite + React", "Blue"),
    [Framework]::new("next", "▲ Next.js", "Blue"),
    [Framework]::new("sveltekit", "⚡Vite + SvelteKit", "Blue")
  )

  static [TargetOSConfig[]] $TargetOperatingSystems = @(
    [TargetOSConfig]::new("Windows (x64)", "windows-latest", $true),
    [TargetOSConfig]::new("macOS (x64)", "macos-latest", $true),
    [TargetOSConfig]::new("Linux (x64)", "ubuntu-latest", $true)
  )

  static [hashtable] $RenameFiles = @{
    "_gitignore" = ".gitignore"
  }

  static [string] $DefaultTargetDir = "tauri-ui"

  # Initialize logger
  static [void] InitializeLogger() {
    if ($null -eq [TauriCraft]::Logger) {
      [TauriCraft]::Logger = New-Logger -Level 1
      [TauriCraft]::Logger.LogInfoLine("TauriCraft logger initialized")
    }
  }

  # Static method to get available templates
  static [string[]] GetTemplates() {
    [TauriCraft]::InitializeLogger()
    [TauriCraft]::Logger.LogDebugLine("Getting available templates")
    return [TauriCraft]::Frameworks | ForEach-Object { $_.Name }
  }

  # Static method to get available OS targets
  static [string[]] GetAllOSTargets() {
    [TauriCraft]::InitializeLogger()
    [TauriCraft]::Logger.LogDebugLine("Getting available OS targets")
    return [TauriCraft]::TargetOperatingSystems | ForEach-Object { $_.Value }
  }

  # Main scaffolding method
  static [void] CreateProject([ProjectConfig] $config) {
    [TauriCraft]::CreateProject($config, $PSScriptRoot)
  }

  # Main scaffolding method with module root
  static [void] CreateProject([ProjectConfig] $config, [string] $moduleRoot) {
    [TauriCraft]::InitializeLogger()
    [TauriCraft]::Logger.LogInfoLine("Starting TauriCraft project creation")
    [TauriCraft]::Logger.LogInfoLine("Project: $($config.ProjectName), Framework: $($config.Framework.Name)")

    try {
      [TauriCraft]::ValidateConfig($config)
      [TauriCraft]::SetupProjectDirectory($config)
      [TauriCraft]::CopyTemplateFiles($config, $moduleRoot)
      [TauriCraft]::ProcessConfigurationFiles($config, $moduleRoot)
      [TauriCraft]::ShowCompletionInstructions($config)

      [TauriCraft]::Logger.LogInfoLine("Project creation completed successfully")
    } catch {
      [TauriCraft]::Logger | Write-LogEntry -Level Error -Message "Project creation failed" -Exception $_.Exception
      throw
    }
  }

  # Validation method
  static [void] ValidateConfig([ProjectConfig] $config) {
    [TauriCraft]::Logger.LogDebugLine("Validating project configuration")

    if ([string]::IsNullOrWhiteSpace($config.ProjectName)) {
      [TauriCraft]::Logger.LogErrorLine("Project name validation failed: empty or null")
      throw "Project name cannot be empty"
    }

    if ([string]::IsNullOrWhiteSpace($config.PackageName)) {
      [TauriCraft]::Logger.LogErrorLine("Package name validation failed: empty or null")
      throw "Package name cannot be empty"
    }

    if ($null -eq $config.Framework) {
      [TauriCraft]::Logger.LogErrorLine("Framework validation failed: null framework")
      throw "Framework must be specified"
    }

    $validTemplates = [TauriCraft]::GetTemplates()
    if ($config.Framework.Name -notin $validTemplates) {
      [TauriCraft]::Logger.LogErrorLine("Framework validation failed: $($config.Framework.Name) not in valid templates")
      throw "Invalid framework: $($config.Framework.Name). Valid options: $($validTemplates -join ', ')"
    }

    [TauriCraft]::Logger.LogInfoLine("Configuration validation passed")
  }

  # Directory setup method
  static [void] SetupProjectDirectory([ProjectConfig] $config) {
    $projectRoot = Join-Path (Get-Location) $config.TargetDirectory
    [TauriCraft]::Logger.LogInfoLine("Setting up project directory: $projectRoot")

    if ([IO.Directory]::Exists($projectRoot)) {
      [TauriCraft]::Logger.LogDebugLine("Target directory already exists")
      if ($config.Overwrite) {
        [TauriCraft]::Logger.LogWarnLine("Overwriting existing directory contents")
        [TauriCraft]::EmptyDirectory($projectRoot)
      } elseif (![TauriCraft]::IsDirectoryEmpty($projectRoot)) {
        [TauriCraft]::Logger.LogErrorLine("Target directory is not empty and overwrite not specified")
        throw "Target directory '$projectRoot' is not empty. Use -Force to overwrite."
      }
    } else {
      [TauriCraft]::Logger.LogDebugLine("Creating new project directory")
      New-Item -Path $projectRoot -ItemType Directory -Force | Out-Null
    }

    $config.TargetDirectory = $projectRoot
    [TauriCraft]::Logger.LogInfoLine("Project directory setup completed")
  }

  # Template copying method
  static [void] CopyTemplateFiles([ProjectConfig] $config, [string] $moduleRoot) {
    $templateDir = Join-Path $moduleRoot "Private\templates\$($config.Framework.Name)"
    $sharedDir = Join-Path $moduleRoot "Private\templates\.shared"

    [TauriCraft]::Logger.LogInfoLine("Starting template file copying")
    [TauriCraft]::Logger.LogDebugLine("Template directory: $templateDir")
    [TauriCraft]::Logger.LogDebugLine("Shared directory: $sharedDir")

    if (![IO.Directory]::Exists($templateDir)) {
      [TauriCraft]::Logger.LogErrorLine("Template directory not found: $templateDir")
      throw "Template directory not found: $templateDir"
    }

    Write-Host "Scaffolding project in $($config.TargetDirectory)" -ForegroundColor Gray
    [TauriCraft]::Logger.LogInfoLine("Scaffolding project in $($config.TargetDirectory)")

    # Copy template files (excluding configuration files that will be processed separately)
    $templateFiles = Get-ChildItem $templateDir -Recurse -File | Where-Object {
      $_.Name -notin @("package.json", "tauri.conf.json", "Cargo.toml")
    }

    [TauriCraft]::Logger.LogInfoLine("Copying $($templateFiles.Count) template files")

    foreach ($file in $templateFiles) {
      $relativePath = $file.FullName.Substring($templateDir.Length + 1)
      $targetName = [TauriCraft]::RenameFiles[$file.Name]
      if ([string]::IsNullOrEmpty($targetName)) {
        $targetName = $file.Name
        $targetPath = Join-Path $config.TargetDirectory $relativePath
      } else {
        $targetPath = Join-Path $config.TargetDirectory $targetName
      }

      $targetDir = Split-Path $targetPath -Parent
      if (![IO.Directory]::Exists($targetDir)) {
        New-Item -Path $targetDir -ItemType Directory -Force | Out-Null
      }

      [TauriCraft]::Logger.LogDebugLine("Copying: $relativePath -> $targetPath")
      Copy-Item $file.FullName $targetPath -Force
    }

    # Copy shared files (except for SvelteKit)
    if ($config.Framework.Name -ne "sveltekit" -and [IO.Directory]::Exists($sharedDir)) {
      [TauriCraft]::Logger.LogInfoLine("Copying shared files from $sharedDir")
      $sharedFiles = Get-ChildItem $sharedDir -Recurse
      [TauriCraft]::Logger.LogDebugLine("Found $($sharedFiles.Count) shared files/directories")

      foreach ($file in $sharedFiles) {
        $relativePath = $file.FullName.Substring($sharedDir.Length + 1)
        $targetPath = Join-Path $config.TargetDirectory $relativePath
        $targetDir = Split-Path $targetPath -Parent

        if (![IO.Directory]::Exists($targetDir)) {
          New-Item -Path $targetDir -ItemType Directory -Force | Out-Null
        }

        if ($file.PSIsContainer) {
          if (![IO.Directory]::Exists($targetPath)) {
            [TauriCraft]::Logger.LogDebugLine("Creating directory: $relativePath")
            New-Item -Path $targetPath -ItemType Directory -Force | Out-Null
          }
        } else {
          [TauriCraft]::Logger.LogDebugLine("Copying shared file: $relativePath")
          Copy-Item $file.FullName $targetPath -Force
        }
      }
    } else {
      [TauriCraft]::Logger.LogDebugLine("Skipping shared files for SvelteKit or shared directory not found")
    }

    [TauriCraft]::Logger.LogInfoLine("Template file copying completed")
  }

  # Configuration file processing method
  static [void] ProcessConfigurationFiles([ProjectConfig] $config, [string] $moduleRoot) {
    $templateDir = Join-Path $moduleRoot "Private\templates\$($config.Framework.Name)"
    [TauriCraft]::Logger.LogInfoLine("Processing configuration files")

    # Process package.json
    [TauriCraft]::ProcessPackageJson($config, $templateDir)

    # Process tauri.conf.json
    [TauriCraft]::ProcessTauriConfig($config, $templateDir)

    # Process Cargo.toml
    [TauriCraft]::ProcessCargoToml($config, $moduleRoot)

    # Process GitHub Actions release.yml
    [TauriCraft]::ProcessReleaseWorkflow($config, $moduleRoot)

    [TauriCraft]::Logger.LogInfoLine("Configuration file processing completed")
  }

  # Package.json processing
  static [void] ProcessPackageJson([ProjectConfig] $config, [string] $templateDir) {
    $packageJsonPath = [IO.Path]::Combine($templateDir, "package.json")
    [TauriCraft]::Logger.LogDebugLine("Processing package.json from: $packageJsonPath")

    if ([IO.Path]::Exists($packageJsonPath)) {
      $packageJson = Get-Content $packageJsonPath -Raw | ConvertFrom-Json
      $packageJson.name = $config.PackageName

      $targetPath = Join-Path $config.TargetDirectory "package.json"
      $packageJson | ConvertTo-Json -Depth 10 | Set-Content $targetPath -Encoding UTF8
      [TauriCraft]::Logger.LogInfoLine("Updated package.json with name: $($config.PackageName)")
    } else {
      [TauriCraft]::Logger.LogWarnLine("package.json not found in template directory")
    }
  }

  # Tauri configuration processing
  static [void] ProcessTauriConfig([ProjectConfig] $config, [string] $templateDir) {
    $tauriConfigPath = Join-Path $templateDir "src-tauri\tauri.conf.json"
    [TauriCraft]::Logger.LogDebugLine("Processing tauri.conf.json from: $tauriConfigPath")

    if ([IO.Path]::Exists($tauriConfigPath)) {
      $tauriConfig = Get-Content $tauriConfigPath -Raw | ConvertFrom-Json

      # Update the product name and window title based on the actual structure
      if ($tauriConfig.productName) {
        $tauriConfig.productName = $config.PackageName
        [TauriCraft]::Logger.LogDebugLine("Updated productName to: $($config.PackageName)")
      }

      if ($tauriConfig.app -and $tauriConfig.app.windows -and $tauriConfig.app.windows.Count -gt 0) {
        $tauriConfig.app.windows[0].title = $config.PackageName
        [TauriCraft]::Logger.LogDebugLine("Updated window title to: $($config.PackageName)")
      }

      $targetDir = Join-Path $config.TargetDirectory "src-tauri"
      if (![IO.Directory]::Exists($targetDir)) {
        New-Item -Path $targetDir -ItemType Directory -Force | Out-Null
      }

      $targetPath = Join-Path $targetDir "tauri.conf.json"
      $tauriConfig | ConvertTo-Json -Depth 10 | Set-Content $targetPath -Encoding UTF8
      [TauriCraft]::Logger.LogInfoLine("Updated tauri.conf.json configuration")
    } else {
      [TauriCraft]::Logger.LogWarnLine("tauri.conf.json not found in template directory")
    }
  }

  # Cargo.toml processing
  static [void] ProcessCargoToml([ProjectConfig] $config, [string] $moduleRoot) {
    $sharedCargoPath = Join-Path $moduleRoot "Private\templates\.shared\src-tauri\Cargo.toml"

    if ([IO.Path]::Exists($sharedCargoPath)) {
      $cargoContent = Get-Content $sharedCargoPath -Raw
      $updatedContent = $cargoContent -replace 'name\s*=\s*"tauri-ui"', "name = `"$($config.PackageName)`""

      $targetDir = Join-Path $config.TargetDirectory "src-tauri"
      if (![IO.Directory]::Exists($targetDir)) {
        New-Item -Path $targetDir -ItemType Directory -Force | Out-Null
      }

      $targetPath = Join-Path $targetDir "Cargo.toml"
      Set-Content $targetPath $updatedContent -Encoding UTF8
    }
  }

  # GitHub Actions release workflow processing
  static [void] ProcessReleaseWorkflow([ProjectConfig] $config, [string] $moduleRoot) {
    $releaseYmlPath = Join-Path $moduleRoot "Private\templates\.shared\.github\workflows\release.yml"

    if ([IO.Path]::Exists($releaseYmlPath)) {
      $releaseContent = Get-Content $releaseYmlPath -Raw
      $allOS = [TauriCraft]::GetAllOSTargets()
      $selectedOS = $config.ReleaseOS -join ", "

      $comment = ""
      if ($config.ReleaseOS.Count -lt $allOS.Count) {
        $excludedOS = $allOS | Where-Object { $_ -notin $config.ReleaseOS }
        $comment = " # $($excludedOS -join ', ')"
      }

      $updatedContent = $releaseContent -replace
      "platform: \[macos-latest, ubuntu-latest, windows-latest\]",
      "platform: [$selectedOS]$comment"

      $targetDir = Join-Path $config.TargetDirectory ".github\workflows"
      if (![IO.Directory]::Exists($targetDir)) {
        New-Item -Path $targetDir -ItemType Directory -Force | Out-Null
      }

      $targetPath = Join-Path $targetDir "release.yml"
      Set-Content $targetPath $updatedContent -Encoding UTF8
    }
  }

  # Completion instructions
  static [void] ShowCompletionInstructions([ProjectConfig] $config) {
    $relativePath = Resolve-Path $config.TargetDirectory -Relative

    Write-Host "`nDone. Now run:" -ForegroundColor Green

    if ($config.TargetDirectory -ne (Get-Location).Path) {
      if ($relativePath.Contains(" ")) {
        Write-Host " cd `"$relativePath`"" -ForegroundColor Cyan
      } else {
        Write-Host " cd $relativePath" -ForegroundColor Cyan
      }
    }

    switch ($config.PackageManager) {
      "yarn" {
        Write-Host " yarn" -ForegroundColor Cyan
        Write-Host " yarn tauri dev" -ForegroundColor Cyan
      }
      "pnpm" {
        Write-Host " pnpm i" -ForegroundColor Cyan
        Write-Host " pnpm tauri dev" -ForegroundColor Cyan
      }
      default {
        Write-Host " $($config.PackageManager) install" -ForegroundColor Cyan
        Write-Host " $($config.PackageManager) run tauri dev" -ForegroundColor Cyan
      }
    }
    Write-Host ""
  }

  # Utility methods
  static [bool] IsDirectoryEmpty([string] $path) {
    if (![IO.Path]::Exists($path)) {
      return $true
    }

    $items = Get-ChildItem $path -Force
    return $items.Count -eq 0 -or ($items.Count -eq 1 -and $items[0].Name -eq ".git")
  }

  static [void] EmptyDirectory([string] $path) {
    if (![IO.Path]::Exists($path)) {
      return
    }

    Get-ChildItem $path -Force | Where-Object { $_.Name -ne ".git" } | Remove-Item -Recurse -Force
  }

  static [bool] IsValidPackageName([string] $name) {
    return $name -match '^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$'
  }

  static [string] ToValidPackageName([string] $name) {
    return $name.Trim().ToLower() -replace '\s+', '-' -replace '^[._]', '' -replace '[^a-z\d\-~]+', '-'
  }

  static [string] FormatTargetDirectory([string] $targetDir) {
    if ([string]::IsNullOrWhiteSpace($targetDir)) {
      return ""
    }
    return $targetDir.Trim() -replace '/+$', ''
  }

  static [hashtable] DetectPackageManager() {
    $userAgent = $env:npm_config_user_agent
    if ([string]::IsNullOrWhiteSpace($userAgent)) {
      return @{ name = "npm"; version = "" }
    }

    $pkgSpec = $userAgent.Split(" ")[0]
    $pkgSpecArr = $pkgSpec.Split("/")

    return @{
      name    = $pkgSpecArr[0]
      version = if ($pkgSpecArr.Length -gt 1) { $pkgSpecArr[1] } else { "" }
    }
  }

  # Dispose logger resources
  static [void] DisposeLogger() {
    if ($null -ne [TauriCraft]::Logger) {
      [TauriCraft]::Logger.LogInfoLine("Disposing TauriCraft logger")
      [TauriCraft]::Logger.Dispose()
      [TauriCraft]::Logger = $null
    }
  }
}
#endregion Classes

# Types that will be available to users when they import the module.
$typestoExport = @(
  [TauriCraft],
  [Framework],
  [TargetOSConfig],
  [ProjectConfig],
  [FrameworkType],
  [TargetOS]
)
$TypeAcceleratorsClass = [PsObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')
foreach ($Type in $typestoExport) {
  if ($Type.FullName -in $TypeAcceleratorsClass::Get.Keys) {
    $Message = @(
      "Unable to register type accelerator '$($Type.FullName)'"
      'Accelerator already exists.'
    ) -join ' - '
    "TypeAcceleratorAlreadyExists $Message" | Write-Debug
  }
}
# Add type accelerators for every exportable type.
foreach ($Type in $typestoExport) {
  $TypeAcceleratorsClass::Add($Type.FullName, $Type)
}
# Remove type accelerators when the module is removed.
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
  # Dispose logger resources
  [TauriCraft]::DisposeLogger()

  # Remove type accelerators
  foreach ($Type in $typestoExport) {
    $TypeAcceleratorsClass::Remove($Type.FullName)
  }
}.GetNewClosure();

$scripts = @();
$Public = Get-ChildItem "$PSScriptRoot/Public" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue
$scripts += Get-ChildItem "$PSScriptRoot/Private" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue
$scripts += $Public

foreach ($file in $scripts) {
  try {
    if ([string]::IsNullOrWhiteSpace($file.fullname)) { continue }
    . "$($file.fullname)"
  } catch {
    Write-Warning "Failed to import function $($file.BaseName): $_"
    $host.UI.WriteErrorLine($_)
  }
}

$Param = @{
  Function = $Public.BaseName
  Cmdlet   = '*'
  Alias    = '*'
  Verbose  = $false
}
Export-ModuleMember @Param