Repo2md.psm1

#!/usr/bin/env pwsh
enum FileType {
  Text
  Binary
  SymbolicLink
}

class Repo2md {
  [string]$Repo
  [string[]]$Include
  [string[]]$Ignore

  Repo2md([string]$repo, [string[]]$include, [string[]]$ignore) {
    $this.Repo = $repo
    $this.Include = $include
    $this.Ignore = $ignore
  }
  [string] GenerateMarkdown() {
    return $this.GenerateMarkdown([IO.Path]::combine((Resolve-Path .).Path, ([IO.Path]::GetDirectoryName($this.repo) + '_repo2md.md')))
  }
  [string] GenerateMarkdown([string]$outputFile) {
    if (![IO.Directory]::Exists($this.Repo)) {
      throw [System.IO.DirectoryNotFoundException]::new("Repository path does not exist: $($this.Repo.Path)")
    }
    Write-Verbose "Repository Path: $($this.Repo.Path)"
    $gitignoreRules = $this.BuildGitIgnoreRules($this.Repo.Path)

    $walker = Get-ChildItem -Path $this.Repo.Path -Recurse -Directory -ErrorAction SilentlyContinue, $this.Repo.Path -Recurse -File -ErrorAction SilentlyContinue

    $output = New-Object System.Text.StringBuilder
    $output.AppendLine(('#' + " ``$(Split-Path -Leaf -Path $this.Repo.Path)``" + "`n"))

    Write-Progress -Activity "Traversing Repository" -Status "Starting..."

    $filteredEntries = @{}
    $itemCount = 0
    foreach ($item in $walker) {
      $itemCount++
    }
    $processedCount = 0

    foreach ($item in $walker) {
      $processedCount++
      $relativePath = $item.FullName.Substring($this.Repo.Path.Length).TrimStart([char]92) # Trim leading slash and convert to relative path
      Write-Progress -Activity "Traversing Repository" -Status "Processing: $relativePath" -PercentComplete (($processedCount / $itemCount) * 100)

      $isIgnored = $this.IsPathIgnored($relativePath, $gitignoreRules)
      $isIncluded = $this.IsPathIncluded($relativePath, $this.Include)

      if ($isIgnored -and !$isIncluded) {
        if ($item.PSIsContainer) {
          # Skip ignored directories - Get-ChildItem -Recurse already handles this partially but for clarity we keep this logic
          continue
        }
        continue
      }

      if ($item.PSIsContainer) {
        $dirEntries = @()
        foreach ($entry in Get-ChildItem -Path $item.FullName -ErrorAction SilentlyContinue) {
          $entryRelativePath = $entry.FullName.Substring($this.Repo.Path.Length).TrimStart([char]92)
          if (!$this.IsPathIgnored($entryRelativePath, $gitignoreRules)) {
            $dirEntries += $entry
          }
        }
        $filteredEntries[$item.FullName] = $dirEntries

        $dirName = $relativePath
        $headerLevel = ($relativePath -split '\\').Count + 2 # Assuming backslash as path separator in Windows, adjust if needed
        $output.AppendLine(('#' * $headerLevel + ' Directory `' + $dirName + "/` `n"))
        $output.AppendLine(('```' + "sh`n"))
        $ignoredDirs = [System.Collections.Generic.HashSet[string]]::new()
        $treeOutput = $this.GenerateTreeOutput($item.FullName, $filteredEntries, $ignoredDirs)
        $output.AppendLine($treeOutput)
        $output.AppendLine(('```' + "`n"))
      } else {
        # It's a file
        $fileType = $this.DetectFileType($item.FullName)
        if ($fileType -eq [FileType]::Text) {
          $sourceFile = $relativePath
          $output.AppendLine("### Source file: ``$sourceFile```n")

          $fileExtension = [System.IO.Path]::GetExtension($item.FullName).TrimStart('.')
          $codeBlockLang = switch ($fileExtension) {
            "rs" { "rust"; break }
            "md" { "markdown"; break }
            default { $fileExtension }
          }

          $output.AppendLine(('```' + $codeBlockLang + "`n"))
          try {
            $content = Get-Content -Path $item.FullName -Raw -ErrorAction Stop
            $output.AppendLine($content)
          } catch {
            Write-Warning "Failed to read file: $($item.FullName), error: $_"
            # $output.AppendLine("[Failed to read file contents]") # Optional placeholder
          }
          $output.AppendLine(('```' + "`n"))
        } elseif ($fileType -eq [FileType]::Binary) {
          Write-Warning "Binary file not ignored by .gitignore: $relativePath"
          # $output.AppendLine("Binary file ``$relativePath`` detected, consider adding it to .gitignore.`n") # Optional warning in output
        } elseif ($fileType -eq [FileType]::SymbolicLink) {
          Write-Warning "Symbolic link not ignored by .gitignore: $relativePath"
          # $output.AppendLine("Symbolic link ``$relativePath`` detected, consider adding it to .gitignore.`n") # Optional warning in output
        }
      }
    }
    Write-Progress -Activity "Traversing Repository" -Completed -Status "Completed!"
    [string]$output = ($output.ToString().TrimEnd("`n") + "`n")
    try {
      Set-Content -Path $outputFile -Value $output -Encoding UTF8 -ErrorAction Stop
      Write-Host "Markdown output written to: $outputFile"
    } catch {
      Write-Error "Failed to write output file: $outputFile, error: $_"
      return [string]::Empty
    }
    return $output
  }

  [string] GenerateTreeOutput([string]$dirPath, [Hashtable]$filteredEntries, [System.Collections.Generic.HashSet[string]]$ignoredDirs) {
    $output = New-Object System.Text.StringBuilder
    [void]$this.TreeRecursive($dirPath, 0, $filteredEntries, $ignoredDirs, ([ref]$output))
    return $output.ToString()
  }

  [string] TreeRecursive([string]$dirPath, [int]$level, [Hashtable]$filteredEntries, [System.Collections.Generic.HashSet[string]]$ignoredDirs, [ref]$output) {
    if (!$filteredEntries.ContainsKey($dirPath)) {
      return [string]::Empty
    }
    $entries = $filteredEntries[$dirPath]; $files = @(); $dirs = @()
    foreach ($entry in $entries) {
      if ($entry.PSIsContainer) {
        $dirs += $entry
      } else {
        $files += $entry
      }
    }

    $dirs = $dirs | Sort-Object Name
    $files = $files | Sort-Object Name

    for ($i = 0; $i -lt $dirs.Count; $i++) {
      $entry = $dirs[$i]
      $path = $entry.FullName
      if ($ignoredDirs.Contains($path)) {
        continue
      }
      $name = $entry.Name

      if ($i -eq $dirs.Count - 1 -and $files.Count -eq 0) {
        $prefix = "`-- "
      } else {
        $prefix = "|-- "
      }

      $output.Value.AppendLine((" " * $level) + $prefix + "$name/")
      $this.TreeRecursive($path, $level + 1, $filteredEntries, $ignoredDirs, ([ref]$output))
    }

    for ($i = 0; $i -lt $files.Count; $i++) {
      $entry = $files[$i]
      $path = $entry.FullName
      if ($ignoredDirs.Contains($path)) {
        continue
      }
      $name = $entry.Name

      $fileType = $this.DetectFileType($path)
      $fileTypeStr = switch ($fileType) {
        [FileType]::Text { "[text]"; break }
        [FileType]::Binary { "[binary]"; break }
        [FileType]::SymbolicLink { "[symlink]"; break }
      }

      if ($i -eq $files.Count - 1) {
        $prefix = "`-- "
      } else {
        $prefix = "|-- "
      }
      $output.Value.AppendLine((" " * $level) + $prefix + "$name $fileTypeStr")
    }
    return $output.Value
  }

  [FileType] DetectFileType([string]$filePath) {
    if ((Test-Path -Path $filePath -PathType SymbolicLink)) {
      return [FileType]::SymbolicLink
    } else {
      try {
        Get-Content -Path $filePath -Encoding UTF8 -ErrorAction Stop | Out-Null
        return [FileType]::Text
      } catch {
        return [FileType]::Binary
      }
    }
  }

  [string[]] BuildGitIgnoreRules() {
    $gitignoreRules = @()

    # Add command-line ignore patterns
    foreach ($ignorePattern in $this.Ignore) {
      $pattern = $ignorePattern
      if ($pattern.EndsWith('/')) {
        $pattern += '**'
      }
      $gitignoreRules += $pattern
    }

    # Add .git/ to ignore list
    $gitignoreRules += ".git/**"

    # Add content of .gitignore
    $gitignorePath = Join-Path -Path $this.Repo -ChildPath ".gitignore"
    if (Test-Path -Path $gitignorePath) {
      $gitignoreRules += Get-Content -Path $gitignorePath -ErrorAction SilentlyContinue
    }

    # Add content of .git/info/exclude
    $excludePath = Join-Path -Path $this.Repo -ChildPath ".git/info/exclude"
    if (Test-Path -Path $excludePath) {
      $gitignoreRules += Get-Content -Path $excludePath -ErrorAction SilentlyContinue
    }
    return $gitignoreRules
  }

  [bool] IsPathIgnored([string]$relativePath, [string[]]$gitignoreRules) {
    foreach ($rule in $gitignoreRules) {
      if ([WildcardPattern]::new($rule, "IgnoreCase").IsMatch($relativePath)) {
        return $true
      }
    }
    return $false
  }

  [bool] IsPathIncluded([string]$relativePath, [string[]]$includePatterns) {
    if ($includePatterns -and $includePatterns.Count -gt 0) {
      foreach ($pattern in $includePatterns) {
        if ($relativePath.StartsWith($pattern)) {
          return $true
        }
      }
      return $false # If include patterns are provided, and none match, then it's not included.
    }
    return $false # No include patterns provided, so it's not considered explicitly included.
  }
}

$typestoExport = @(
  [Repo2md]
)
$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 ' - '

    [System.Management.Automation.ErrorRecord]::new(
      [System.InvalidOperationException]::new($Message),
      'TypeAcceleratorAlreadyExists',
      [System.Management.Automation.ErrorCategory]::InvalidOperation,
      $Type.FullName
    ) | 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 = {
  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