diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e9fb296 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# Normalize line endings: Git stores LF, checks out platform-native by default. +* text=auto eol=lf + +# Windows-only scripts must keep CRLF on disk. +*.ps1 text eol=crlf +*.psm1 text eol=crlf +*.psd1 text eol=crlf +*.cmd text eol=crlf +*.bat text eol=crlf + +# Treat known binary formats as binary so Git never tries to munge them. +*.binlog binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.vsix binary +*.zip binary +*.gz binary diff --git a/.github/actions/workspace-version/action.yml b/.github/actions/workspace-version/action.yml new file mode 100644 index 0000000..46c4966 --- /dev/null +++ b/.github/actions/workspace-version/action.yml @@ -0,0 +1,41 @@ +# Copyright (c) 2026 Mike Grier +name: Read workspace version +description: > + Reads [workspace.package].version from the root Cargo.toml (the single + source of truth after workspace version inheritance) and exposes it as an + output so callers do not have to duplicate the Cargo.toml parsing logic. + +outputs: + version: + description: Workspace version string (e.g. "0.1.2") + value: ${{ steps.read.outputs.version }} + +runs: + using: composite + steps: + - id: read + shell: bash + run: | + set -euo pipefail + # Read the version only from the [workspace.package] section: + # 1. Set `in_section=1` when entering [workspace.package]. + # 2. Reset `in_section=0` when any other section header is seen. + # 3. Capture the version string only while in_section is set. + # This avoids accidentally matching a `version = "..."` line that + # might appear in another TOML section before [workspace.package]. + ver=$(awk ' + /^\[workspace\.package\]/ { in_section=1; next } + /^\[/ { in_section=0 } + in_section && /^version[[:space:]]*=/ { + # POSIX split() on " is universally supported (mawk, gawk, awk). + # Field [2] is the text between the first and second double-quote. + split($0, a, "\"") + print a[2] + exit + } + ' Cargo.toml) + if [ -z "$ver" ]; then + echo "::error::Could not parse [workspace.package].version from Cargo.toml" + exit 1 + fi + echo "version=$ver" >> "$GITHUB_OUTPUT" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..ca1438c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,44 @@ +# Copilot Instructions + +Use LF line endings. + +## Repository purpose + +This repository is a **template** for a Rust crate that will be published to +crates.io. Keep the automation working while replacing the placeholder crate +with project-specific code. + +## Working rules + +- Prefer small, reviewable changes. +- Do not leave placeholder names like `your-crate-name`, `OWNER`, or + `REPOSITORY` behind when specializing the template for a real project. +- Keep documentation, release automation, and publish automation aligned with + the actual crate metadata. +- If you add or remove workspace members, update any workflow or release config + that assumes a single publishable crate. + +## Validation + +Run the standard workspace checks from the repository root: + +```sh +cargo fmt --all --check +cargo build --workspace --all-targets --locked +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo test --workspace --locked +``` + +## Release automation + +- `release-please` manages version bumps, tags, and changelog updates. +- The publish workflow expects a `v` tag that matches the crate + version. +- Publishing requires the `RELEASE_PLEASE_TOKEN` and + `CARGO_REGISTRY_TOKEN` repository secrets. + +## Planning docs + +- Use `DESIGN-NOTES.md` for durable design decisions. +- Use `CHECKLIST.md` for outstanding work. +- Use `PLANS.md` for short-lived implementation plans. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d43bf8e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +# Keep GitHub Actions and Cargo deps current. +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + - github-actions + + - package-ecosystem: cargo + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + - rust diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1b81d9f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +# Copyright (c) 2026 Mike Grier. All rights reserved. +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + encoding: + name: encoding sanity check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Run check-encoding.ps1 + shell: pwsh + run: ./tools/check-encoding.ps1 + + build-test: + name: build + test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: cargo build + run: cargo build --workspace --all-targets --locked + - name: cargo test + env: + RUST_BACKTRACE: 1 + RUST_LIB_BACKTRACE: 1 + run: cargo test --workspace --locked --no-fail-fast + + fmt: + name: rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: cargo fmt --check + run: cargo fmt --all --check + + clippy: + name: clippy (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - name: cargo clippy + run: cargo clippy --workspace --all-targets --locked -- -D warnings + + msrv: + name: MSRV check 1.97 (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@1.97.0 + - uses: Swatinem/rust-cache@v2 + - name: cargo check (MSRV) + run: cargo check --workspace --all-targets --locked diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..5796f62 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,43 @@ +name: "CodeQL Advanced" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '27 3 * * 3' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + security-events: write + packages: read + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: rust + build-mode: none + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml new file mode 100644 index 0000000..bd1a997 --- /dev/null +++ b/.github/workflows/publish-crate.yml @@ -0,0 +1,42 @@ +# Copyright (c) 2026 Mike Grier +name: publish-crate + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + publish: + name: cargo publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - name: Verify tag matches crate version + run: | + metadata="$(cargo metadata --no-deps --format-version 1)" + member_count="$(printf '%s' "$metadata" | jq '.workspace_members | length')" + if [ "$member_count" -ne 1 ]; then + echo "::error::publish-crate.yml expects exactly one workspace member; found ${member_count}" >&2 + exit 1 + fi + pkg_id="$(printf '%s' "$metadata" | jq -r '.workspace_members[0]')" + crate_name="$(printf '%s' "$metadata" | jq -r --arg pkg_id "$pkg_id" '.packages[] | select(.id == $pkg_id) | .name')" + version="$(printf '%s' "$metadata" | jq -r --arg pkg_id "$pkg_id" '.packages[] | select(.id == $pkg_id) | .version')" + expected="v${version}" + if [ "${GITHUB_REF_NAME}" != "${expected}" ]; then + echo "::error::tag ${GITHUB_REF_NAME} does not match crate version ${expected}" >&2 + exit 1 + fi + echo "CRATE_NAME=${crate_name}" >> "$GITHUB_ENV" + - name: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish -p "$CRATE_NAME" --locked diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..a7bb088 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,57 @@ +# Copyright (c) 2026 Mike Grier +name: release-please + +on: + push: + branches: [main] + +concurrency: + group: release-please + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + pr: ${{ steps.release.outputs.pr }} + steps: + - uses: googleapis/release-please-action@v5 + id: release + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + update-lockfile: + name: update Cargo.lock on release PR + runs-on: ubuntu-latest + needs: release-please + if: ${{ needs.release-please.outputs.pr != '' }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ fromJSON(needs.release-please.outputs.pr).headBranchName }} + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Update Cargo.lock for version bump + run: cargo check --workspace + + - name: Commit updated Cargo.lock + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Cargo.lock + git diff --staged --quiet || git commit -m "chore: update Cargo.lock for version bump" + git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c49f0fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Generated by Cargo +debug +target + +# Backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these (debug info) +*.pdb + +# cargo mutants +**/mutants.out*/ + +# Scratch / diagnostic output (git-ignored per repo instructions) +.scratch/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..96d9691 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..15c73ea --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.1.0] - 2026-08-16 + +- Initial template contents. diff --git a/CHECKLIST.md b/CHECKLIST.md new file mode 100644 index 0000000..90619db --- /dev/null +++ b/CHECKLIST.md @@ -0,0 +1,9 @@ +# Checklist + +Use this file for the open work needed to turn this template into your actual +crate project. Replace these items with project-specific milestones. + +- [ ] Rename the placeholder crate and package metadata. +- [ ] Replace the example library code with the real implementation. +- [ ] Update the release configuration and repository URLs. +- [ ] Confirm CI and crates.io publishing secrets are configured. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..46c7f46 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "your-crate-name" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..32ef7ac --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +# Copyright (c) 2026 Mike Grier + +[workspace] +members = ["crates/your-crate-name"] +resolver = "2" + +[workspace.package] +version = "0.1.0" # x-release-please-version +authors = ["Your Name "] +edition = "2024" +rust-version = "1.97" +license = "MIT" +repository = "https://github.com/OWNER/REPOSITORY" +homepage = "https://github.com/OWNER/REPOSITORY" +documentation = "https://docs.rs/your-crate-name" + +[profile.release] +strip = "symbols" +lto = "thin" +codegen-units = 1 diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md new file mode 100644 index 0000000..e52f7c6 --- /dev/null +++ b/DESIGN-NOTES.md @@ -0,0 +1,4 @@ +# Design notes + +Describe the crate's goals, constraints, major design decisions, and any +trade-offs that future contributors should understand. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..7fc275d --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,25 @@ +# Development + +This repository is a generic template for publishing a Rust crate to crates.io. + +## Commands + +Run the standard checks from the workspace root: + +```sh +cargo fmt --all --check +cargo build --workspace --all-targets --locked +cargo test --workspace --locked +cargo clippy --workspace --all-targets --locked -- -D warnings +``` + +## Release setup + +Before publishing from a repository created from this template: + +1. Replace the placeholder crate name and metadata in the Cargo manifests and + workflow files. +2. Create the `RELEASE_PLEASE_TOKEN` repository secret so release tags trigger + downstream workflows. +3. Create the `CARGO_REGISTRY_TOKEN` repository secret with crates.io publish + permission. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4116f5b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Mike Grier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PLANS.md b/PLANS.md new file mode 100644 index 0000000..8f4887f --- /dev/null +++ b/PLANS.md @@ -0,0 +1,3 @@ +# Plans + +Record short-lived implementation plans for the real crate here. diff --git a/README.md b/README.md index 81d5c59..9ac08e9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,42 @@ # crate-template -Template repo for building crate(s) to publish to crates.io + +A template repository for building and publishing a Rust crate on crates.io. + +## What this template includes + +- A Cargo workspace with one placeholder library crate at `crates/your-crate-name` +- GitHub Actions CI for formatting, clippy, tests, MSRV checks, and CodeQL +- `release-please` automation for changelog, tags, and release PRs +- A publish workflow that pushes the crate to crates.io from `v*` tags +- Copilot instructions and lightweight planning/design placeholders + +## Specialize this template + +Before your first real release, replace the placeholder values below: + +1. Rename the crate directory `crates/your-crate-name` if desired. +2. Update `your-crate-name` in: + - `Cargo.toml` + - `crates/your-crate-name/Cargo.toml` + - `release-please-config.json` +3. Replace the example metadata URLs, author, and documentation settings in `Cargo.toml`. +4. Replace the placeholder library code with your actual crate implementation. +5. Set the repository secrets required for releases: + - `RELEASE_PLEASE_TOKEN` + - `CARGO_REGISTRY_TOKEN` + +## Build + +Requires Rust `1.97` or newer. + +```sh +cargo fmt --all --check +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo test --workspace --locked +``` + +## Release + +Merging conventional commits to `main` allows `release-please` to open or update +release PRs. Merging a release PR creates a `v` tag, which triggers the +crates.io publish workflow. diff --git a/crates/your-crate-name/Cargo.toml b/crates/your-crate-name/Cargo.toml new file mode 100644 index 0000000..7f441af --- /dev/null +++ b/crates/your-crate-name/Cargo.toml @@ -0,0 +1,19 @@ +# Copyright (c) 2026 Mike Grier + +[package] +name = "your-crate-name" +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme = "README.md" +description = "Placeholder crate shipped with the crate-template repository." +keywords = ["template", "crate"] +categories = ["development-tools"] + +[lib] +path = "src/lib.rs" diff --git a/crates/your-crate-name/README.md b/crates/your-crate-name/README.md new file mode 100644 index 0000000..08b664d --- /dev/null +++ b/crates/your-crate-name/README.md @@ -0,0 +1,3 @@ +# your-crate-name + +Replace this placeholder crate with your actual library. diff --git a/crates/your-crate-name/src/lib.rs b/crates/your-crate-name/src/lib.rs new file mode 100644 index 0000000..602bfa9 --- /dev/null +++ b/crates/your-crate-name/src/lib.rs @@ -0,0 +1,17 @@ +//! Replace this placeholder crate with your actual library API. + +/// Returns a greeting for the provided crate or project name. +#[must_use] +pub fn greeting(name: &str) -> String { + format!("hello from {name}") +} + +#[cfg(test)] +mod tests { + use super::greeting; + + #[test] + fn greeting_includes_name() { + assert_eq!(greeting("your-crate-name"), "hello from your-crate-name"); + } +} diff --git a/crates/your-crate-name/tests/smoke.rs b/crates/your-crate-name/tests/smoke.rs new file mode 100644 index 0000000..35e3893 --- /dev/null +++ b/crates/your-crate-name/tests/smoke.rs @@ -0,0 +1,4 @@ +use your_crate_name as _; + +#[test] +fn crate_builds_for_integration_tests() {} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..9822962 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "include-component-in-tag": false, + "packages": { + ".": { + "package-name": "your-crate-name", + "extra-files": [ + "Cargo.toml" + ] + } + } +} diff --git a/tools/check-encoding.ps1 b/tools/check-encoding.ps1 new file mode 100644 index 0000000..5025429 --- /dev/null +++ b/tools/check-encoding.ps1 @@ -0,0 +1,130 @@ +# Copyright (c) Michael Grier +# +# tools/check-encoding.ps1 -- fail if any tracked text file is not valid +# UTF-8 or contains characteristic mojibake digraphs. +# +# encoding-check: allow-mojibake (this file contains literal examples +# of mojibake patterns in regexes and comments) +# +# Usage: +# pwsh tools/check-encoding.ps1 # check every tracked file +# pwsh tools/check-encoding.ps1 -Path src/foo # check one file or dir +# +# Exits 0 on success, 1 when encoding issues are found, and 2 for +# usage/configuration errors (unknown path, or not in a git repo without +# -Path). Designed for both local use after a fallback edit and for CI +# invocation on every pull request. + +[CmdletBinding()] +param( + # Optional path to restrict the check to. Default: all files tracked + # by git. + [string]$Path +) + +$ErrorActionPreference = 'Stop' + +# Common UTF-8-misread-as-Windows-1252 digraphs / trigraphs. These are +# not exhaustive but catch the vast majority of real-world corruption: +# à prefix -- most Latin-1 letters misread (é, ê, è, à , ï, ...) +# †prefix -- typographic punctuation (em-dash, en-dash, smart quotes, ellipsis, bullet) +# â" prefix -- box drawing +#  -- stray NBSP in front of an ASCII char +$Patterns = @( + [pscustomobject]@{ Name = 'Latin-1 mojibake (Ã...)'; Regex = '[\u00C3][\u0080-\u00BF]' } + [pscustomobject]@{ Name = 'Punctuation mojibake (â€...)'; Regex = '\u00E2\u20AC[\u0080-\u20FF]' } + [pscustomobject]@{ Name = 'Box-draw mojibake (â"...)'; Regex = '\u00E2\u201D[\u0080-\u20FF]' } + [pscustomobject]@{ Name = 'NBSP mojibake (Â)'; Regex = '\u00C2\u00A0' } +) + +# File extensions we consider "text" and therefore subject to the check. +# Binary files (images, archives, .binlog fixtures, etc.) are skipped. +$TextExtensions = @( + '.rs', '.toml', '.md', '.txt', '.json', '.yaml', '.yml', + '.ps1', '.psm1', '.psd1', '.sh', '.cfg', '.ini', '.ts', + '.lock', '.gitignore', '.gitattributes', '.vscodeignore' +) + +function Test-IsTextFile([string]$file) { + $ext = [System.IO.Path]::GetExtension($file).ToLowerInvariant() + if ($ext -and $TextExtensions -contains $ext) { return $true } + # Files with no extension that look like text (LICENSE, README, etc.). + $name = [System.IO.Path]::GetFileName($file) + if (-not $ext -and $name -match '^(LICENSE|README|CHANGELOG|AUTHORS|NOTICE|MAINTAINERS|CODEOWNERS)') { + return $true + } + return $false +} + +function Get-TargetFiles { + if ($Path) { + if (Test-Path -LiteralPath $Path -PathType Leaf) { + return @((Resolve-Path -LiteralPath $Path).Path) + } + if (Test-Path -LiteralPath $Path -PathType Container) { + return Get-ChildItem -LiteralPath $Path -Recurse -File | + Where-Object { Test-IsTextFile $_.FullName } | + ForEach-Object { $_.FullName } + } + Write-Error "Path not found: $Path" + exit 2 + } + # Default: all files tracked by git. + $repoRoot = (& git rev-parse --show-toplevel 2>$null) + if (-not $repoRoot) { + Write-Error 'Not inside a git repository; pass -Path explicitly.' + exit 2 + } + Push-Location $repoRoot + try { + $tracked = & git ls-files + return $tracked | + Where-Object { Test-IsTextFile $_ } | + ForEach-Object { Join-Path $repoRoot $_ } + } finally { + Pop-Location + } +} + +$files = @(Get-TargetFiles) +$failures = @() +$strictUtf8 = [System.Text.UTF8Encoding]::new($false, $true) + +# Files that legitimately contain mojibake digraphs as documentation / +# regex content opt out of the pattern check by including this marker. +# They are still validated as UTF-8. +$AllowMojibakeMarker = 'encoding-check: allow-mojibake' + +foreach ($file in $files) { + if (-not (Test-Path -LiteralPath $file)) { continue } + $bytes = [System.IO.File]::ReadAllBytes($file) + if ($bytes.Length -eq 0) { continue } + + # 1. Must be valid UTF-8. + try { + $text = $strictUtf8.GetString($bytes) + } catch { + $failures += "INVALID UTF-8: $file ($($_.Exception.Message))" + continue + } + + # 2. Must not contain characteristic mojibake patterns -- unless the + # file explicitly opts out. + if ($text.Contains($AllowMojibakeMarker)) { continue } + foreach ($p in $Patterns) { + if ([regex]::IsMatch($text, $p.Regex)) { + $failures += "MOJIBAKE: $file [$($p.Name)]" + break + } + } +} + +if ($failures.Count -gt 0) { + foreach ($f in $failures) { Write-Host $f -ForegroundColor Red } + Write-Host '' + Write-Host "Encoding check failed: $($failures.Count) file(s) flagged out of $($files.Count) checked." -ForegroundColor Red + exit 1 +} + +Write-Host "Encoding check passed: $($files.Count) file(s) clean." -ForegroundColor Green +exit 0