diff --git a/.editorconfig b/.editorconfig index 75d9084f..7282c0f7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -274,3 +274,6 @@ dotnet_naming_style.underscore_camel_case.capitalization = camel_case # Public API analyzer dotnet_public_api_analyzer.require_api_files = true + +# RS0017: Symbol removed from public API - error severity (IDE guidance; build enforcement via WarningsAsErrors in .csproj) +dotnet_diagnostic.RS0017.severity = error diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..880b45f7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +* @petesramek + +.github/ @petesramek +src/ @petesramek +tests/ @petesramek diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dd84ea78..6236e3a0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,8 +1,8 @@ --- name: Bug report about: Create a report to help us improve -title: '' -labels: '' +title: '[Bug]: ' +labels: 'bug' assignees: '' --- @@ -11,28 +11,22 @@ assignees: '' A clear and concise description of what the bug is. **To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error +Minimal code snippet that reproduces the issue: + +```csharp +// paste reproduction code here +``` **Expected behavior** A clear and concise description of what you expected to happen. -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] +**Actual behavior** +A clear and concise description of what actually happens (include exception messages/stack traces if applicable). -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] +**Environment** + - Package version: [e.g. 2.1.0] + - .NET version: [e.g. net8.0] + - OS: [e.g. Windows 11, Ubuntu 24.04] **Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..2afb950d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a question + url: https://github.com/petesramek/polyline-algorithm-csharp/discussions + about: Use GitHub Discussions for questions, ideas, or general feedback. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7d..5c8a425a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,8 +1,8 @@ --- name: Feature request about: Suggest an idea for this project -title: '' -labels: '' +title: '[Feature]: ' +labels: 'enhancement' assignees: '' --- diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..70eda657 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +## Summary + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation / samples only + +## Checklist + +- [ ] Unit tests added or updated (`/tests`) +- [ ] Benchmarks added or updated (`/benchmarks`) — if performance-impacting +- [ ] XML doc comments updated for all public API changes +- [ ] `dotnet format` run with no issues +- [ ] README and `/samples` updated — if public API changed +- [ ] PR label added (`breaking` / `feat` / `fix` / `docs`) — for changelog categorization diff --git a/.github/actions/git/configure-identity/action.yml b/.github/actions/git/configure-identity/action.yml new file mode 100644 index 00000000..ece38a74 --- /dev/null +++ b/.github/actions/git/configure-identity/action.yml @@ -0,0 +1,12 @@ +name: 'Configure git identity' +author: 'Pete Sramek' +description: 'Configures git user name and email from the last commit.' + +runs: + using: composite + steps: + - name: 'Configure git identity' + shell: bash + run: | + git config user.name "$(git log -n 1 --pretty=format:%an)" + git config user.email "$(git log -n 1 --pretty=format:%ae)" diff --git a/.github/actions/git/push-changes/action.yml b/.github/actions/git/push-changes/action.yml index 31ddd3f2..5c728462 100644 --- a/.github/actions/git/push-changes/action.yml +++ b/.github/actions/git/push-changes/action.yml @@ -87,12 +87,14 @@ runs: set -e working-directory: ${{ inputs.working-directory }} + - name: Configure git identity + if: ${{ fromJSON(steps.validate.outputs.has-changes) == '1' }} + uses: './.github/actions/git/configure-identity' + - name: Push changes to ${{ github.head_ref || github.ref }} if: ${{ fromJSON(steps.validate.outputs.has-changes) == '1' }} shell: bash run: | - git config user.name "$(git log -n 1 --pretty=format:%an)" - git config user.email "$(git log -n 1 --pretty=format:%ae)" git commit -m '${{ inputs.commit-message }}' git pull --rebase origin ${{ github.head_ref || github.ref }} git push diff --git a/.github/actions/github/branch-protection/lock/action.yml b/.github/actions/github/branch-protection/lock/action.yml index e675db54..1ceaaf84 100644 --- a/.github/actions/github/branch-protection/lock/action.yml +++ b/.github/actions/github/branch-protection/lock/action.yml @@ -1,37 +1,16 @@ name: 'Lock branch' author: 'Pete Sramek' -description: 'Apply branch protection to prevent direct pushes. Requires PRs with configurable approval count; admins can optionally bypass all restrictions.' +description: 'Restore ruleset protection for a branch previously unlocked by the unlock action (removes it from each ruleset exclude list).' inputs: branch: description: 'Branch name to lock.' required: true token: - description: 'GitHub token with administration:write (repo admin) permission. Use a PAT; GITHUB_TOKEN cannot call the branch protection API.' + description: 'GitHub token with administration:write (repo admin) permission. Use a PAT; GITHUB_TOKEN cannot modify rulesets.' + required: true + ruleset-ids: + description: 'Space-separated ruleset IDs, as output by the unlock action.' required: true - lock-branch: - description: 'When true, sets lock_branch to prevent even PR merges (use during automated operations). When false (default), only direct pushes are blocked; PRs can still be merged.' - required: false - default: 'false' - required-approving-review-count: - description: 'Number of approving reviews required before a PR can be merged. Set to 0 to require PRs without requiring approvals.' - required: false - default: '1' - dismiss-stale-reviews: - description: 'When true, approved reviews are dismissed when new commits are pushed to the branch.' - required: false - default: 'true' - bypass-admins: - description: 'When true, repository admins are exempt from all branch protection rules (enforce_admins is disabled). When false (default), admins are also subject to the rules.' - required: false - default: 'false' - skip-pull-request-reviews: - description: 'When true, sets required_pull_request_reviews to null (removes PR review requirement). Use temporarily before automated merges so the merge API is not blocked. When false (default), PR reviews are required as configured.' - required: false - default: 'false' - required-linear-history: - description: 'When true, enforces linear history (no merge commits). When false (default), merge commits are allowed. Set to false before automated merges that create merge commits.' - required: false - default: 'false' runs: using: composite @@ -40,36 +19,20 @@ runs: shell: bash env: GH_TOKEN: ${{ inputs.token }} + BRANCH: ${{ inputs.branch }} + REPOSITORY: ${{ github.repository }} + RULESET_IDS: ${{ inputs.ruleset-ids }} run: | - ENFORCE_ADMINS=true - if [ '${{ inputs.bypass-admins }}' = 'true' ]; then - ENFORCE_ADMINS=false - fi - - PAYLOAD=$(jq -n \ - --argjson review_count '${{ inputs.required-approving-review-count }}' \ - --argjson dismiss_stale '${{ inputs.dismiss-stale-reviews }}' \ - --argjson enforce_admins "$ENFORCE_ADMINS" \ - --argjson lock_branch '${{ inputs.lock-branch }}' \ - --argjson skip_reviews '${{ inputs.skip-pull-request-reviews }}' \ - --argjson required_linear_history '${{ inputs.required-linear-history }}' \ - '{ - "required_status_checks": null, - "enforce_admins": $enforce_admins, - "required_pull_request_reviews": (if $skip_reviews then null else { - "dismiss_stale_reviews": $dismiss_stale, - "require_code_owner_reviews": false, - "required_approving_review_count": $review_count - } end), - "restrictions": null, - "allow_force_pushes": false, - "allow_deletions": false, - "lock_branch": $lock_branch, - "required_linear_history": $required_linear_history - }') - - if ! echo "$PAYLOAD" | gh api --method PUT /repos/${{ github.repository }}/branches/${{ inputs.branch }}/protection --input -; then - echo "::error::Failed to apply branch protection to '${{ inputs.branch }}'. Ensure the token has 'administration: write' permission and the branch exists." - exit 1 - fi - echo "🔒 Branch '${{ inputs.branch }}' is now protected." >> $GITHUB_STEP_SUMMARY + ref="refs/heads/$BRANCH" + failed=0 + for id in $RULESET_IDS; do + endpoint="/repos/$REPOSITORY/rulesets/$id" + if ! gh api "$endpoint" \ + | jq -c --arg ref "$ref" '{conditions: (.conditions | .ref_name.exclude -= [$ref])}' \ + | gh api --method PUT "$endpoint" --input - > /dev/null; then + echo "::error::Failed to restore ruleset $id for '$BRANCH'." + failed=1 + fi + done + [ "$failed" = 0 ] || exit 1 + echo "🔒 Branch '$BRANCH' is protected again." >> $GITHUB_STEP_SUMMARY diff --git a/.github/actions/github/branch-protection/unlock/action.yml b/.github/actions/github/branch-protection/unlock/action.yml index 1f21af0a..c809d903 100644 --- a/.github/actions/github/branch-protection/unlock/action.yml +++ b/.github/actions/github/branch-protection/unlock/action.yml @@ -1,29 +1,71 @@ name: 'Unlock branch' author: 'Pete Sramek' -description: 'Remove branch protection to allow a workflow to push directly. Always re-lock after the operation.' +description: 'Temporarily exempt a branch from the repository rulesets that apply to it (adds it to each ruleset exclude list) so a workflow can push directly. Always restore afterwards.' inputs: branch: description: 'Branch name to unlock.' required: true token: - description: 'GitHub token with administration:write (repo admin) permission. Use a PAT; GITHUB_TOKEN cannot call the branch protection API.' + description: 'GitHub token with administration:write (repo admin) permission. Use a PAT; GITHUB_TOKEN cannot modify rulesets.' required: true fail-on-error: - description: 'When true, the step fails if branch protection cannot be removed. When false (default), errors are silently ignored.' + description: 'When true, the step fails if the rulesets cannot be updated. When false (default), errors are ignored.' required: false default: 'false' +outputs: + ruleset-ids: + description: 'Space-separated IDs of the rulesets the branch was excluded from, for restoring them later.' + value: ${{ steps.unlock.outputs.ruleset-ids }} runs: using: composite steps: - name: 'Unlock branch ${{ inputs.branch }}' + id: unlock shell: bash env: GH_TOKEN: ${{ inputs.token }} + BRANCH: ${{ inputs.branch }} + REPOSITORY: ${{ github.repository }} + FAIL_ON_ERROR: ${{ inputs.fail-on-error }} run: | - if [ '${{ inputs.fail-on-error }}' = 'true' ]; then - gh api --method DELETE /repos/${{ github.repository }}/branches/${{ inputs.branch }}/protection - else - gh api --method DELETE /repos/${{ github.repository }}/branches/${{ inputs.branch }}/protection || true - fi - echo "🔓 Branch protection removed from '${{ inputs.branch }}'." >> $GITHUB_STEP_SUMMARY + fail() { + if [ "$FAIL_ON_ERROR" = 'true' ]; then + echo "::error::$1" + exit 1 + fi + exit 0 + } + + branch_path=$(jq -rn --arg branch "$BRANCH" '$branch | @uri') + ref="refs/heads/$BRANCH" + + rules=$(gh api "/repos/$REPOSITORY/rules/branches/$branch_path") \ + || fail "Failed to read rules applying to '$BRANCH'." + + # Only repository-level rulesets can be edited through the repository endpoint. + ids=$(echo "$rules" | jq -r '[.[] | select(.ruleset_source_type == "Repository") | .ruleset_id] | unique | .[]') + + changed="" + for id in $ids; do + endpoint="/repos/$REPOSITORY/rulesets/$id" + ruleset=$(gh api "$endpoint") || fail "Failed to read ruleset $id." + payload=$(echo "$ruleset" | jq -c --arg ref "$ref" \ + '{conditions: (.conditions | .ref_name.exclude = ((.ref_name.exclude // []) + [$ref] | unique))}') + if echo "$payload" | gh api --method PUT "$endpoint" --input - > /dev/null; then + changed="$changed $id" + else + echo "::error::Failed to exclude '$BRANCH' from ruleset $id." + # Roll back what was already changed so protection is not left weakened. + for done_id in $changed; do + gh api "/repos/$REPOSITORY/rulesets/$done_id" | jq -c --arg ref "$ref" \ + '{conditions: (.conditions | .ref_name.exclude -= [$ref])}' \ + | gh api --method PUT "/repos/$REPOSITORY/rulesets/$done_id" --input - > /dev/null || true + done + fail "Failed to unlock '$BRANCH'." + fi + done + + changed=$(echo $changed) + echo "ruleset-ids=$changed" >> "$GITHUB_OUTPUT" + echo "🔓 Branch '$BRANCH' excluded from rulesets: ${changed:-none}." >> $GITHUB_STEP_SUMMARY diff --git a/.github/actions/github/create-release/action.yml b/.github/actions/github/create-release/action.yml index 0e0015ce..c8425d12 100644 --- a/.github/actions/github/create-release/action.yml +++ b/.github/actions/github/create-release/action.yml @@ -23,18 +23,21 @@ runs: echo "is-preview=${{ inputs.is-preview }}" echo "preview-argument=${{ inputs.is-preview == 'true' && '--prerelease' || '' }}" echo "notes-start-tag=${{ inputs.notes-start-tag }}" - echo "notes-start-tag-argument="${{ inputs.notes-start-tag != '' && '--notes-start-tag $(inputs.notes-start-tag)' || '' }}" + echo "notes-start-tag-argument=${{ inputs.notes-start-tag != '' && format('--notes-start-tag {0}', inputs.notes-start-tag) || '' }}" shell: bash - - name: 'Create git tag ${{ env.release-version }}' + - name: 'Configure git identity' + uses: './.github/actions/git/configure-identity' + - name: 'Create git tag ${{ inputs.release-version }}' shell: bash run: | - git tag -a ${{ env.release-version }} -m "${{ env.release-version }}" - - name: 'Create GitHub release PolylineAlgorithm ${{ env.release-version }}' + git tag -a ${{ inputs.release-version }} -m "${{ inputs.release-version }}" + git push origin ${{ inputs.release-version }} + - name: 'Create GitHub release PolylineAlgorithm ${{ inputs.release-version }}' shell: bash env: GH_TOKEN: ${{ github.token }} release-version: ${{ inputs.release-version }} preview-argument: "${{ inputs.is-preview == 'true' && '--prerelease' || '' }}" - notes-start-tag-argument: "${{ inputs.notes-start-tag != '' && '--notes-start-tag $(inputs.notes-start-tag)' || '' }}" + notes-start-tag-argument: "${{ inputs.notes-start-tag != '' && format('--notes-start-tag {0}', inputs.notes-start-tag) || '' }}" run: | gh release create ${{ env.release-version }} --generate-notes --discussion-category "General" ${{ env.preview-argument }} ${{ env.notes-start-tag-argument }} diff --git a/.github/actions/source/compile/action.yml b/.github/actions/source/compile/action.yml index 0af331ee..dbe88dd5 100644 --- a/.github/actions/source/compile/action.yml +++ b/.github/actions/source/compile/action.yml @@ -12,7 +12,7 @@ inputs: file-version: description: 'Assembly file version.' required: true - treat-warnins-as-error: + treat-warnings-as-error: description: 'Treat warnings as errors.' required: true project-path: diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 02a54502..b00ae422 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,11 +4,23 @@ updates: directory: "/" schedule: interval: "weekly" + labels: + - "dependencies" + assignees: + - "petesramek" - package-ecosystem: "nuget" directory: "/" schedule: interval: "weekly" + labels: + - "dependencies" + assignees: + - "petesramek" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + labels: + - "dependencies" + assignees: + - "petesramek" diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..e7b05735 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,23 @@ +changelog: + exclude: + labels: + - ignore-for-release + categories: + - title: Breaking Changes + labels: + - breaking + - title: New Features + labels: + - feat + - enhancement + - title: Bug Fixes + labels: + - fix + - bug + - title: Documentation + labels: + - docs + - documentation + - title: Other Changes + labels: + - '*' diff --git a/.github/workflows/backtrack.yml b/.github/workflows/backtrack.yml index 82f5483b..551c5671 100644 --- a/.github/workflows/backtrack.yml +++ b/.github/workflows/backtrack.yml @@ -23,16 +23,14 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout ${{ github.base_ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ github.base_ref }} fetch-depth: 0 token: ${{ secrets.GH_ADMIN_TOKEN }} - name: 'Configure git' - run: | - git config user.name "$(git log -n 1 --pretty=format:%an)" - git config user.email "$(git log -n 1 --pretty=format:%ae)" + uses: './.github/actions/git/configure-identity' - name: 'Resolve backtrack targets' id: targets @@ -61,40 +59,14 @@ jobs: echo "::warning::Develop branch '${{ steps.targets.outputs.develop-branch }}' not found, skipping backtrack." fi - - name: 'Lock base branch' - id: lock-base - uses: './.github/actions/github/branch-protection/lock' - with: - branch: ${{ github.base_ref }} - token: ${{ secrets.GH_ADMIN_TOKEN }} - lock-branch: 'true' - - - name: 'Lock preview branch' - id: lock-preview - if: ${{ steps.targets.outputs.preview-branch != '' }} - uses: './.github/actions/github/branch-protection/lock' - with: - branch: ${{ steps.targets.outputs.preview-branch }} - token: ${{ secrets.GH_ADMIN_TOKEN }} - lock-branch: 'true' - - - name: 'Lock develop branch' - id: lock-develop - if: ${{ steps.check-develop.outputs.exists == 'true' }} - uses: './.github/actions/github/branch-protection/lock' - with: - branch: ${{ steps.targets.outputs.develop-branch }} - token: ${{ secrets.GH_ADMIN_TOKEN }} - lock-branch: 'true' - - name: 'Unlock preview branch for merge' + id: unlock-preview if: ${{ steps.targets.outputs.preview-branch != '' }} - uses: './.github/actions/github/branch-protection/lock' + uses: './.github/actions/github/branch-protection/unlock' with: branch: ${{ steps.targets.outputs.preview-branch }} token: ${{ secrets.GH_ADMIN_TOKEN }} - lock-branch: 'false' - skip-pull-request-reviews: 'true' + fail-on-error: 'true' - name: 'Backtrack: merge ${{ github.base_ref }} into ${{ steps.targets.outputs.preview-branch }}' if: ${{ steps.targets.outputs.preview-branch != '' }} @@ -107,13 +79,13 @@ jobs: --field commit_message="Backtrack: merge ${{ github.base_ref }} into ${{ steps.targets.outputs.preview-branch }}" - name: 'Unlock develop branch for merge' + id: unlock-develop if: ${{ steps.check-develop.outputs.exists == 'true' }} - uses: './.github/actions/github/branch-protection/lock' + uses: './.github/actions/github/branch-protection/unlock' with: branch: ${{ steps.targets.outputs.develop-branch }} token: ${{ secrets.GH_ADMIN_TOKEN }} - lock-branch: 'false' - skip-pull-request-reviews: 'true' + fail-on-error: 'true' - name: 'Backtrack: merge ${{ steps.targets.outputs.merge-source }} into ${{ steps.targets.outputs.develop-branch }}' if: ${{ steps.check-develop.outputs.exists == 'true' }} @@ -125,29 +97,21 @@ jobs: --field head="${{ steps.targets.outputs.merge-source }}" \ --field commit_message="Backtrack: merge ${{ steps.targets.outputs.merge-source }} into ${{ steps.targets.outputs.develop-branch }}" - - name: 'Restore protection: base branch' - if: ${{ always() && steps.lock-base.outcome == 'success' }} - uses: './.github/actions/github/branch-protection/unlock' - with: - branch: ${{ github.base_ref }} - token: ${{ secrets.GH_ADMIN_TOKEN }} - fail-on-error: 'true' - - name: 'Restore protection: preview branch' - if: ${{ always() && steps.lock-preview.outcome == 'success' }} - uses: './.github/actions/github/branch-protection/unlock' + if: ${{ always() && steps.unlock-preview.outputs.ruleset-ids != '' }} + uses: './.github/actions/github/branch-protection/lock' with: branch: ${{ steps.targets.outputs.preview-branch }} token: ${{ secrets.GH_ADMIN_TOKEN }} - fail-on-error: 'true' + ruleset-ids: ${{ steps.unlock-preview.outputs.ruleset-ids }} - name: 'Restore protection: develop branch' - if: ${{ always() && steps.lock-develop.outcome == 'success' }} - uses: './.github/actions/github/branch-protection/unlock' + if: ${{ always() && steps.unlock-develop.outputs.ruleset-ids != '' }} + uses: './.github/actions/github/branch-protection/lock' with: branch: ${{ steps.targets.outputs.develop-branch }} token: ${{ secrets.GH_ADMIN_TOKEN }} - fail-on-error: 'true' + ruleset-ids: ${{ steps.unlock-develop.outputs.ruleset-ids }} - name: 'Write backtrack summary' if: ${{ always() }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 047d4afd..4481a06c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build +name: Build on: workflow_dispatch: @@ -54,9 +54,9 @@ jobs: release-version: ${{ steps.format-version.outputs.release-version }} steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Setup .NET ${{ env.dotnet-sdk-version }}' - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} - name: 'Extract version from branch name' @@ -102,7 +102,7 @@ jobs: steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Format source code' uses: ./.github/actions/source/format @@ -121,7 +121,7 @@ jobs: steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Compile source code' uses: ./.github/actions/source/compile @@ -130,7 +130,7 @@ jobs: assembly-version: ${{ env.assembly-version }} assembly-informational-version: ${{ env.assembly-informational-version }} file-version: ${{ env.file-version }} - treat-warnins-as-error: ${{ needs.workflow-variables.outputs.is-release }} + treat-warnings-as-error: ${{ needs.workflow-variables.outputs.is-release }} test: name: 'Run tests' @@ -138,10 +138,10 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Setup .NET' - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} @@ -184,10 +184,10 @@ jobs: package-artifact-name: ${{ env.package-artifact-name }} steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} @@ -217,10 +217,10 @@ jobs: environment: 'Development' steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} @@ -239,10 +239,11 @@ jobs: needs: [versioning, build] env: friendly-version: ${{ needs.versioning.outputs.friendly-version }} + if: ${{ needs.versioning.outputs.friendly-version != '0.0' }} runs-on: ubuntu-latest steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Generate assembly metadata' uses: ./.github/actions/documentation/docfx-metadata with: @@ -250,9 +251,23 @@ jobs: docfx-json-manifest: './api-reference/assembly-metadata.json' temporary-directory: './api-reference/temp' output-directory: './api-reference/${{ env.friendly-version }}' + - name: 'Unlock branch for assembly metadata push' + id: unlock-branch + uses: ./.github/actions/github/branch-protection/unlock + with: + branch: ${{ github.ref_name }} + token: ${{ secrets.GH_ADMIN_TOKEN }} + fail-on-error: 'true' - name: 'Push assembly metadata artifact' uses: ./.github/actions/git/push-changes with: artifact-name: 'assembly-metadata' commit-message: 'Updated docs for version ${{ env.friendly-version }}' working-directory: './api-reference/${{ env.friendly-version }}' + - name: 'Restore branch protection' + if: ${{ always() && steps.unlock-branch.outputs.ruleset-ids != '' }} + uses: ./.github/actions/github/branch-protection/lock + with: + branch: ${{ github.ref_name }} + token: ${{ secrets.GH_ADMIN_TOKEN }} + ruleset-ids: ${{ steps.unlock-branch.outputs.ruleset-ids }} diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 6f79680c..26f4052d 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -33,7 +33,7 @@ jobs: target-branch: ${{ steps.calculate.outputs.target-branch }} steps: - name: 'Checkout main' - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: main fetch-depth: 0 @@ -72,7 +72,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout main' - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: main fetch-depth: 1 @@ -102,20 +102,18 @@ jobs: target-branch: ${{ needs.detect-version.outputs.target-branch }} steps: - name: 'Checkout main' - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: main fetch-depth: 0 - name: 'Setup .NET ${{ env.dotnet-sdk-version }}' - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} - name: 'Configure git' - run: | - git config user.name "$(git log -n 1 --pretty=format:%an)" - git config user.email "$(git log -n 1 --pretty=format:%ae)" + uses: './.github/actions/git/configure-identity' - name: 'Create develop branch from main' run: | diff --git a/.github/workflows/promote-branch.yml b/.github/workflows/promote-branch.yml index 8e566ec3..d3599ffb 100644 --- a/.github/workflows/promote-branch.yml +++ b/.github/workflows/promote-branch.yml @@ -40,9 +40,9 @@ jobs: friendly-version: ${{ steps.extract-version.outputs.version }} steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Setup .NET ${{ env.dotnet-sdk-version }}' - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} - name: 'Extract version from branch name' @@ -60,6 +60,8 @@ jobs: target-branch-exists: ${{ steps.check-target-branch-exists.outputs.target-branch-exists }} pull-request-exists: ${{ steps.check-pull-request-exists.outputs.pull-request-exists }} steps: + - name: 'Checkout ${{ github.head_ref || github.ref }}' + uses: actions/checkout@v7 - name: 'Set target branch' id: set-target-branch run: | @@ -154,7 +156,7 @@ jobs: target-branch: ${{ env.target-branch }} steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Check promotion type' if: ${{ (env.promotion-type != 'release') && (env.promotion-type != 'preview') }} run: | @@ -198,9 +200,9 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Setup .NET ${{ env.dotnet-sdk-version }}' - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} - name: 'Create target branch' @@ -211,12 +213,6 @@ jobs: run: | git fetch origin git push origin origin/${{ env.base-branch }}:refs/heads/${{ env.target-branch }} - - name: 'Lock target branch' - if: ${{ needs.workflow-variables.outputs.target-branch-exists == 'false' }} - uses: './.github/actions/github/branch-protection/lock' - with: - branch: ${{ needs.workflow-variables.outputs.target-branch }} - token: ${{ secrets.GH_ADMIN_TOKEN }} - name: 'Create PR: "Promote ${{ env.current-branch }} to ${{ env.target-branch }}"' if: ${{ needs.workflow-variables.outputs.pull-request-exists == 'false' }} env: @@ -224,4 +220,5 @@ jobs: current-branch: ${{ github.ref_name }} target-branch: ${{ needs.workflow-variables.outputs.target-branch }} run: | + git fetch origin ${{ env.target-branch }} gh pr create --title "Promote ${{ env.current-branch }} to ${{ env.target-branch }}" --fill --base ${{ env.target-branch }} --head ${{ env.current-branch }} diff --git a/.github/workflows/publish-documentation.yml b/.github/workflows/publish-documentation.yml index 85d8f21a..aff0bca9 100644 --- a/.github/workflows/publish-documentation.yml +++ b/.github/workflows/publish-documentation.yml @@ -1,4 +1,4 @@ -name: 'Publish documentation' +name: 'Publish documentation' on: workflow_dispatch: @@ -51,11 +51,11 @@ jobs: steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 # Ensure the full git history is available for versioning - name: 'Setup .NET ${{ env.dotnet-sdk-version }}' - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} - name: 'Extract version from branch name' @@ -72,10 +72,10 @@ jobs: friendly-version: ${{ needs.versioning.outputs.friendly-version }} steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Setup .NET ${{ env.dotnet-sdk-version }}' - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} @@ -96,6 +96,21 @@ jobs: run: | cp -r api-reference/guide api-reference/${{ env.friendly-version }}/guide + - name: 'Download benchmark results' + uses: actions/download-artifact@v8 + with: + pattern: benchmark-* + path: /tmp/benchmarks + merge-multiple: true + + - name: 'Inject benchmark results into guide for v${{ env.friendly-version }}' + shell: bash + run: | + find /tmp/benchmarks -name "*-report-github.md" | sort | xargs -r cat > /tmp/benchmark_results.md + awk '/{benchmarks_section}/{while((getline line < "/tmp/benchmark_results.md") > 0) print line; close("/tmp/benchmark_results.md"); next} {print}' \ + "api-reference/${{ env.friendly-version }}/guide/benchmarks.md" > /tmp/benchmarks.md + mv /tmp/benchmarks.md "api-reference/${{ env.friendly-version }}/guide/benchmarks.md" + - name: 'Discover all versions' id: discover-versions shell: bash @@ -151,15 +166,15 @@ jobs: { echo "## Released Versions" echo "" - echo "| Version | Guide | API Reference |" - echo "|---------|-------|---------------|" + echo "| Version | Guide | API Reference | Release Notes |" + echo "|---------|-------|---------------|---------------|" for ver in $(echo "${{ steps.discover-versions.outputs.versions }}" | tr ',' '\n' | sort -Vr); do if [ "$ver" = "$latest" ]; then label="v${ver} (latest)" else label="v${ver}" fi - echo "| ${label} | [Guide](${ver}/guide/getting-started.html) | [API Reference](${ver}/PolylineAlgorithm.html) |" + echo "| ${label} | [Guide](${ver}/guide/getting-started.html) | [API Reference](${ver}/PolylineAlgorithm.html) | [Release Notes](https://github.com/petesramek/polyline-algorithm-csharp/releases/tag/v${ver}) |" done } > /tmp/versions_section.md awk '/{versions_section}/{while((getline line < "/tmp/versions_section.md") > 0) print line; close("/tmp/versions_section.md"); next} {print}' \ @@ -172,7 +187,7 @@ jobs: run: docfx build api-reference/api-reference.json - name: 'Upload artifact' - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: name: github-pages path: './api-reference/_docs' diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 29392ddf..e6ccf296 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -8,8 +8,9 @@ on: - synchronize - reopened branches: + - 'develop/**' - 'preview/**' - - 'release/**' + - 'release/**' permissions: actions: read @@ -17,8 +18,8 @@ permissions: id-token: write contents: write -concurrency: - group: pull-request-${{ github.head_ref || github.ref }} +concurrency: + group: pull-request-${{ github.head_ref || github.ref }} cancel-in-progress: true env: @@ -56,9 +57,9 @@ jobs: release-version: ${{ steps.format-version.outputs.release-version }} steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Setup .NET ${{ env.dotnet-sdk-version }}' - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} - name: 'Extract version from branch name' @@ -110,7 +111,7 @@ jobs: steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Compile source code' uses: ./.github/actions/source/compile @@ -119,7 +120,7 @@ jobs: assembly-version: ${{ env.assembly-version }} assembly-informational-version: ${{ env.assembly-informational-version }} file-version: ${{ env.file-version }} - treat-warnins-as-error: ${{ needs.workflow-variables.outputs.is-release }} + treat-warnings-as-error: ${{ needs.workflow-variables.outputs.is-release }} test: name: 'Run tests' @@ -127,10 +128,10 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Setup .NET' - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} @@ -173,10 +174,10 @@ jobs: package-artifact-name: ${{ env.package-artifact-name }} steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} @@ -188,7 +189,7 @@ jobs: - name: Pack with .NET run: | dotnet pack ${{ vars.SRC_DEFAULT_GLOB_PATTERN }} --configuration ${{ env.build-configuration }} /p:Platform="${{ env.build-platform }}" /p:PackageVersion=${{ env.release-version }} /p:Version=${{ env.assembly-version }} /p:AssemblyInformationalVersion=${{ env.assembly-informational-version }} /p:FileVersion=${{ env.file-version }} --output ${{ runner.temp }}/${{ env.nuget-packages-directory }} - + - name: Upload Package uses: actions/upload-artifact@v7 with: @@ -206,10 +207,10 @@ jobs: environment: 'Development' steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} @@ -221,39 +222,20 @@ jobs: nuget-feed-api-key: ${{ secrets.NUGET_PACKAGE_FEED_API_KEY }} nuget-feed-server: 'AzureArtifacts' working-directory: ${{ runner.temp }}/${{ env.nuget-packages-directory }} - dotnet-sdk-version: ${{ env.dotnet-sdk-version }}' + dotnet-sdk-version: ${{ env.dotnet-sdk-version }} - benchmark: - if: ${{ github.env.is_release || vars.BENCHMARKDOTNET_RUN_OVERRIDE == 'true' }} - name: Benchmark with .NET CLI on ${{ matrix.os }} + security: + name: 'Check for vulnerable packages' needs: [build] - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 - - name: Install .NET SDK - uses: actions/setup-dotnet@v5 - with: - dotnet-version: | - 8.x - 10.x - - name: Download Build - uses: actions/download-artifact@v8 - with: - name: build - - name: Benchmark - working-directory: ${{ vars.BENCHMARKDOTNET_WORKING_DIRECTORY }} - run: dotnet run --configuration ${{ env.build-configuration }} /p:Platform=${{ env.build-platform }} --framework ${{ vars.DEFAULT_BUILD_FRAMEWORK }} --runtimes ${{ vars.BENCHMARKDOTNET_RUNTIMES }} --filter ${{ vars.BENCHMARKDOTNET_FILTER }} --artifacts ${{ runner.temp }}/benchmarks/ --exporters GitHub --memory --iterationTime 100 --join - - name: Upload Benchmark Results - uses: actions/upload-artifact@v7 + uses: actions/checkout@v7 + + - name: 'Setup .NET' + uses: actions/setup-dotnet@v6 with: - name: benchmark-${{ matrix.os }} - path: | - ${{ runner.temp }}/benchmarks/**/*-report-github.md - - name: Write Benchmark Summary - shell: bash - run: cat **/*-report-github.md > $GITHUB_STEP_SUMMARY - working-directory: ${{ runner.temp }}/benchmarks/ + dotnet-version: ${{ env.dotnet-sdk-version }} + + - name: 'Check for vulnerable packages' + run: dotnet list src/PolylineAlgorithm/PolylineAlgorithm.csproj package --vulnerable --include-transitive diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8997287..406ae278 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,9 +66,9 @@ jobs: release-version: ${{ steps.format-version.outputs.release-version }} steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Setup .NET ${{ env.dotnet-sdk-version }}' - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} - name: 'Extract version from branch name' @@ -80,7 +80,7 @@ jobs: shell: bash id: create-build-number run: | - git fetch --unshallow --filter=tree:0 + git fetch --unshallow --filter=blob:none build_number=$(git rev-list --count origin/${{ github.ref_name }} ^origin/main) echo "build-number=$build_number" >> $GITHUB_OUTPUT - name: 'Create pre-release tag' @@ -120,7 +120,7 @@ jobs: steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Compile source code' uses: ./.github/actions/source/compile @@ -129,7 +129,7 @@ jobs: assembly-version: ${{ env.assembly-version }} assembly-informational-version: ${{ env.assembly-informational-version }} file-version: ${{ env.file-version }} - treat-warnins-as-error: ${{ needs.workflow-variables.outputs.is-release }} + treat-warnings-as-error: ${{ needs.workflow-variables.outputs.is-release }} test: name: 'Run tests' @@ -137,10 +137,10 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Setup .NET' - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} @@ -169,6 +169,133 @@ jobs: - name: Write code coverage report summary run: cat ${{ steps.code-coverage-report.outputs.code-coverage-report-file }} >> $GITHUB_STEP_SUMMARY + update-api-unshipped: + name: 'Update PublicAPI.Unshipped.txt' + needs: [workflow-variables, validate-release] + # Run even when build fails — the build may fail solely because a dropped preview API is still + # listed in Unshipped.txt (RS0017). This job fixes exactly that, so it must not be gated on build. + if: ${{ needs.workflow-variables.outputs.is-preview == 'true' && needs.validate-release.result == 'success' }} + runs-on: ubuntu-latest + steps: + - name: 'Checkout ${{ github.ref }}' + uses: actions/checkout@v7 + + - name: 'Setup .NET' + uses: actions/setup-dotnet@v6 + with: + dotnet-version: ${{ env.dotnet-sdk-version }} + + - name: 'Snapshot PublicAPI.Shipped.txt' + shell: bash + run: cp src/PolylineAlgorithm/PublicAPI.Shipped.txt src/PolylineAlgorithm/PublicAPI.Shipped.txt.bak + + - name: 'Sync PublicAPI.Unshipped.txt (add new + remove dropped preview APIs)' + shell: bash + run: dotnet format analyzers src/PolylineAlgorithm/PolylineAlgorithm.csproj --diagnostics RS0016 RS0017 + + - name: 'Guard against accidental shipped API removal' + shell: bash + run: | + SHIPPED="src/PolylineAlgorithm/PublicAPI.Shipped.txt" + if ! diff -q "$SHIPPED" "$SHIPPED.bak" > /dev/null 2>&1; then + echo "::error::Breaking change detected — a shipped API was removed from PublicAPI.Shipped.txt." + echo "::error::This requires a major version bump. Reverting the unintended change." + cp "$SHIPPED.bak" "$SHIPPED" + exit 1 + fi + rm -f "$SHIPPED.bak" + + - name: 'Upload updated API files' + uses: actions/upload-artifact@v7 + with: + name: updated-api-unshipped + path: src/PolylineAlgorithm/PublicAPI.Unshipped.txt + + - name: 'Unlock branch for API file sync' + id: unlock-branch + uses: './.github/actions/github/branch-protection/unlock' + with: + branch: ${{ github.ref_name }} + token: ${{ secrets.GH_ADMIN_TOKEN }} + fail-on-error: 'true' + + - name: 'Push updated API files' + uses: './.github/actions/git/push-changes' + with: + artifact-name: updated-api-unshipped + commit-message: 'Sync PublicAPI.Unshipped.txt (add new, remove dropped preview APIs)' + working-directory: src/PolylineAlgorithm + + - name: 'Restore branch protection' + if: ${{ always() && steps.unlock-branch.outputs.ruleset-ids != '' }} + uses: './.github/actions/github/branch-protection/lock' + with: + branch: ${{ github.ref_name }} + token: ${{ secrets.GH_ADMIN_TOKEN }} + ruleset-ids: ${{ steps.unlock-branch.outputs.ruleset-ids }} + + promote-api-files: + name: 'Promote PublicAPI files (Unshipped -> Shipped)' + needs: [workflow-variables, test, validate-release] + if: ${{ needs.workflow-variables.outputs.is-release == 'true' }} + runs-on: ubuntu-latest + steps: + - name: 'Checkout ${{ github.ref }}' + uses: actions/checkout@v7 + + - name: 'Promote Unshipped.txt into Shipped.txt' + id: promote-api + shell: bash + run: | + UNSHIPPED="src/PolylineAlgorithm/PublicAPI.Unshipped.txt" + SHIPPED="src/PolylineAlgorithm/PublicAPI.Shipped.txt" + + # Append every non-blank, non-header line from Unshipped into Shipped + tail -n +2 "$UNSHIPPED" | grep -v '^[[:space:]]*$' >> "$SHIPPED" || true + + # Reset Unshipped to just the nullable-enable header (with BOM to match convention) + printf '\xef\xbb\xbf#nullable enable\n' > "$UNSHIPPED" + + if git diff --quiet -- "$UNSHIPPED" "$SHIPPED"; then + echo "has-changes=false" >> "$GITHUB_OUTPUT" + else + echo "has-changes=true" >> "$GITHUB_OUTPUT" + fi + + - name: 'Upload promoted API files' + if: ${{ steps.promote-api.outputs.has-changes == 'true' }} + uses: actions/upload-artifact@v7 + with: + name: promoted-api-files + path: | + src/PolylineAlgorithm/PublicAPI.Shipped.txt + src/PolylineAlgorithm/PublicAPI.Unshipped.txt + + - name: 'Unlock release branch for API file promotion' + id: unlock-release-branch + if: ${{ steps.promote-api.outputs.has-changes == 'true' }} + uses: './.github/actions/github/branch-protection/unlock' + with: + branch: ${{ github.ref_name }} + token: ${{ secrets.GH_ADMIN_TOKEN }} + fail-on-error: 'true' + + - name: 'Push promoted API files' + if: ${{ steps.promote-api.outputs.has-changes == 'true' }} + uses: './.github/actions/git/push-changes' + with: + artifact-name: promoted-api-files + commit-message: 'Promote PublicAPI.Unshipped.txt into PublicAPI.Shipped.txt for release' + working-directory: src/PolylineAlgorithm + + - name: 'Restore release branch protection' + if: ${{ always() && steps.unlock-release-branch.outputs.ruleset-ids != '' }} + uses: './.github/actions/github/branch-protection/lock' + with: + branch: ${{ github.ref_name }} + token: ${{ secrets.GH_ADMIN_TOKEN }} + ruleset-ids: ${{ steps.unlock-release-branch.outputs.ruleset-ids }} + pack: name: 'Package binaries' needs: [versioning, build, test, validate-release] @@ -183,10 +310,10 @@ jobs: package-artifact-name: ${{ env.package-artifact-name }} steps: - name: 'Checkout ${{ github.base_ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} @@ -207,9 +334,44 @@ jobs: ${{ runner.temp }}/${{ env.nuget-packages-directory }}/**/*.nupkg ${{ runner.temp }}/${{ env.nuget-packages-directory }}/**/*.snupkg + benchmark: + name: Benchmark with .NET CLI on ${{ matrix.os }} + needs: [workflow-variables, build, validate-release] + if: ${{ needs.workflow-variables.outputs.is-release == 'true' || vars.BENCHMARKDOTNET_RUN_OVERRIDE == 'true' }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: 'Checkout ${{ github.head_ref || github.ref }}' + uses: actions/checkout@v7 + - name: Install .NET SDK + uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 8.x + 10.x + - name: Download Build + uses: actions/download-artifact@v8 + with: + name: build + - name: Benchmark + working-directory: ${{ vars.BENCHMARKDOTNET_WORKING_DIRECTORY }} + run: dotnet run --configuration ${{ env.build-configuration }} /p:Platform=${{ env.build-platform }} --framework ${{ vars.DEFAULT_BUILD_FRAMEWORK }} --runtimes ${{ vars.BENCHMARKDOTNET_RUNTIMES }} --filter ${{ vars.BENCHMARKDOTNET_FILTER }} --artifacts ${{ runner.temp }}/benchmarks/ --exporters GitHub --memory --iterationTime 100 --join + - name: Upload Benchmark Results + uses: actions/upload-artifact@v7 + with: + name: benchmark-${{ matrix.os }} + path: | + ${{ runner.temp }}/benchmarks/**/*-report-github.md + - name: Write Benchmark Summary + shell: bash + run: cat **/*-report-github.md > $GITHUB_STEP_SUMMARY + working-directory: ${{ runner.temp }}/benchmarks/ + publish-documentation: name: 'Publish documentation' - needs: [pack, validate-release, workflow-variables] + needs: [pack, benchmark, validate-release, workflow-variables] if: ${{ needs.workflow-variables.outputs.is-release == 'true' }} uses: ./.github/workflows/publish-documentation.yml permissions: @@ -219,18 +381,24 @@ jobs: publish-package: name: 'Publish package' - needs: [pack, validate-release, publish-documentation] - if: ${{ always() && needs.pack.result == 'success' && needs.validate-release.result == 'success' && (needs.publish-documentation.result == 'success' || needs.publish-documentation.result == 'skipped') }} + needs: [pack, validate-release, publish-documentation, update-api-unshipped, promote-api-files] + if: | + always() && + needs.pack.result == 'success' && + needs.validate-release.result == 'success' && + (needs.publish-documentation.result == 'success' || needs.publish-documentation.result == 'skipped') && + (needs.promote-api-files.result == 'success' || needs.promote-api-files.result == 'skipped') && + (needs.update-api-unshipped.result == 'success' || needs.update-api-unshipped.result == 'skipped') env: package-artifact-name: ${{ needs.pack.outputs.package-artifact-name }} runs-on: ubuntu-latest environment: 'NuGet' steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.dotnet-sdk-version }} @@ -247,6 +415,7 @@ jobs: release: name: 'Create release' needs: [workflow-variables, publish-package, validate-release, versioning] + if: ${{ !cancelled() && needs.workflow-variables.outputs.is-release == 'true' && needs.workflow-variables.result == 'success' && needs.publish-package.result == 'success' && needs.validate-release.result == 'success' && needs.versioning.result == 'success' }} runs-on: ubuntu-latest env: release-version: ${{ needs.versioning.outputs.release-version }} @@ -254,7 +423,7 @@ jobs: notes-start-tag: ${{ needs.workflow-variables.outputs.notes-start-tag }} steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-tags: true @@ -272,18 +441,96 @@ jobs: is-preview: ${{ env.is-preview }} notes-start-tag: ${{ steps.determine-notes-start-tag.outputs.notes-start-tag }} + update-changelog: + name: 'Update CHANGELOG.md' + needs: [workflow-variables, release, versioning] + if: ${{ !cancelled() && needs.workflow-variables.outputs.is-release == 'true' && needs.workflow-variables.result == 'success' && needs.publish-package.result == 'success' && needs.validate-release.result == 'success' && needs.versioning.result == 'success' }} + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + release-version: ${{ needs.versioning.outputs.release-version }} + steps: + - name: 'Checkout ${{ github.ref }}' + uses: actions/checkout@v7 + with: + ref: ${{ github.ref }} + + - name: 'Fetch release notes' + shell: bash + run: | + gh release view ${{ env.release-version }} --json body --jq '.body' > /tmp/release-notes.txt + + - name: 'Prepend entry to CHANGELOG.md' + shell: bash + run: | + release_date=$(date -u +%Y-%m-%d) + { + echo "## ${{ env.release-version }} — ${release_date}" + echo "" + cat /tmp/release-notes.txt + echo "" + } > /tmp/new-entry.txt + awk ' + // { + print + print "" + while ((getline line < "/tmp/new-entry.txt") > 0) print line + close("/tmp/new-entry.txt") + next + } + { print } + ' CHANGELOG.md > /tmp/changelog-new.md + mv /tmp/changelog-new.md CHANGELOG.md + + - name: 'Upload CHANGELOG.md' + uses: actions/upload-artifact@v7 + with: + name: changelog + path: CHANGELOG.md + + - name: 'Unlock branch for CHANGELOG.md update' + id: unlock-branch + uses: './.github/actions/github/branch-protection/unlock' + with: + branch: ${{ github.ref_name }} + token: ${{ secrets.GH_ADMIN_TOKEN }} + fail-on-error: 'true' + + - name: 'Push CHANGELOG.md' + uses: './.github/actions/git/push-changes' + with: + artifact-name: changelog + commit-message: 'Update CHANGELOG.md for ${{ env.release-version }}' + working-directory: '.' + + - name: 'Restore branch protection' + if: ${{ always() && steps.unlock-branch.outputs.ruleset-ids != '' }} + uses: './.github/actions/github/branch-protection/lock' + with: + branch: ${{ github.ref_name }} + token: ${{ secrets.GH_ADMIN_TOKEN }} + ruleset-ids: ${{ steps.unlock-branch.outputs.ruleset-ids }} + + - name: 'Write changelog summary' + shell: bash + run: | + echo "✅ CHANGELOG.md updated for **${{ env.release-version }}**." >> $GITHUB_STEP_SUMMARY + merge-to-main: name: 'Merge ${{ github.ref_name }} into main' - needs: [workflow-variables, release, versioning] - if: ${{ needs.workflow-variables.outputs.is-release == 'true' }} + needs: [workflow-variables, release, versioning, update-changelog] + if: ${{ !cancelled() && needs.workflow-variables.outputs.is-release == 'true' && needs.workflow-variables.result == 'success' && needs.publish-package.result == 'success' && needs.validate-release.result == 'success' && needs.versioning.result == 'success' }} runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read env: GH_TOKEN: ${{ github.token }} current-branch: ${{ github.ref_name }} current-version: ${{ needs.versioning.outputs.friendly-version }} steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -332,13 +579,13 @@ jobs: create-support-branch: name: 'Create support branch for ${{ github.ref_name }}' needs: [workflow-variables, release, versioning] - if: ${{ needs.workflow-variables.outputs.is-release == 'true' }} + if: ${{ !cancelled() && needs.workflow-variables.outputs.is-release == 'true' && needs.workflow-variables.result == 'success' && needs.publish-package.result == 'success' && needs.validate-release.result == 'success' && needs.versioning.result == 'success' }} runs-on: ubuntu-latest env: current-version: ${{ needs.versioning.outputs.friendly-version }} steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -358,25 +605,19 @@ jobs: echo "support-branch-exists=false" >> $GITHUB_OUTPUT fi + - name: 'Configure git identity' + if: ${{ steps.check-support-branch.outputs.support-branch-exists == 'false' }} + uses: './.github/actions/git/configure-identity' - name: 'Create support branch' if: ${{ steps.check-support-branch.outputs.support-branch-exists == 'false' }} run: | - git config user.name "$(git log -n 1 --pretty=format:%an)" - git config user.email "$(git log -n 1 --pretty=format:%ae)" git checkout -b "${{ steps.resolve-support-branch.outputs.support-branch }}" git push --set-upstream origin "${{ steps.resolve-support-branch.outputs.support-branch }}" - - name: 'Lock support branch' - if: ${{ steps.check-support-branch.outputs.support-branch-exists == 'false' }} - uses: './.github/actions/github/branch-protection/lock' - with: - branch: ${{ steps.resolve-support-branch.outputs.support-branch }} - token: ${{ secrets.GH_ADMIN_TOKEN }} - - name: 'Write support branch summary' run: | if [[ "${{ steps.check-support-branch.outputs.support-branch-exists }}" == "false" ]]; then - echo "✅ Created and locked support branch **${{ steps.resolve-support-branch.outputs.support-branch }}**." >> $GITHUB_STEP_SUMMARY + echo "✅ Created support branch **${{ steps.resolve-support-branch.outputs.support-branch }}**." >> $GITHUB_STEP_SUMMARY else echo "⏭️ Support branch **${{ steps.resolve-support-branch.outputs.support-branch }}** already exists." >> $GITHUB_STEP_SUMMARY fi diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index cb8bb32a..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,93 +0,0 @@ -# Polyline Algorithm Agents Instructions - -## Purpose - -Instructions for automated agents (bots, CI, and code review tools) and contributors interacting with the Polyline Algorithm library. - ---- - -## General Guidelines - -- All contributions and automation **must adhere to code style** (`.editorconfig`, `dotnet format`). -- **Unit tests** are required for new features and bug fixes (`tests/` directory). -- **Benchmarks** must be updated for performance-impacting changes (`benchmarks/` directory). - ---- - -## Pull Requests - -Agents and contributors should: - -- **Attach benchmark results** for encoding/decoding performance changes -- Document **public API changes** in XML comments and verify updates at [API Reference](https://petesramek.github.io/polyline-algorithm-csharp/) -- Run format and static analysis tools before submitting (`dotnet format`, analyzers) -- Update **README.md** and `/samples` for public API changes - ---- - -## Error Handling and Logging - -- Throw **descriptive exceptions** for invalid input/edge cases -- Use internal logging helpers for operational status (`LogInfoExtensions`, `LogWarningExtensions`) - ---- - -## Encoding/Decoding Agents - -- Use abstraction interfaces (`IPolylineEncoder`, `IPolylineDecoder` if available) -- Prefer extension methods for collections and arrays -- Validate latitude/longitude ranges - ---- - -## Issue and PR Templates - -Agents should reference standardized templates from `.github`. Contributors must use them for new issues or PRs. - ---- - -## Extensibility - -- Add encoding schemes or coordinate types in **separate classes/files** -- Register via factory pattern if supporting multiple algorithms -- Do not mix logic between different polyline versions - ---- - -## Future-proofing - -- Support for precision or custom coordinate fields: update `PolylineEncodingOptions` with clear doc comments - ---- - -## Documentation - -- Keep XML doc comments up-to-date in source files -- API reference is auto-generated and hosted at - [https://petesramek.github.io/polyline-algorithm-csharp/](https://petesramek.github.io/polyline-algorithm-csharp/) -- After public API changes, verify docs render correctly on the website -- Add usage samples in XML comments and `/samples` directory - ---- - -## Agent File Format (for `.github/agents`) - -Each agent instruction file should specify: - -``` -# AGENT INSTRUCTIONS - -- Purpose and scope -- Required tools/commands -- Coding and testing requirements -- Logging/error handling expectations -- Documentation or samples to update -``` - ---- - -## Contact & Questions - -Questions or clarifications: open a GitHub issue and tag `@petesramek`. - ---- diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..f804b404 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,59 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and release notes are generated automatically from merged pull requests. + +See all releases at: + + + +## 1.0.88.25 — 2026-10-29 + + + +## What's Changed +### Other Changes +* Updated documentation by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/1 +* Updated documentation by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/2 +* Documentation updated by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/3 +* test by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/4 +* Removed .(dot) from tokens by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/5 +* Updated path to library in nuspec file by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/7 +* Implementation refactored by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/8 +* Test Pull Request by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/9 +* Updated refrences to packages by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/10 +* Removed file with missing references by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/12 +* just a dumg test to try if vsts is going to restore packages by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/14 +* docfx.json added by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/15 +* added benchmarks by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/16 +* Memory allocation optimized by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/17 +* Release: v1.0 = core rewrite, CI/CD, docs, and test suite overhaul by @github-actions[bot] in https://github.com/petesramek/polyline-algorithm-csharp/pull/166 +* fixes for 1.0 by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/171 +* FIX: workflows by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/173 +* version-snapshot guides alongside API reference, fix index.md links, add released versions table by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/176 +* minor updates to code and docs generation by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/180 +* Fix version switcher URL bug and improve docs navigation/landing page by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/183 +* backtracking by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/189 +* backtracking by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/195 +* backtracking 2 by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/198 +* Release/1.0 by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/200 +* Preview/1.0 by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/202 +* Promote preview/1.0 to release/1.0 by @github-actions[bot] in https://github.com/petesramek/polyline-algorithm-csharp/pull/208 +* Promote preview/1.0 to release/1.0 by @github-actions[bot] in https://github.com/petesramek/polyline-algorithm-csharp/pull/211 +* Promote preview/1.0 to release/1.0 by @github-actions[bot] in https://github.com/petesramek/polyline-algorithm-csharp/pull/215 +* Promote preview/1.0 to release/1.0 by @github-actions[bot] in https://github.com/petesramek/polyline-algorithm-csharp/pull/245 +* Fixed release workflow issues by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/276 +* Fixed unshipedd.txt update by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/279 +* Fixed release promotion of unshipped to shipped api by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/282 +* Fix release API promotion: use rulesets instead of branch protection by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/285 +* Fix skipped release job condition, reverted package versioning by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/288 +* Minor changes to docs and pipelines. by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/295 +* Docs removed version 0.0, fixed build action by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/300 +* Refactor implementation, update benchmarks, and improve project structure by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/303 +* Fix release CHANGELOG push blocked by branch rules- #306 by @petesramek in https://github.com/petesramek/polyline-algorithm-csharp/pull/308 + + +**Full Changelog**: https://github.com/petesramek/polyline-algorithm-csharp/commits/1.0.88.25 + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..d0b9d1d8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,47 @@ +# Contributor Covenant Code of Conduct + +## Our Commitment + +We are committed to providing a welcoming and inspiring community for all. We pledge to create an environment in which every individual feels valued, respected, and free from harassment and discrimination. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +- Using welcoming and inclusive language +- Being respectful of differing opinions, viewpoints, and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement + +Community leaders are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Reporting + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainer responsible for enforcement. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/Directory.Build.props b/Directory.Build.props index 96ebb124..80c3886e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,6 +6,8 @@ enable true en + true + embedded @@ -20,6 +22,7 @@ + diff --git a/PolylineAlgorithm.slnx b/PolylineAlgorithm.slnx index 29fa9445..3c54b2a7 100644 --- a/PolylineAlgorithm.slnx +++ b/PolylineAlgorithm.slnx @@ -7,6 +7,7 @@ + diff --git a/README.md b/README.md index 9dfb8a18..0555c16f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # PolylineAlgorithm for .NET -Lightweight .NET Standard 2.1 library implementing Google-compliant Encoded Polyline Algorithm with strong input validation, modern API patterns, and extensibility for custom coordinate types. +[![NuGet](https://img.shields.io/nuget/v/PolylineAlgorithm)](https://www.nuget.org/packages/PolylineAlgorithm) +[![Build](https://github.com/petesramek/polyline-algorithm-csharp/actions/workflows/build.yml/badge.svg)](https://github.com/petesramek/polyline-algorithm-csharp/actions/workflows/build.yml) +[![License: MIT](https://img.shields.io/github/license/petesramek/polyline-algorithm-csharp)](./LICENSE) + +Google's Encoded Polyline Algorithm compresses sequences of geographic coordinates into a compact ASCII string, widely used in mapping APIs. This library provides a fully compliant .NET implementation with extensible, type-safe encoding and decoding APIs. ## Table of Contents @@ -17,15 +21,16 @@ Lightweight .NET Standard 2.1 library implementing Google-compliant Encoded Poly ## Features - Fully compliant Google Encoded Polyline Algorithm for .NET Standard 2.1+ -- Extensible encoding and decoding APIs for custom coordinate and polyline types (`IPolylineEncoder`, `IPolylineDecoder`, `AbstractPolylineEncoder`, `AbstractPolylineDecoder`) -- Extension methods for encoding from `List` and arrays (`PolylineEncoderExtensions`) -- Robust input validation with descriptive exceptions for malformed/invalid data +- Extensible APIs — implement your own encoder/decoder for any coordinate or polyline type +- Robust input validation with descriptive exceptions for malformed or out-of-range data - Advanced configuration via `PolylineEncodingOptions` (precision, buffer size, logging) -- Logging and diagnostic support for CI/CD and developer diagnostics via `Microsoft.Extensions.Logging` -- Low-level utilities for normalization, validation, encoding and decoding via static `PolylineEncoding` class +- Extension methods for encoding directly from `List` and arrays +- Logging and diagnostic support via `Microsoft.Extensions.Logging` +- Low-level utilities for normalization, validation, and bit-level operations via static `PolylineEncoding` class +- Thread-safe, stateless APIs - Thorough unit tests and benchmarks for correctness and performance - Auto-generated API documentation ([API Reference](https://petesramek.github.io/polyline-algorithm-csharp/)) -- Support for .NET Core, .NET 5+, Xamarin, Unity, Blazor, and other platforms supporting `netstandard2.1` +- Supports .NET Core, .NET 5+, Xamarin, Unity, Blazor, and any platform targeting `netstandard2.1` ## Installation @@ -43,7 +48,19 @@ Install-Package PolylineAlgorithm ## Usage -The library provides abstract base classes to implement your own encoder and decoder for any coordinate and polyline type. +The library provides abstract base classes to implement your own encoder and decoder for any coordinate and polyline type. Inherit from `AbstractPolylineEncoder` or `AbstractPolylineDecoder`, override the coordinate accessors, then call `Encode` or `Decode`. + +### Quick Start + +```csharp +// 1. Implement a minimal encoder (see full example below) +var encoder = new MyPolylineEncoder(); +string encoded = encoder.Encode(coordinates); // e.g. "yseiHoc_MwacOjnwM" + +// 2. Implement a minimal decoder (see full example below) +var decoder = new MyPolylineDecoder(); +IEnumerable<(double Latitude, double Longitude)> decoded = decoder.Decode(encoded); +``` ### Custom encoder and decoder @@ -56,23 +73,9 @@ using PolylineAlgorithm; using PolylineAlgorithm.Abstraction; public sealed class MyPolylineEncoder : AbstractPolylineEncoder<(double Latitude, double Longitude), string> { - public MyPolylineEncoder() - : base() { } - - public MyPolylineEncoder(PolylineEncodingOptions options) - : base(options) { } - - protected override double GetLatitude((double Latitude, double Longitude) coordinate) { - return coordinate.Latitude; - } - - protected override double GetLongitude((double Latitude, double Longitude) coordinate) { - return coordinate.Longitude; - } - - protected override string CreatePolyline(ReadOnlyMemory polyline) { - return polyline.ToString(); - } + protected override double GetLatitude((double Latitude, double Longitude) coordinate) => coordinate.Latitude; + protected override double GetLongitude((double Latitude, double Longitude) coordinate) => coordinate.Longitude; + protected override string CreatePolyline(ReadOnlyMemory polyline) => polyline.ToString(); } ``` @@ -102,19 +105,8 @@ using PolylineAlgorithm; using PolylineAlgorithm.Abstraction; public sealed class MyPolylineDecoder : AbstractPolylineDecoder { - public MyPolylineDecoder() - : base() { } - - public MyPolylineDecoder(PolylineEncodingOptions options) - : base(options) { } - - protected override (double Latitude, double Longitude) CreateCoordinate(double latitude, double longitude) { - return (latitude, longitude); - } - - protected override ReadOnlyMemory GetReadOnlyMemory(in string polyline) { - return polyline.AsMemory(); - } + protected override (double Latitude, double Longitude) CreateCoordinate(double latitude, double longitude) => (latitude, longitude); + protected override ReadOnlyMemory GetReadOnlyMemory(in string polyline) => polyline.AsMemory(); } ``` @@ -136,8 +128,13 @@ Full API docs and guides (auto-generated from source) are available at [API Refe ## Benchmarks -- See `/benchmarks` in the repo for performance evaluation. -- Contributors: Update benchmarks and document results for performance-impacting PRs. +- See [`/benchmarks`](./benchmarks) in the repo for benchmark projects. +- Run benchmarks with: + ```shell + dotnet run --project benchmarks/PolylineAlgorithm.Benchmarks --configuration Release + ``` +- For guidance on writing and interpreting benchmarks, see [docs/benchmarks.md](./docs/benchmarks.md). +- Contributors: update benchmarks and document results for performance-impacting PRs. ## FAQ @@ -173,13 +170,13 @@ A: Currently, only batch encode/decode is supported. For streaming scenarios, im ## Contributing -- Follow code style and PR instructions in [AGENTS.md](./AGENTS.md). +- Follow code style and PR instructions in [CONTRIBUTING.md](./CONTRIBUTING.md). - Ensure all features are covered by tests and XML doc comments. - For questions or suggestions, open an issue and use the provided templates. ## Support -Have a question, bug, or feature request? [Open an issue!](https://github.com/petesramek/polyline-algorithm-csharp/issues) +Have a question, bug, or feature request? [Open an issue](https://github.com/petesramek/polyline-algorithm-csharp/issues/new/choose) — bug report and feature request templates are available to guide you. --- diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..8ca1471a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | --------- | +| Latest | ✅ | +| Older | ❌ | + +Only the latest published version receives security fixes. If you are on an older version, please upgrade. + +## Reporting a Vulnerability + +**Please do not open a public GitHub issue for security vulnerabilities.** + +Report security issues privately via [GitHub Security Advisories](https://github.com/petesramek/polyline-algorithm-csharp/security/advisories/new). + +Include: +- A description of the vulnerability +- Steps to reproduce or a proof-of-concept +- Potential impact assessment + +You can expect an initial response within 5 business days. Once confirmed, a fix will be prioritized and a patch release issued. You will be credited in the release notes unless you prefer to remain anonymous. diff --git a/api-reference/1.0/PolylineAlgorithm.Abstraction.AbstractPolylineEncoder-2.yml b/api-reference/1.0/PolylineAlgorithm.Abstraction.AbstractPolylineEncoder-2.yml index ec0a65e9..8a0ea10f 100644 --- a/api-reference/1.0/PolylineAlgorithm.Abstraction.AbstractPolylineEncoder-2.yml +++ b/api-reference/1.0/PolylineAlgorithm.Abstraction.AbstractPolylineEncoder-2.yml @@ -106,7 +106,7 @@ body: - h2: Methods - api3: CreatePolyline(ReadOnlyMemory) id: PolylineAlgorithm_Abstraction_AbstractPolylineEncoder_2_CreatePolyline_System_ReadOnlyMemory_System_Char__ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Abstraction/AbstractPolylineEncoder.cs#L174 + src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Abstraction/AbstractPolylineEncoder.cs#L172 metadata: uid: PolylineAlgorithm.Abstraction.AbstractPolylineEncoder`2.CreatePolyline(System.ReadOnlyMemory{System.Char}) commentId: M:PolylineAlgorithm.Abstraction.AbstractPolylineEncoder`2.CreatePolyline(System.ReadOnlyMemory{System.Char}) @@ -176,7 +176,7 @@ body: description: Thrown when the internal encoding buffer cannot accommodate the encoded value. - api3: GetLatitude(TCoordinate) id: PolylineAlgorithm_Abstraction_AbstractPolylineEncoder_2_GetLatitude__0_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Abstraction/AbstractPolylineEncoder.cs#L194 + src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Abstraction/AbstractPolylineEncoder.cs#L192 metadata: uid: PolylineAlgorithm.Abstraction.AbstractPolylineEncoder`2.GetLatitude(`0) commentId: M:PolylineAlgorithm.Abstraction.AbstractPolylineEncoder`2.GetLatitude(`0) @@ -196,7 +196,7 @@ body: description: The latitude value as a . - api3: GetLongitude(TCoordinate) id: PolylineAlgorithm_Abstraction_AbstractPolylineEncoder_2_GetLongitude__0_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Abstraction/AbstractPolylineEncoder.cs#L184 + src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Abstraction/AbstractPolylineEncoder.cs#L182 metadata: uid: PolylineAlgorithm.Abstraction.AbstractPolylineEncoder`2.GetLongitude(`0) commentId: M:PolylineAlgorithm.Abstraction.AbstractPolylineEncoder`2.GetLongitude(`0) diff --git a/api-reference/1.0/PolylineAlgorithm.Extensions.PolylineDecoderExtensions.yml b/api-reference/1.0/PolylineAlgorithm.Extensions.PolylineDecoderExtensions.yml index 4b97f584..79946b09 100644 --- a/api-reference/1.0/PolylineAlgorithm.Extensions.PolylineDecoderExtensions.yml +++ b/api-reference/1.0/PolylineAlgorithm.Extensions.PolylineDecoderExtensions.yml @@ -46,7 +46,10 @@ body: uid: PolylineAlgorithm.Extensions.PolylineDecoderExtensions.Decode``1(PolylineAlgorithm.Abstraction.IPolylineDecoder{System.String,``0},System.Char[]) commentId: M:PolylineAlgorithm.Extensions.PolylineDecoderExtensions.Decode``1(PolylineAlgorithm.Abstraction.IPolylineDecoder{System.String,``0},System.Char[]) - markdown: Decodes an encoded polyline represented as a character array into a sequence of geographic coordinates. -- code: public static IEnumerable Decode(this IPolylineDecoder decoder, char[] polyline) +- code: >- + [SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "It is guarder using ExceptionGuard.ThrowArgumentNull(nameof(decoder));")] + + public static IEnumerable Decode(this IPolylineDecoder decoder, char[] polyline) - h4: Parameters - parameters: - name: decoder @@ -89,12 +92,15 @@ body: description: Thrown when decoder or polyline is null. - api3: Decode(IPolylineDecoder, ReadOnlyMemory) id: PolylineAlgorithm_Extensions_PolylineDecoderExtensions_Decode__1_PolylineAlgorithm_Abstraction_IPolylineDecoder_System_String___0__System_ReadOnlyMemory_System_Char__ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Extensions/PolylineDecoderExtensions.cs#L61 + src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Extensions/PolylineDecoderExtensions.cs#L62 metadata: uid: PolylineAlgorithm.Extensions.PolylineDecoderExtensions.Decode``1(PolylineAlgorithm.Abstraction.IPolylineDecoder{System.String,``0},System.ReadOnlyMemory{System.Char}) commentId: M:PolylineAlgorithm.Extensions.PolylineDecoderExtensions.Decode``1(PolylineAlgorithm.Abstraction.IPolylineDecoder{System.String,``0},System.ReadOnlyMemory{System.Char}) - markdown: Decodes an encoded polyline represented as a read-only memory of characters into a sequence of geographic coordinates. -- code: public static IEnumerable Decode(this IPolylineDecoder decoder, ReadOnlyMemory polyline) +- code: >- + [SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "It is guarder using ExceptionGuard.ThrowArgumentNull(nameof(decoder));")] + + public static IEnumerable Decode(this IPolylineDecoder decoder, ReadOnlyMemory polyline) - h4: Parameters - parameters: - name: decoder @@ -139,7 +145,7 @@ body: description: Thrown when decoder is null. - api3: Decode(IPolylineDecoder, TValue>, string) id: PolylineAlgorithm_Extensions_PolylineDecoderExtensions_Decode__1_PolylineAlgorithm_Abstraction_IPolylineDecoder_System_ReadOnlyMemory_System_Char____0__System_String_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Extensions/PolylineDecoderExtensions.cs#L86 + src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Extensions/PolylineDecoderExtensions.cs#L88 metadata: uid: PolylineAlgorithm.Extensions.PolylineDecoderExtensions.Decode``1(PolylineAlgorithm.Abstraction.IPolylineDecoder{System.ReadOnlyMemory{System.Char},``0},System.String) commentId: M:PolylineAlgorithm.Extensions.PolylineDecoderExtensions.Decode``1(PolylineAlgorithm.Abstraction.IPolylineDecoder{System.ReadOnlyMemory{System.Char},``0},System.String) @@ -147,7 +153,10 @@ body: Decodes an encoded polyline string into a sequence of geographic coordinates, using a decoder that accepts of . -- code: public static IEnumerable Decode(this IPolylineDecoder, TValue> decoder, string polyline) +- code: >- + [SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "It is guarder using ExceptionGuard.ThrowArgumentNull(nameof(decoder));")] + + public static IEnumerable Decode(this IPolylineDecoder, TValue> decoder, string polyline) - h4: Parameters - parameters: - name: decoder diff --git a/api-reference/1.0/PolylineAlgorithm.Extensions.PolylineEncoderExtensions.yml b/api-reference/1.0/PolylineAlgorithm.Extensions.PolylineEncoderExtensions.yml index aeba23f9..9f9ef3b1 100644 --- a/api-reference/1.0/PolylineAlgorithm.Extensions.PolylineEncoderExtensions.yml +++ b/api-reference/1.0/PolylineAlgorithm.Extensions.PolylineEncoderExtensions.yml @@ -51,6 +51,8 @@ body: [SuppressMessage("Design", "MA0016:Prefer using collection abstraction instead of implementation", Justification = "We need a list as we do need to marshal it as span.")] + [SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "It is guarder using ExceptionGuard.ThrowArgumentNull(nameof(encoder));")] + public static TPolyline Encode(this IPolylineEncoder encoder, List coordinates) - h4: Parameters - parameters: @@ -92,12 +94,15 @@ body: description: Thrown when encoder or coordinates is null. - api3: Encode(IPolylineEncoder, TCoordinate[]) id: PolylineAlgorithm_Extensions_PolylineEncoderExtensions_Encode__2_PolylineAlgorithm_Abstraction_IPolylineEncoder___0___1____0___ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Extensions/PolylineEncoderExtensions.cs#L73 + src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Extensions/PolylineEncoderExtensions.cs#L74 metadata: uid: PolylineAlgorithm.Extensions.PolylineEncoderExtensions.Encode``2(PolylineAlgorithm.Abstraction.IPolylineEncoder{``0,``1},``0[]) commentId: M:PolylineAlgorithm.Extensions.PolylineEncoderExtensions.Encode``2(PolylineAlgorithm.Abstraction.IPolylineEncoder{``0,``1},``0[]) - markdown: Encodes an array of TCoordinate instances into an encoded polyline. -- code: public static TPolyline Encode(this IPolylineEncoder encoder, TCoordinate[] coordinates) +- code: >- + [SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "It is guarder using ExceptionGuard.ThrowArgumentNull(nameof(encoder));")] + + public static TPolyline Encode(this IPolylineEncoder encoder, TCoordinate[] coordinates) - h4: Parameters - parameters: - name: encoder diff --git a/api-reference/1.0/PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.yml b/api-reference/1.0/PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.yml deleted file mode 100644 index 45962074..00000000 --- a/api-reference/1.0/PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.yml +++ /dev/null @@ -1,313 +0,0 @@ -### YamlMime:ApiPage -title: Class ExceptionGuard -body: -- api1: Class ExceptionGuard - id: PolylineAlgorithm_Internal_Diagnostics_ExceptionGuard - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Internal/Diagnostics/ExceptionGuard.cs#L29 - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard - commentId: T:PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard -- facts: - - name: Namespace - value: - text: PolylineAlgorithm.Internal.Diagnostics - url: PolylineAlgorithm.Internal.Diagnostics.html - - name: Assembly - value: PolylineAlgorithm.dll -- markdown: Centralizes exception throwing for common validation and error scenarios used across the library. -- code: public static class ExceptionGuard -- h4: Inheritance -- inheritance: - - text: object - url: https://learn.microsoft.com/dotnet/api/system.object - - text: ExceptionGuard - url: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.html -- h4: Inherited Members -- list: - - text: object.Equals(object) - url: https://learn.microsoft.com/dotnet/api/system.object.equals#system-object-equals(system-object) - - text: object.Equals(object, object) - url: https://learn.microsoft.com/dotnet/api/system.object.equals#system-object-equals(system-object-system-object) - - text: object.GetHashCode() - url: https://learn.microsoft.com/dotnet/api/system.object.gethashcode - - text: object.GetType() - url: https://learn.microsoft.com/dotnet/api/system.object.gettype - - text: object.MemberwiseClone() - url: https://learn.microsoft.com/dotnet/api/system.object.memberwiseclone - - text: object.ReferenceEquals(object, object) - url: https://learn.microsoft.com/dotnet/api/system.object.referenceequals - - text: object.ToString() - url: https://learn.microsoft.com/dotnet/api/system.object.tostring -- h2: Remarks -- markdown: >- - Methods in this class are intentionally small and annotated so that they can act as single - - call sites for throwing exceptions (improving inlining and stack traces). Many members have - - attributes to avoid polluting callers' stack traces (__StackTraceHidden__ on supported targets) - - or to prevent inlining on older targets. -- h2: Methods -- api3: StackAllocLimitMustBeEqualOrGreaterThan(int, string) - id: PolylineAlgorithm_Internal_Diagnostics_ExceptionGuard_StackAllocLimitMustBeEqualOrGreaterThan_System_Int32_System_String_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Internal/Diagnostics/ExceptionGuard.cs#L97 - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.StackAllocLimitMustBeEqualOrGreaterThan(System.Int32,System.String) - commentId: M:PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.StackAllocLimitMustBeEqualOrGreaterThan(System.Int32,System.String) -- markdown: Throws an when a stack allocation limit is below the required minimum. -- code: >- - [DoesNotReturn] - - public static void StackAllocLimitMustBeEqualOrGreaterThan(int minValue, string paramName) -- h4: Parameters -- parameters: - - name: minValue - type: - - text: int - url: https://learn.microsoft.com/dotnet/api/system.int32 - description: Minimum required stack allocation limit. - - name: paramName - type: - - text: string - url: https://learn.microsoft.com/dotnet/api/system.string - description: Name of the parameter representing the limit. -- api3: ThrowArgumentCannotBeEmptyEnumerationMessage(string) - id: PolylineAlgorithm_Internal_Diagnostics_ExceptionGuard_ThrowArgumentCannotBeEmptyEnumerationMessage_System_String_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Internal/Diagnostics/ExceptionGuard.cs#L111 - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowArgumentCannotBeEmptyEnumerationMessage(System.String) - commentId: M:PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowArgumentCannotBeEmptyEnumerationMessage(System.String) -- markdown: Throws an when an enumeration argument is empty but must contain at least one element. -- code: >- - [DoesNotReturn] - - public static void ThrowArgumentCannotBeEmptyEnumerationMessage(string paramName) -- h4: Parameters -- parameters: - - name: paramName - type: - - text: string - url: https://learn.microsoft.com/dotnet/api/system.string - description: Name of the parameter representing the enumeration. -- api3: ThrowArgumentNull(string) - id: PolylineAlgorithm_Internal_Diagnostics_ExceptionGuard_ThrowArgumentNull_System_String_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Internal/Diagnostics/ExceptionGuard.cs#L51 - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowArgumentNull(System.String) - commentId: M:PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowArgumentNull(System.String) -- markdown: Throws an for a null argument. -- code: >- - [DoesNotReturn] - - public static void ThrowArgumentNull(string paramName) -- h4: Parameters -- parameters: - - name: paramName - type: - - text: string - url: https://learn.microsoft.com/dotnet/api/system.string - description: Name of the parameter that was null. -- api3: ThrowBufferOverflow(string) - id: PolylineAlgorithm_Internal_Diagnostics_ExceptionGuard_ThrowBufferOverflow_System_String_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Internal/Diagnostics/ExceptionGuard.cs#L65 - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowBufferOverflow(System.String) - commentId: M:PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowBufferOverflow(System.String) -- markdown: Throws an with a provided message. -- code: >- - [DoesNotReturn] - - public static void ThrowBufferOverflow(string message) -- h4: Parameters -- parameters: - - name: message - type: - - text: string - url: https://learn.microsoft.com/dotnet/api/system.string - description: Message that describes the overflow condition. -- api3: ThrowCoordinateValueOutOfRange(double, double, double, string) - id: PolylineAlgorithm_Internal_Diagnostics_ExceptionGuard_ThrowCoordinateValueOutOfRange_System_Double_System_Double_System_Double_System_String_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Internal/Diagnostics/ExceptionGuard.cs#L82 - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowCoordinateValueOutOfRange(System.Double,System.Double,System.Double,System.String) - commentId: M:PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowCoordinateValueOutOfRange(System.Double,System.Double,System.Double,System.String) -- markdown: Throws an when a coordinate value is outside the allowed range. -- code: >- - [DoesNotReturn] - - public static void ThrowCoordinateValueOutOfRange(double value, double min, double max, string paramName) -- h4: Parameters -- parameters: - - name: value - type: - - text: double - url: https://learn.microsoft.com/dotnet/api/system.double - description: The coordinate value that was out of range. - - name: min - type: - - text: double - url: https://learn.microsoft.com/dotnet/api/system.double - description: Inclusive minimum allowed value. - - name: max - type: - - text: double - url: https://learn.microsoft.com/dotnet/api/system.double - description: Inclusive maximum allowed value. - - name: paramName - type: - - text: string - url: https://learn.microsoft.com/dotnet/api/system.string - description: Name of the parameter containing the coordinate. -- api3: ThrowCouldNotWriteEncodedValueToBuffer() - id: PolylineAlgorithm_Internal_Diagnostics_ExceptionGuard_ThrowCouldNotWriteEncodedValueToBuffer - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Internal/Diagnostics/ExceptionGuard.cs#L124 - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowCouldNotWriteEncodedValueToBuffer - commentId: M:PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowCouldNotWriteEncodedValueToBuffer -- markdown: Throws an when an encoded value could not be written to the destination buffer. -- code: >- - [DoesNotReturn] - - public static void ThrowCouldNotWriteEncodedValueToBuffer() -- api3: ThrowDestinationArrayLengthMustBeEqualOrGreaterThanPolylineLength(int, int, string) - id: PolylineAlgorithm_Internal_Diagnostics_ExceptionGuard_ThrowDestinationArrayLengthMustBeEqualOrGreaterThanPolylineLength_System_Int32_System_Int32_System_String_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Internal/Diagnostics/ExceptionGuard.cs#L140 - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowDestinationArrayLengthMustBeEqualOrGreaterThanPolylineLength(System.Int32,System.Int32,System.String) - commentId: M:PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowDestinationArrayLengthMustBeEqualOrGreaterThanPolylineLength(System.Int32,System.Int32,System.String) -- markdown: Throws an when a destination array is not large enough to contain the polyline data. -- code: >- - [DoesNotReturn] - - public static void ThrowDestinationArrayLengthMustBeEqualOrGreaterThanPolylineLength(int destinationLength, int polylineLength, string paramName) -- h4: Parameters -- parameters: - - name: destinationLength - type: - - text: int - url: https://learn.microsoft.com/dotnet/api/system.int32 - description: The length of the destination array. - - name: polylineLength - type: - - text: int - url: https://learn.microsoft.com/dotnet/api/system.int32 - description: The required polyline length. - - name: paramName - type: - - text: string - url: https://learn.microsoft.com/dotnet/api/system.string - description: Name of the parameter representing the destination array. -- api3: ThrowInvalidPolylineBlockTerminator() - id: PolylineAlgorithm_Internal_Diagnostics_ExceptionGuard_ThrowInvalidPolylineBlockTerminator - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Internal/Diagnostics/ExceptionGuard.cs#L212 - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowInvalidPolylineBlockTerminator - commentId: M:PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowInvalidPolylineBlockTerminator -- markdown: Throws an when the polyline block terminator is invalid. -- code: >- - [DoesNotReturn] - - public static void ThrowInvalidPolylineBlockTerminator() -- api3: ThrowInvalidPolylineCharacter(char, int) - id: PolylineAlgorithm_Internal_Diagnostics_ExceptionGuard_ThrowInvalidPolylineCharacter_System_Char_System_Int32_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Internal/Diagnostics/ExceptionGuard.cs#L171 - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowInvalidPolylineCharacter(System.Char,System.Int32) - commentId: M:PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowInvalidPolylineCharacter(System.Char,System.Int32) -- markdown: Throws an when an unexpected character is encountered in the polyline. -- code: >- - [DoesNotReturn] - - public static void ThrowInvalidPolylineCharacter(char character, int position) -- h4: Parameters -- parameters: - - name: character - type: - - text: char - url: https://learn.microsoft.com/dotnet/api/system.char - description: The invalid character. - - name: position - type: - - text: int - url: https://learn.microsoft.com/dotnet/api/system.int32 - description: Position in the polyline where the character was found. -- api3: ThrowInvalidPolylineFormat(long) - id: PolylineAlgorithm_Internal_Diagnostics_ExceptionGuard_ThrowInvalidPolylineFormat_System_Int64_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Internal/Diagnostics/ExceptionGuard.cs#L199 - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowInvalidPolylineFormat(System.Int64) - commentId: M:PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowInvalidPolylineFormat(System.Int64) -- markdown: Throws an when the polyline format is malformed. -- code: >- - [DoesNotReturn] - - public static void ThrowInvalidPolylineFormat(long position) -- h4: Parameters -- parameters: - - name: position - type: - - text: long - url: https://learn.microsoft.com/dotnet/api/system.int64 - description: Approximate position where the polyline became malformed. -- api3: ThrowInvalidPolylineLength(int, int) - id: PolylineAlgorithm_Internal_Diagnostics_ExceptionGuard_ThrowInvalidPolylineLength_System_Int32_System_Int32_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Internal/Diagnostics/ExceptionGuard.cs#L156 - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowInvalidPolylineLength(System.Int32,System.Int32) - commentId: M:PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowInvalidPolylineLength(System.Int32,System.Int32) -- markdown: Throws an when the polyline length is invalid. -- code: >- - [DoesNotReturn] - - public static void ThrowInvalidPolylineLength(int length, int min) -- h4: Parameters -- parameters: - - name: length - type: - - text: int - url: https://learn.microsoft.com/dotnet/api/system.int32 - description: The invalid length. - - name: min - type: - - text: int - url: https://learn.microsoft.com/dotnet/api/system.int32 - description: The minimum required length. -- api3: ThrowNotFiniteNumber(string) - id: PolylineAlgorithm_Internal_Diagnostics_ExceptionGuard_ThrowNotFiniteNumber_System_String_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Internal/Diagnostics/ExceptionGuard.cs#L37 - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowNotFiniteNumber(System.String) - commentId: M:PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowNotFiniteNumber(System.String) -- markdown: Throws an when a numeric argument is not a finite value. -- code: >- - [DoesNotReturn] - - public static void ThrowNotFiniteNumber(string paramName) -- h4: Parameters -- parameters: - - name: paramName - type: - - text: string - url: https://learn.microsoft.com/dotnet/api/system.string - description: Name of the parameter that had a non-finite value. -- api3: ThrowPolylineBlockTooLong(int) - id: PolylineAlgorithm_Internal_Diagnostics_ExceptionGuard_ThrowPolylineBlockTooLong_System_Int32_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/Internal/Diagnostics/ExceptionGuard.cs#L185 - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowPolylineBlockTooLong(System.Int32) - commentId: M:PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.ThrowPolylineBlockTooLong(System.Int32) -- markdown: Throws an when a polyline block is longer than allowed. -- code: >- - [DoesNotReturn] - - public static void ThrowPolylineBlockTooLong(int position) -- h4: Parameters -- parameters: - - name: position - type: - - text: int - url: https://learn.microsoft.com/dotnet/api/system.int32 - description: Position in the polyline where the block exceeded the allowed length. -languageId: csharp -metadata: - description: Centralizes exception throwing for common validation and error scenarios used across the library. diff --git a/api-reference/1.0/PolylineAlgorithm.Internal.Diagnostics.yml b/api-reference/1.0/PolylineAlgorithm.Internal.Diagnostics.yml deleted file mode 100644 index dabb499a..00000000 --- a/api-reference/1.0/PolylineAlgorithm.Internal.Diagnostics.yml +++ /dev/null @@ -1,15 +0,0 @@ -### YamlMime:ApiPage -title: Namespace PolylineAlgorithm.Internal.Diagnostics -body: -- api1: Namespace PolylineAlgorithm.Internal.Diagnostics - id: PolylineAlgorithm_Internal_Diagnostics - metadata: - uid: PolylineAlgorithm.Internal.Diagnostics - commentId: N:PolylineAlgorithm.Internal.Diagnostics -- h3: Classes -- parameters: - - type: - text: ExceptionGuard - url: PolylineAlgorithm.Internal.Diagnostics.ExceptionGuard.html - description: Centralizes exception throwing for common validation and error scenarios used across the library. -languageId: csharp diff --git a/api-reference/1.0/PolylineAlgorithm.PolylineEncoding.yml b/api-reference/1.0/PolylineAlgorithm.PolylineEncoding.yml index 127dbf53..aceb05a1 100644 --- a/api-reference/1.0/PolylineAlgorithm.PolylineEncoding.yml +++ b/api-reference/1.0/PolylineAlgorithm.PolylineEncoding.yml @@ -50,7 +50,7 @@ body: - h2: Methods - api3: Denormalize(int, uint) id: PolylineAlgorithm_PolylineEncoding_Denormalize_System_Int32_System_UInt32_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/PolylineEncoding.cs#L121 + src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/PolylineEncoding.cs#L122 metadata: uid: PolylineAlgorithm.PolylineEncoding.Denormalize(System.Int32,System.UInt32) commentId: M:PolylineAlgorithm.PolylineEncoding.Denormalize(System.Int32,System.UInt32) @@ -117,7 +117,7 @@ body: description: Thrown if the arithmetic operation overflows during conversion. - api3: GetRequiredBufferSize(int) id: PolylineAlgorithm_PolylineEncoding_GetRequiredBufferSize_System_Int32_ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/PolylineEncoding.cs#L297 + src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/PolylineEncoding.cs#L298 metadata: uid: PolylineAlgorithm.PolylineEncoding.GetRequiredBufferSize(System.Int32) commentId: M:PolylineAlgorithm.PolylineEncoding.GetRequiredBufferSize(System.Int32) @@ -268,7 +268,7 @@ body: description: Thrown when the normalized result exceeds the range of a 32-bit signed integer during the conversion from double to int. - api3: TryReadValue(ref int, ReadOnlyMemory, ref int) id: PolylineAlgorithm_PolylineEncoding_TryReadValue_System_Int32__System_ReadOnlyMemory_System_Char__System_Int32__ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/PolylineEncoding.cs#L168 + src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/PolylineEncoding.cs#L169 metadata: uid: PolylineAlgorithm.PolylineEncoding.TryReadValue(System.Int32@,System.ReadOnlyMemory{System.Char},System.Int32@) commentId: M:PolylineAlgorithm.PolylineEncoding.TryReadValue(System.Int32@,System.ReadOnlyMemory{System.Char},System.Int32@) @@ -328,7 +328,7 @@ body:

- api3: TryWriteValue(int, Span, ref int) id: PolylineAlgorithm_PolylineEncoding_TryWriteValue_System_Int32_System_Span_System_Char__System_Int32__ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/PolylineEncoding.cs#L236 + src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/PolylineEncoding.cs#L237 metadata: uid: PolylineAlgorithm.PolylineEncoding.TryWriteValue(System.Int32,System.Span{System.Char},System.Int32@) commentId: M:PolylineAlgorithm.PolylineEncoding.TryWriteValue(System.Int32,System.Span{System.Char},System.Int32@) @@ -405,85 +405,13 @@ body: This method is the inverse of and can be used to encode coordinate deltas for polyline serialization.

-- api3: ValidateBlockLength(ReadOnlySpan) - id: PolylineAlgorithm_PolylineEncoding_ValidateBlockLength_System_ReadOnlySpan_System_Char__ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/PolylineEncoding.cs#L437 - metadata: - uid: PolylineAlgorithm.PolylineEncoding.ValidateBlockLength(System.ReadOnlySpan{System.Char}) - commentId: M:PolylineAlgorithm.PolylineEncoding.ValidateBlockLength(System.ReadOnlySpan{System.Char}) -- markdown: Validates the block structure of a polyline segment, ensuring each encoded value does not exceed 7 characters and the polyline ends correctly. -- code: public static void ValidateBlockLength(ReadOnlySpan polyline) -- h4: Parameters -- parameters: - - name: polyline - type: - - text: ReadOnlySpan - url: https://learn.microsoft.com/dotnet/api/system.readonlyspan-1 - - < - - text: char - url: https://learn.microsoft.com/dotnet/api/system.char - - '>' - description: A span representing the polyline segment to validate. -- h4: Remarks -- markdown: >- -

- - Iterates through the polyline, counting the length of each block (a sequence of characters representing an encoded value). - - Throws an if any block exceeds 7 characters or if the polyline does not end with a valid block terminator. - -

-- h4: Exceptions -- parameters: - - type: - - text: ArgumentException - url: https://learn.microsoft.com/dotnet/api/system.argumentexception - description: Thrown when a block exceeds 7 characters or the polyline does not end with a valid block terminator. -- api3: ValidateCharRange(ReadOnlySpan) - id: PolylineAlgorithm_PolylineEncoding_ValidateCharRange_System_ReadOnlySpan_System_Char__ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/PolylineEncoding.cs#L391 - metadata: - uid: PolylineAlgorithm.PolylineEncoding.ValidateCharRange(System.ReadOnlySpan{System.Char}) - commentId: M:PolylineAlgorithm.PolylineEncoding.ValidateCharRange(System.ReadOnlySpan{System.Char}) -- markdown: Validates that all characters in the polyline segment are within the allowed ASCII range for polyline encoding. -- code: public static void ValidateCharRange(ReadOnlySpan polyline) -- h4: Parameters -- parameters: - - name: polyline - type: - - text: ReadOnlySpan - url: https://learn.microsoft.com/dotnet/api/system.readonlyspan-1 - - < - - text: char - url: https://learn.microsoft.com/dotnet/api/system.char - - '>' - description: A span representing the polyline segment to validate. -- h4: Remarks -- markdown: >- -

- - Uses SIMD vectorization for efficient validation of large spans. Falls back to scalar checks for any block where an invalid character is detected. - -

- -

- - The valid range is from '?' (63) to '_' (95), inclusive. If an invalid character is found, an is thrown. - -

-- h4: Exceptions -- parameters: - - type: - - text: ArgumentException - url: https://learn.microsoft.com/dotnet/api/system.argumentexception - description: Thrown when an invalid character is found in the polyline segment. - api3: ValidateFormat(ReadOnlySpan) id: PolylineAlgorithm_PolylineEncoding_ValidateFormat_System_ReadOnlySpan_System_Char__ - src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/PolylineEncoding.cs#L369 + src: https://github.com/petesramek/polyline-algorithm-csharp/blob/develop/1.0/src/PolylineAlgorithm/PolylineEncoding.cs#L367 metadata: uid: PolylineAlgorithm.PolylineEncoding.ValidateFormat(System.ReadOnlySpan{System.Char}) commentId: M:PolylineAlgorithm.PolylineEncoding.ValidateFormat(System.ReadOnlySpan{System.Char}) -- markdown: Validates the format of a polyline segment, ensuring all characters are valid and block structure is correct. +- markdown: Validates that an encoded polyline segment is structurally well-formed. - code: public static void ValidateFormat(ReadOnlySpan polyline) - h4: Parameters - parameters: @@ -495,32 +423,36 @@ body: - text: char url: https://learn.microsoft.com/dotnet/api/system.char - '>' - description: A span representing the polyline segment to validate. + description: Encoded polyline text to validate. +- h4: Examples +- markdown:
PolylineEncoding.ValidateFormat(encodedPolyline);
- h4: Remarks - markdown: >- -

+ This method performs a single pass over polyline and validates: - This method performs two levels of validation on the provided polyline segment: - -

  1. - Character Range Validation: Checks that every character in the polyline is within the valid ASCII range for polyline encoding ('?' [63] to '_' [95], inclusive). - Uses SIMD acceleration for efficient validation of large segments. + Character range: each character must be in the inclusive algorithm range + from '?' (63) to '_' (95). Invalid characters are reported with + .
  2. - Block Structure Validation: Ensures that each encoded value (block) does not exceed 7 characters and that the polyline ends with a valid block terminator. + Value block termination: each encoded value block must end with a character + whose code is less than . A block longer than + is reported with + , and a missing final block + terminator is reported with .
-

- - If an invalid character or block structure is detected, an is thrown with details about the error. -

+ Complexity: O(n) time and O(1) additional memory. - h4: Exceptions - parameters: - type: - text: ArgumentException url: https://learn.microsoft.com/dotnet/api/system.argumentexception - description: Thrown when an invalid character is found or the block structure is invalid. + description: >- + Thrown when the input contains an invalid character, contains a block longer than allowed, + + or does not end with a valid block terminator (including empty input). languageId: csharp metadata: description: >- diff --git a/api-reference/guide/benchmarks.md b/api-reference/guide/benchmarks.md new file mode 100644 index 00000000..47b453f9 --- /dev/null +++ b/api-reference/guide/benchmarks.md @@ -0,0 +1,11 @@ +--- +title: Benchmarks +--- +# Benchmarks + +Performance benchmarks for **PolylineAlgorithm for .NET** measured using [BenchmarkDotNet](https://benchmarkdotnet.org/). + +> [!NOTE] +> Benchmarks are generated automatically during the release process. Results may vary depending on hardware and runtime version. + +{benchmarks_section} diff --git a/api-reference/guide/toc.yml b/api-reference/guide/toc.yml index 015a9943..e9d9e716 100644 --- a/api-reference/guide/toc.yml +++ b/api-reference/guide/toc.yml @@ -8,3 +8,5 @@ href: sample.md - name: FAQ href: faq.md +- name: Benchmarks + href: benchmarks.md diff --git a/benchmarks/PolylineAlgorithm.Benchmarks/PolylineAlgorithm.Benchmarks.csproj b/benchmarks/PolylineAlgorithm.Benchmarks/PolylineAlgorithm.Benchmarks.csproj index 99b5011f..ce8db1dd 100644 --- a/benchmarks/PolylineAlgorithm.Benchmarks/PolylineAlgorithm.Benchmarks.csproj +++ b/benchmarks/PolylineAlgorithm.Benchmarks/PolylineAlgorithm.Benchmarks.csproj @@ -23,4 +23,9 @@ + + + + +
diff --git a/benchmarks/PolylineAlgorithm.Benchmarks/PolylineEncodingBenchmark.cs b/benchmarks/PolylineAlgorithm.Benchmarks/PolylineEncodingBenchmark.cs index 861ad85f..ee716fca 100644 --- a/benchmarks/PolylineAlgorithm.Benchmarks/PolylineEncodingBenchmark.cs +++ b/benchmarks/PolylineAlgorithm.Benchmarks/PolylineEncodingBenchmark.cs @@ -28,11 +28,5 @@ public void Setup() { } [Benchmark(Baseline = true)] - public void ValidateCharRange() => PolylineEncoding.ValidateCharRange(polyline); - - [Benchmark] - public void ValidateBlockLength() => PolylineEncoding.ValidateBlockLength(polyline); - - [Benchmark] public void ValidateFormat() => PolylineEncoding.ValidateFormat(polyline); } \ No newline at end of file diff --git a/docs/branch-strategy.md b/docs/branch-strategy.md index fb13f779..0c9d4c3d 100644 --- a/docs/branch-strategy.md +++ b/docs/branch-strategy.md @@ -110,4 +110,4 @@ The `X.Y` in `preview/X.Y` and `release/X.Y` drives the version pipeline. See [V ## Locking and Unlocking Branches -`preview/**` and `release/**` branches are locked via the [`github/branch-protection/lock`](./composite-actions.md#githubbranch-protectionlock) composite action when created. `develop/X.Y` and `support/X.Y` branches must be manually configured as protected in repository settings (PR required, no direct pushes). The [`github/branch-protection/unlock`](./composite-actions.md#githubbranch-protectionunlock) action temporarily removes protection when a workflow needs to push directly (e.g., `bump-version.yml`). Branches are always re-locked immediately after. +`develop/**`, `preview/**`, `release/**` and `main` are protected by repository rulesets (Settings → Rules). The [`github/branch-protection/unlock`](./composite-actions.md#githubbranch-protectionunlock) action temporarily excludes a branch from its rulesets when a workflow needs to push directly (e.g., `bump-version.yml`). Branches are always re-locked immediately after. diff --git a/docs/composite-actions.md b/docs/composite-actions.md index 66243f45..ed363f39 100644 --- a/docs/composite-actions.md +++ b/docs/composite-actions.md @@ -55,28 +55,30 @@ All reusable GitHub Actions live under `.github/actions/`. They are referenced w ## github/branch-protection/lock **Path:** `.github/actions/github/branch-protection/lock` -**Description:** Applies branch protection rules to a branch via the GitHub API: requires at least one PR approval, disables force pushes and deletions. +**Description:** Restores ruleset protection for a branch by removing it from the exclude list of the repository rulesets listed in `ruleset-ids` (output of `unlock`). | Input | Required | Description | |---|---|---| -| `branch` | ✅ | Branch name to protect | +| `branch` | ✅ | Branch name to re-protect | +| `token` | ✅ | Admin PAT (`administration: write`) | +| `ruleset-ids` | ✅ | Space-separated ruleset IDs from `unlock` | -**Requires:** `administration: write` permission on the workflow token. -**Used by:** `promote-branch` workflow (after creating a new `preview/**` or `release/**` branch). +**Used by:** `release` and `backtrack` workflows. --- ## github/branch-protection/unlock **Path:** `.github/actions/github/branch-protection/unlock` -**Description:** Removes all branch protection rules from a branch so a workflow can push directly to it. Always re-lock immediately after. +**Description:** Temporarily excludes a branch from every repository ruleset that applies to it (via `GET /rules/branches/{branch}`), so a workflow can push directly. Outputs `ruleset-ids`; always restore with `lock` afterwards. | Input | Required | Description | |---|---|---| | `branch` | ✅ | Branch name to unprotect | +| `token` | ✅ | Admin PAT (`administration: write`) | +| `fail-on-error` | ❌ | Fail the step on errors (default `false`) | -**Requires:** `administration: write` permission on the workflow token. -**Used by:** Workflows that need to push commits directly to protected branches (e.g., `bump-version`). +**Used by:** `release` (API file promotion) and `backtrack` workflows. --- @@ -299,7 +301,7 @@ runs: using: composite steps: - name: 'Checkout ${{ github.head_ref || github.ref }}' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'My step' id: my-step shell: bash diff --git a/docs/local-development.md b/docs/local-development.md index 48fa5f76..ab2da8ab 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -72,3 +72,24 @@ The CI `format` job also runs `dotnet format` automatically on every push to non ## Editor Configuration Code style rules are stored in `.editorconfig` at the repository root. Any compliant IDE (Visual Studio, VS Code with C# Dev Kit, Rider) will pick these up automatically. + +## Required Repository Secrets and Variables + +The CI/CD workflows rely on the following GitHub repository secrets and variables. + +### Secrets + +| Name | Description | +|------|-------------| +| `NUGET_PACKAGE_FEED_API_KEY` | API key for the Azure Artifacts NuGet feed | + +### Variables + +| Name | Description | +|------|-------------| +| `NUGET_PACKAGE_FEED_URL` | NuGet feed URL | +| `BENCHMARKDOTNET_RUN_OVERRIDE` | Set to `true` to force benchmarks to run on non-release PRs | +| `BENCHMARKDOTNET_RUNTIMES` | Runtimes to benchmark (e.g. `net8.0 net10.0`) | +| `BENCHMARKDOTNET_FILTER` | Benchmark filter pattern (e.g. `*`) | +| `DEFAULT_BUILD_FRAMEWORK` | Default target framework for builds (e.g. `net10.0`) | +| `SRC_DEFAULT_GLOB_PATTERN` | Glob pattern for source project files (e.g. `**/PolylineAlgorithm.csproj`) | diff --git a/docs/workflows.md b/docs/workflows.md index 4c864783..8782270f 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -87,6 +87,7 @@ Version bumping runs independently via `bump-version.yml` (manual trigger). | `pack` | `versioning`, `build` | Packages binaries | | `publish-package` | `pack` | Publishes to NuGet.org (Production environment) | | `create-release` | `versioning`, `publish-package` | Creates a git tag + GitHub release with auto-generated notes | +| `update-changelog` | `release` | Fetches release notes and prepends a new entry to `CHANGELOG.md` — stable releases only | | `generate-docs` | `versioning` | Builds DocFX site | | `publish-docs` | `generate-docs` | Deploys to GitHub Pages | diff --git a/samples/PolylineAlgorithm.NetTopologySuite.Sample/NetTopologyPolylineDecoder.cs b/samples/PolylineAlgorithm.NetTopologySuite.Sample/NetTopologyPolylineDecoder.cs index 82899203..d7dcb13e 100644 --- a/samples/PolylineAlgorithm.NetTopologySuite.Sample/NetTopologyPolylineDecoder.cs +++ b/samples/PolylineAlgorithm.NetTopologySuite.Sample/NetTopologyPolylineDecoder.cs @@ -12,7 +12,7 @@ namespace PolylineAlgorithm.NetTopologySuite.Sample; /// /// Polyline decoder using NetTopologySuite. /// -public sealed class NetTopologyPolylineDecoder : AbstractPolylineDecoder { +internal sealed class NetTopologyPolylineDecoder : AbstractPolylineDecoder { /// /// Creates a NetTopologySuite point from latitude and longitude. /// diff --git a/samples/PolylineAlgorithm.NetTopologySuite.Sample/NetTopologyPolylineEncoder.cs b/samples/PolylineAlgorithm.NetTopologySuite.Sample/NetTopologyPolylineEncoder.cs index 649547bd..c6719000 100644 --- a/samples/PolylineAlgorithm.NetTopologySuite.Sample/NetTopologyPolylineEncoder.cs +++ b/samples/PolylineAlgorithm.NetTopologySuite.Sample/NetTopologyPolylineEncoder.cs @@ -11,7 +11,7 @@ namespace PolylineAlgorithm.NetTopologySuite.Sample; /// /// Polyline encoder using NetTopologySuite's Point type. /// -public sealed class NetTopologyPolylineEncoder : AbstractPolylineEncoder { +internal sealed class NetTopologyPolylineEncoder : AbstractPolylineEncoder { /// /// Creates encoded polyline string from memory. /// @@ -31,9 +31,7 @@ protected override string CreatePolyline(ReadOnlyMemory polyline) { /// Point instance. /// Latitude value. protected override double GetLatitude(Point current) { - if (current is null) { - throw new ArgumentNullException(nameof(current)); - } + ArgumentNullException.ThrowIfNull(current); // NetTopologySuite Point: Y = latitude return current.Y; @@ -45,9 +43,7 @@ protected override double GetLatitude(Point current) { /// Point instance. /// Longitude value. protected override double GetLongitude(Point current) { - if (current is null) { - throw new ArgumentNullException(nameof(current)); - } + ArgumentNullException.ThrowIfNull(current); // NetTopologySuite Point: X = longitude return current.X; diff --git a/samples/PolylineAlgorithm.NetTopologySuite.Sample/PolylineAlgorithm.NetTopologySuite.Sample.csproj b/samples/PolylineAlgorithm.NetTopologySuite.Sample/PolylineAlgorithm.NetTopologySuite.Sample.csproj index 57d5f7c1..1f430b34 100644 --- a/samples/PolylineAlgorithm.NetTopologySuite.Sample/PolylineAlgorithm.NetTopologySuite.Sample.csproj +++ b/samples/PolylineAlgorithm.NetTopologySuite.Sample/PolylineAlgorithm.NetTopologySuite.Sample.csproj @@ -1,7 +1,8 @@  - netstandard2.1 + Exe + net10.0 @@ -16,4 +17,9 @@ + + + + + diff --git a/samples/PolylineAlgorithm.NetTopologySuite.Sample/Program.cs b/samples/PolylineAlgorithm.NetTopologySuite.Sample/Program.cs new file mode 100644 index 00000000..92c1517f --- /dev/null +++ b/samples/PolylineAlgorithm.NetTopologySuite.Sample/Program.cs @@ -0,0 +1,47 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +using NetTopologySuite.Geometries; + +namespace PolylineAlgorithm.NetTopologySuite.Sample { + internal static class Program { + public static void Main(string[] _) { + // Sample route: Seattle → Bellevue → Redmond + var points = new Point[] + { + new(x: -122.3503, y: 47.6219), // Seattle (x = longitude, y = latitude) + new(x: -122.2015, y: 47.6101), // Bellevue + new(x: -122.1215, y: 47.6740), // Redmond + }; + + var encoder = new NetTopologyPolylineEncoder(); + var decoder = new NetTopologyPolylineDecoder(); + + // Encode + string encoded = encoder.Encode(points); + + Console.WriteLine("=== NetTopologySuite Polyline Sample ==="); + Console.WriteLine(); + Console.WriteLine("Input points (longitude, latitude):"); + + foreach (Point p in points) { + Console.WriteLine($" ({p.X}, {p.Y})"); + } + + Console.WriteLine(); + Console.WriteLine($"Encoded polyline: {encoded}"); + Console.WriteLine(); + + // Decode + IEnumerable decoded = decoder.Decode(encoded); + + Console.WriteLine("Decoded points (longitude, latitude):"); + + foreach (Point p in decoded) { + Console.WriteLine($" ({p.X}, {p.Y})"); + } + } + } +} \ No newline at end of file diff --git a/samples/PolylineAlgorithm.SensorData.Sample/PolylineAlgorithm.SensorData.Sample.csproj b/samples/PolylineAlgorithm.SensorData.Sample/PolylineAlgorithm.SensorData.Sample.csproj new file mode 100644 index 00000000..07fbe2d2 --- /dev/null +++ b/samples/PolylineAlgorithm.SensorData.Sample/PolylineAlgorithm.SensorData.Sample.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + + + + false + + + + + + + + + + + + diff --git a/samples/PolylineAlgorithm.SensorData.Sample/Program.cs b/samples/PolylineAlgorithm.SensorData.Sample/Program.cs new file mode 100644 index 00000000..02d0d8db --- /dev/null +++ b/samples/PolylineAlgorithm.SensorData.Sample/Program.cs @@ -0,0 +1,49 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace PolylineAlgorithm.SensorData.Sample { + internal static class Program { + public static void Main(string[] _) { + // Sample temperature readings from a sensor over six seconds + var readings = new SensorReading[] + { + new(DateTimeOffset.UtcNow, 23.5), + new(DateTimeOffset.UtcNow.AddSeconds(1), 23.7), + new(DateTimeOffset.UtcNow.AddSeconds(2), 24.1), + new(DateTimeOffset.UtcNow.AddSeconds(3), 25.0), + new(DateTimeOffset.UtcNow.AddSeconds(4), 24.8), + new(DateTimeOffset.UtcNow.AddSeconds(5), 24.8), + new(DateTimeOffset.UtcNow.AddSeconds(6), 22.3), + }; + + var encoder = new SensorDataEncoder(); + var decoder = new SensorDataDecoder(); + + // Encode + string encoded = encoder.Encode(readings); + + Console.WriteLine("=== Sensor Data Polyline Sample ==="); + Console.WriteLine(); + Console.WriteLine("Input readings:"); + + foreach (SensorReading r in readings) { + Console.WriteLine($" [{r.Timestamp:HH:mm:ss}] {r.Temperature:F1} °C"); + } + + Console.WriteLine(); + Console.WriteLine($"Encoded polyline: {encoded}"); + Console.WriteLine(); + + // Decode (timestamps and temperatures are both recovered) + IEnumerable decoded = decoder.Decode(encoded); + + Console.WriteLine("Decoded readings:"); + + foreach (SensorReading r in decoded) { + Console.WriteLine($" [{r.Timestamp:HH:mm:ss}] {r.Temperature:F1} °C"); + } + } + } +} \ No newline at end of file diff --git a/samples/PolylineAlgorithm.SensorData.Sample/Properties/CodeCoverage.cs b/samples/PolylineAlgorithm.SensorData.Sample/Properties/CodeCoverage.cs new file mode 100644 index 00000000..e3468ef6 --- /dev/null +++ b/samples/PolylineAlgorithm.SensorData.Sample/Properties/CodeCoverage.cs @@ -0,0 +1,8 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +using System.Diagnostics.CodeAnalysis; + +[assembly: ExcludeFromCodeCoverage] diff --git a/samples/PolylineAlgorithm.SensorData.Sample/SensorDataDecoder.cs b/samples/PolylineAlgorithm.SensorData.Sample/SensorDataDecoder.cs new file mode 100644 index 00000000..9c63d647 --- /dev/null +++ b/samples/PolylineAlgorithm.SensorData.Sample/SensorDataDecoder.cs @@ -0,0 +1,107 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace PolylineAlgorithm.SensorData.Sample; + +using PolylineAlgorithm.Abstraction; +using System.Collections.Generic; +using System.Threading; + +/// +/// Decodes a compact polyline string produced by back into a sequence +/// of values. +/// +/// +/// +/// This class demonstrates implementing for a custom +/// scalar type, following the same structural pattern as . +/// +/// +/// Each encoded pair consists of a delta-compressed Unix timestamp (seconds since Unix epoch, precision 0) +/// followed by a delta-compressed temperature value (at ). +/// Both are recovered and used to reconstruct the original . +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Sonar", "S4456:Parameter validation in yielding methods should be wrapped", Justification = "Inlined by design to demonstrate a simple iterator without a wrapper method.")] +internal sealed class SensorDataDecoder : IPolylineDecoder { + /// + /// Initializes a new instance of the class with default encoding options. + /// + public SensorDataDecoder() + : this(new PolylineEncodingOptions()) { } + + /// + /// Initializes a new instance of the class with the specified encoding options. + /// + /// + /// The to use for decoding operations. + /// The value must match the precision used during encoding. + /// + /// + /// Thrown when is . + /// + public SensorDataDecoder(PolylineEncodingOptions options) { + ArgumentNullException.ThrowIfNull(options); + + Options = options; + } + + /// + /// Gets the encoding options used by this decoder. + /// + public PolylineEncodingOptions Options { get; } + + /// + /// Decodes a polyline string back into a sequence of values. + /// + /// + /// The polyline-encoded string produced by . + /// + /// + /// A that can be used to cancel the decoding operation. + /// + /// + /// An of whose + /// and values + /// are recovered from the encoded string. + /// + /// + /// Thrown when is . + /// + /// + /// Thrown when is empty. + /// + /// + /// Thrown when requests cancellation. + /// + public IEnumerable Decode(string polyline, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(polyline); + + if (polyline.Length < 1) { + throw new ArgumentException("Encoded polyline must not be empty.", nameof(polyline)); + } + + ReadOnlyMemory memory = polyline.AsMemory(); + int position = 0; + // Mirror the encoder's base epoch so the first delta decodes back to the correct Unix seconds. + int accumulatedTimestamp = SensorDataEncoder.TimestampBaseEpochSeconds; + int accumulatedTemperature = 0; + + while (position < memory.Length) { + cancellationToken.ThrowIfCancellationRequested(); + + // Read Unix timestamp delta (precision 0) then temperature delta. + if (!PolylineEncoding.TryReadValue(ref accumulatedTimestamp, memory, ref position) + || !PolylineEncoding.TryReadValue(ref accumulatedTemperature, memory, ref position)) { + yield break; + } + + long unixSeconds = (long)PolylineEncoding.Denormalize(accumulatedTimestamp, precision: 0); + double temperature = PolylineEncoding.Denormalize(accumulatedTemperature, Options.Precision); + + yield return new SensorReading(DateTimeOffset.FromUnixTimeSeconds(unixSeconds), temperature); + } + } +} diff --git a/samples/PolylineAlgorithm.SensorData.Sample/SensorDataEncoder.cs b/samples/PolylineAlgorithm.SensorData.Sample/SensorDataEncoder.cs new file mode 100644 index 00000000..be199e4d --- /dev/null +++ b/samples/PolylineAlgorithm.SensorData.Sample/SensorDataEncoder.cs @@ -0,0 +1,135 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace PolylineAlgorithm.SensorData.Sample; + +using PolylineAlgorithm.Abstraction; +using System.Buffers; +using System.Threading; + +/// +/// Encodes a sequence of values into a compact polyline string +/// using the polyline delta-encoding algorithm applied to both the +/// and fields. +/// +/// +/// +/// This class demonstrates implementing for a custom +/// scalar type, following the same structural pattern as . +/// +/// +/// Because sensor readings carry two numeric dimensions (timestamp and temperature), the base class designed +/// for geographic coordinate pairs is not used. Instead, static +/// helpers are called directly to perform normalisation, delta computation, and character-level encoding. +/// +/// +/// Each reading is encoded as a pair of delta-compressed values: +/// the Unix timestamp in seconds (precision 0) followed by the temperature (at ). +/// +/// +internal sealed class SensorDataEncoder : IPolylineEncoder { + // 2020-01-01 00:00:00 UTC in Unix seconds. Used as the delta-encoding base for timestamps + // so that the first absolute delta stays within the int32 safe range of the polyline algorithm. + internal const int TimestampBaseEpochSeconds = 1_577_836_800; + + /// + /// Initializes a new instance of the class with default encoding options. + /// + public SensorDataEncoder() + : this(new PolylineEncodingOptions()) { } + + /// + /// Initializes a new instance of the class with the specified encoding options. + /// + /// + /// The to use for encoding operations. + /// + /// + /// Thrown when is . + /// + public SensorDataEncoder(PolylineEncodingOptions options) { + ArgumentNullException.ThrowIfNull(options); + + Options = options; + } + + /// + /// Gets the encoding options used by this encoder. + /// + public PolylineEncodingOptions Options { get; } + + /// + /// Encodes a sequence of values into a polyline string. + /// + /// + /// The sensor readings to encode. Each reading contributes a delta-compressed Unix timestamp + /// (seconds since Unix epoch, precision 0) and a delta-compressed temperature value. + /// Must contain at least one element. + /// + /// + /// A that can be used to cancel the encoding operation. + /// + /// + /// A polyline-encoded string representing the delta-compressed timestamp and temperature series. + /// + /// + /// Thrown when is empty. + /// + /// + /// Thrown when requests cancellation. + /// + public string Encode(ReadOnlySpan coordinates, CancellationToken cancellationToken = default) { + if (coordinates.Length < 1) { + throw new ArgumentException("Sequence must contain at least one element.", nameof(coordinates)); + } + + // Maximum number of ASCII characters required to encode a single 32-bit delta value + // using the polyline algorithm (ceil(32 bits / 5 bits per chunk) + sign bit = 7). + const int MaxEncodedCharsPerValue = 7; + + // Each reading encodes two values: Unix timestamp (precision 0) + temperature. + // The polyline algorithm uses signed int32 internally, limiting safe absolute values to ~1.07B. + // Current Unix time in seconds (~1.74B) exceeds this. We therefore delta-encode relative to + // 2020-01-01 00:00:00 UTC (= 1 577 836 800 s), keeping the initial delta well within range. + int previousTimestampNormalized = TimestampBaseEpochSeconds; + int previousTemperatureNormalized = 0; + int position = 0; + int length = coordinates.Length * 2 * MaxEncodedCharsPerValue; + + char[]? temp = length <= Options.StackAllocLimit + ? null + : ArrayPool.Shared.Rent(length); + + Span buffer = temp is null ? stackalloc char[length] : temp.AsSpan(0, length); + + try { + for (int i = 0; i < coordinates.Length; i++) { + cancellationToken.ThrowIfCancellationRequested(); + + // Encode Unix timestamp in whole seconds (precision 0). + int normalizedTimestamp = PolylineEncoding.Normalize((double)coordinates[i].Timestamp.ToUnixTimeSeconds(), precision: 0); + int timestampDelta = normalizedTimestamp - previousTimestampNormalized; + + // Encode temperature at the configured precision. + int normalizedTemperature = PolylineEncoding.Normalize(coordinates[i].Temperature, Options.Precision); + int temperatureDelta = normalizedTemperature - previousTemperatureNormalized; + + if (!PolylineEncoding.TryWriteValue(timestampDelta, buffer, ref position) + || !PolylineEncoding.TryWriteValue(temperatureDelta, buffer, ref position)) { + throw new InvalidOperationException("Encoding buffer is too small to hold the encoded value."); + } + + previousTimestampNormalized = normalizedTimestamp; + previousTemperatureNormalized = normalizedTemperature; + } + + return buffer[..position].ToString(); + } finally { + if (temp is not null) { + ArrayPool.Shared.Return(temp); + } + } + } +} diff --git a/samples/PolylineAlgorithm.SensorData.Sample/SensorReading.cs b/samples/PolylineAlgorithm.SensorData.Sample/SensorReading.cs new file mode 100644 index 00000000..6903c92f --- /dev/null +++ b/samples/PolylineAlgorithm.SensorData.Sample/SensorReading.cs @@ -0,0 +1,16 @@ +// +// Copyright © Pete Sramek. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace PolylineAlgorithm.SensorData.Sample; + +using System.Runtime.InteropServices; + +/// +/// Represents a single temperature reading captured by a sensor. +/// +/// The UTC time at which the reading was captured. +/// The temperature value of the reading, in degrees Celsius. +[StructLayout(LayoutKind.Auto)] +internal readonly record struct SensorReading(DateTimeOffset Timestamp, double Temperature); diff --git a/src/PolylineAlgorithm/Abstraction/AbstractPolylineEncoder.cs b/src/PolylineAlgorithm/Abstraction/AbstractPolylineEncoder.cs index 1bcdb0ee..075934e1 100644 --- a/src/PolylineAlgorithm/Abstraction/AbstractPolylineEncoder.cs +++ b/src/PolylineAlgorithm/Abstraction/AbstractPolylineEncoder.cs @@ -84,8 +84,6 @@ public TPolyline Encode(ReadOnlySpan coordinates, CancellationToken _logger .LogOperationStartedDebug(OperationName); - Debug.Assert(coordinates.Length >= 0, "Count must be non-negative."); - ValidateEmptyCoordinates(ref coordinates, _logger); CoordinateDelta delta = new(); diff --git a/src/PolylineAlgorithm/Extensions/PolylineDecoderExtensions.cs b/src/PolylineAlgorithm/Extensions/PolylineDecoderExtensions.cs index ca48e099..6264a223 100644 --- a/src/PolylineAlgorithm/Extensions/PolylineDecoderExtensions.cs +++ b/src/PolylineAlgorithm/Extensions/PolylineDecoderExtensions.cs @@ -30,6 +30,7 @@ public static class PolylineDecoderExtensions { /// /// Thrown when or is . /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "It is guarder using ExceptionGuard.ThrowArgumentNull(nameof(decoder));")] public static IEnumerable Decode(this IPolylineDecoder decoder, char[] polyline) { if (decoder is null) { ExceptionGuard.ThrowArgumentNull(nameof(decoder)); @@ -58,6 +59,7 @@ public static IEnumerable Decode(this IPolylineDecoder /// Thrown when is . /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "It is guarder using ExceptionGuard.ThrowArgumentNull(nameof(decoder));")] public static IEnumerable Decode(this IPolylineDecoder decoder, ReadOnlyMemory polyline) { if (decoder is null) { ExceptionGuard.ThrowArgumentNull(nameof(decoder)); @@ -83,6 +85,7 @@ public static IEnumerable Decode(this IPolylineDecoder /// Thrown when or is . /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "It is guarder using ExceptionGuard.ThrowArgumentNull(nameof(decoder));")] public static IEnumerable Decode(this IPolylineDecoder, TValue> decoder, string polyline) { if (decoder is null) { ExceptionGuard.ThrowArgumentNull(nameof(decoder)); diff --git a/src/PolylineAlgorithm/Extensions/PolylineEncoderExtensions.cs b/src/PolylineAlgorithm/Extensions/PolylineEncoderExtensions.cs index e9b66923..ae44ef90 100644 --- a/src/PolylineAlgorithm/Extensions/PolylineEncoderExtensions.cs +++ b/src/PolylineAlgorithm/Extensions/PolylineEncoderExtensions.cs @@ -36,6 +36,7 @@ public static class PolylineEncoderExtensions { /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "We need a list as we do need to marshal it as span.")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "MA0016:Prefer using collection abstraction instead of implementation", Justification = "We need a list as we do need to marshal it as span.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "It is guarder using ExceptionGuard.ThrowArgumentNull(nameof(encoder));")] public static TPolyline Encode(this IPolylineEncoder encoder, List coordinates) { if (encoder is null) { ExceptionGuard.ThrowArgumentNull(nameof(encoder)); @@ -70,6 +71,7 @@ public static TPolyline Encode(this IPolylineEncoder /// Thrown when or is . /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "It is guarder using ExceptionGuard.ThrowArgumentNull(nameof(encoder));")] public static TPolyline Encode(this IPolylineEncoder encoder, TCoordinate[] coordinates) { if (encoder is null) { ExceptionGuard.ThrowArgumentNull(nameof(encoder)); diff --git a/src/PolylineAlgorithm/PolylineAlgorithm.csproj b/src/PolylineAlgorithm/PolylineAlgorithm.csproj index 24504239..a6465109 100644 --- a/src/PolylineAlgorithm/PolylineAlgorithm.csproj +++ b/src/PolylineAlgorithm/PolylineAlgorithm.csproj @@ -8,6 +8,13 @@ True
+ + + $(WarningsAsErrors);RS0017 + + true @@ -39,7 +46,7 @@ - + all runtime; build; native; contentfiles; analyzers @@ -70,4 +77,9 @@ + + + + + diff --git a/src/PolylineAlgorithm/PolylineEncoding.cs b/src/PolylineAlgorithm/PolylineEncoding.cs index 4b8d5312..9dcf8d31 100644 --- a/src/PolylineAlgorithm/PolylineEncoding.cs +++ b/src/PolylineAlgorithm/PolylineEncoding.cs @@ -1,4 +1,4 @@ -// +// // Copyright © Pete Sramek. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -76,10 +76,11 @@ public static int Normalize(double value, uint precision = 5) { uint factor = Pow10.GetFactor(precision); + const double Epsilon = 1e-9; + checked { - return (int)(Math.Truncate(value * 10 * factor) / 10); + return (int)Math.Truncate((value * factor) + (Epsilon * Math.Sign(value))); } - } /// @@ -329,123 +330,60 @@ public static int GetRequiredBufferSize(int delta) { private const ushort End = (Defaults.Algorithm.QuestionMark + Defaults.Algorithm.Space); /// - /// SIMD vector containing the minimum valid character value for efficient range validation of polyline segments. - /// - private static readonly Vector MinVector = new(Min); - - /// - /// SIMD vector containing the maximum valid character value for efficient range validation of polyline segments. - /// - private static readonly Vector MaxVector = new(Max); - - /// - /// Validates the format of a polyline segment, ensuring all characters are valid and block structure is correct. + /// Validates that an encoded polyline segment is structurally well-formed. /// /// - /// - /// This method performs two levels of validation on the provided polyline segment: - /// + /// This method performs a single pass over and validates: /// /// /// - /// Character Range Validation: Checks that every character in the polyline is within the valid ASCII range for polyline encoding ('?' [63] to '_' [95], inclusive). - /// Uses SIMD acceleration for efficient validation of large segments. + /// Character range: each character must be in the inclusive algorithm range + /// from '?' (63) to '_' (95). Invalid characters are reported with + /// . /// /// /// /// - /// Block Structure Validation: Ensures that each encoded value (block) does not exceed 7 characters and that the polyline ends with a valid block terminator. + /// Value block termination: each encoded value block must end with a character + /// whose code is less than . A block longer than + /// is reported with + /// , and a missing final block + /// terminator is reported with . /// /// /// - /// - /// If an invalid character or block structure is detected, an is thrown with details about the error. - /// + /// Complexity: O(n) time and O(1) additional memory. /// - /// A span representing the polyline segment to validate. + /// Encoded polyline text to validate. /// - /// Thrown when an invalid character is found or the block structure is invalid. + /// Thrown when the input contains an invalid character, contains a block longer than allowed, + /// or does not end with a valid block terminator (including empty input). /// + /// + /// + /// PolylineEncoding.ValidateFormat(encodedPolyline); + /// + /// public static void ValidateFormat(ReadOnlySpan polyline) { - // 1. SIMD character check (reuse existing method) - ValidateCharRange(polyline); - // 2. Block structure check - ValidateBlockLength(polyline); - } - - /// - /// Validates that all characters in the polyline segment are within the allowed ASCII range for polyline encoding. - /// - /// - /// - /// Uses SIMD vectorization for efficient validation of large spans. Falls back to scalar checks for any block where an invalid character is detected. - /// - /// - /// The valid range is from '?' (63) to '_' (95), inclusive. If an invalid character is found, an is thrown. - /// - /// - /// A span representing the polyline segment to validate. - /// - /// Thrown when an invalid character is found in the polyline segment. - /// - public static void ValidateCharRange(ReadOnlySpan polyline) { - int length = polyline.Length; - int vectorSize = Vector.Count; - - int i = 0; - for (; i <= length - vectorSize; i += vectorSize) { - var span = MemoryMarshal.Cast(polyline.Slice(i, vectorSize)); -#if NET5_0_OR_GREATER - var chars = new Vector(span); -#else - var chars = new Vector(span.ToArray()); -#endif - var belowMin = Vector.LessThan(chars, MinVector); - var aboveMax = Vector.GreaterThan(chars, MaxVector); - if (Vector.BitwiseOr(belowMin, aboveMax) != Vector.Zero) { - // Fallback to scalar check for this block - for (int j = 0; j < vectorSize; j++) { - char character = polyline[i + j]; - if (character < Min || character > Max) { - ExceptionGuard.ThrowInvalidPolylineCharacter(character, i + j); - } - } - } - } - - for (; i < length; i++) { - char character = polyline[i]; - if (character < Min || character > Max) { - ExceptionGuard.ThrowInvalidPolylineCharacter(character, i); - } - } - } - - /// - /// Validates the block structure of a polyline segment, ensuring each encoded value does not exceed 7 characters and the polyline ends correctly. - /// - /// - /// - /// Iterates through the polyline, counting the length of each block (a sequence of characters representing an encoded value). - /// Throws an if any block exceeds 7 characters or if the polyline does not end with a valid block terminator. - /// - /// - /// A span representing the polyline segment to validate. - /// - /// Thrown when a block exceeds 7 characters or the polyline does not end with a valid block terminator. - /// - public static void ValidateBlockLength(ReadOnlySpan polyline) { int blockLen = 0; bool foundBlockEnd = false; for (int i = 0; i < polyline.Length; i++) { + char c = polyline[i]; + + if (c < Min || c > Max) { + ExceptionGuard.ThrowInvalidPolylineCharacter(c, i); + } + blockLen++; - if (polyline[i] < End) { + if (c < End) { foundBlockEnd = true; + if (blockLen > Defaults.Polyline.Block.Length.Max) { ExceptionGuard.ThrowPolylineBlockTooLong(i - blockLen + 1); } + blockLen = 0; } else { foundBlockEnd = false; diff --git a/src/PolylineAlgorithm/PublicAPI.Shipped.txt b/src/PolylineAlgorithm/PublicAPI.Shipped.txt index 91b0e1a4..01a106f3 100644 --- a/src/PolylineAlgorithm/PublicAPI.Shipped.txt +++ b/src/PolylineAlgorithm/PublicAPI.Shipped.txt @@ -1 +1,49 @@ -#nullable enable \ No newline at end of file +#nullable enable +abstract PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.CreateCoordinate(double latitude, double longitude) -> TCoordinate +abstract PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.GetReadOnlyMemory(in TPolyline polyline) -> System.ReadOnlyMemory +abstract PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.CreatePolyline(System.ReadOnlyMemory polyline) -> TPolyline +abstract PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.GetLatitude(TCoordinate current) -> double +abstract PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.GetLongitude(TCoordinate current) -> double +PolylineAlgorithm.Abstraction.AbstractPolylineDecoder +PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.AbstractPolylineDecoder() -> void +PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.AbstractPolylineDecoder(PolylineAlgorithm.PolylineEncodingOptions! options) -> void +PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.Decode(TPolyline polyline, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IEnumerable! +PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.Options.get -> PolylineAlgorithm.PolylineEncodingOptions! +PolylineAlgorithm.Abstraction.AbstractPolylineEncoder +PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.AbstractPolylineEncoder() -> void +PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.AbstractPolylineEncoder(PolylineAlgorithm.PolylineEncodingOptions! options) -> void +PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.Encode(System.ReadOnlySpan coordinates, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> TPolyline +PolylineAlgorithm.Abstraction.AbstractPolylineEncoder.Options.get -> PolylineAlgorithm.PolylineEncodingOptions! +PolylineAlgorithm.Abstraction.IPolylineDecoder +PolylineAlgorithm.Abstraction.IPolylineDecoder.Decode(TPolyline polyline, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IEnumerable! +PolylineAlgorithm.Abstraction.IPolylineEncoder +PolylineAlgorithm.Abstraction.IPolylineEncoder.Encode(System.ReadOnlySpan coordinates, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> TPolyline +PolylineAlgorithm.Extensions.PolylineDecoderExtensions +PolylineAlgorithm.Extensions.PolylineEncoderExtensions +PolylineAlgorithm.InvalidPolylineException +PolylineAlgorithm.InvalidPolylineException.InvalidPolylineException() -> void +PolylineAlgorithm.InvalidPolylineException.InvalidPolylineException(string! message, System.Exception! innerException) -> void +PolylineAlgorithm.PolylineEncoding +PolylineAlgorithm.PolylineEncodingOptions +PolylineAlgorithm.PolylineEncodingOptions.LoggerFactory.get -> Microsoft.Extensions.Logging.ILoggerFactory! +PolylineAlgorithm.PolylineEncodingOptions.PolylineEncodingOptions() -> void +PolylineAlgorithm.PolylineEncodingOptions.Precision.get -> uint +PolylineAlgorithm.PolylineEncodingOptions.StackAllocLimit.get -> int +PolylineAlgorithm.PolylineEncodingOptionsBuilder +PolylineAlgorithm.PolylineEncodingOptionsBuilder.Build() -> PolylineAlgorithm.PolylineEncodingOptions! +PolylineAlgorithm.PolylineEncodingOptionsBuilder.WithLoggerFactory(Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> PolylineAlgorithm.PolylineEncodingOptionsBuilder! +PolylineAlgorithm.PolylineEncodingOptionsBuilder.WithPrecision(uint precision) -> PolylineAlgorithm.PolylineEncodingOptionsBuilder! +PolylineAlgorithm.PolylineEncodingOptionsBuilder.WithStackAllocLimit(int stackAllocLimit) -> PolylineAlgorithm.PolylineEncodingOptionsBuilder! +static PolylineAlgorithm.Extensions.PolylineDecoderExtensions.Decode(this PolylineAlgorithm.Abstraction.IPolylineDecoder! decoder, char[]! polyline) -> System.Collections.Generic.IEnumerable! +static PolylineAlgorithm.Extensions.PolylineDecoderExtensions.Decode(this PolylineAlgorithm.Abstraction.IPolylineDecoder! decoder, System.ReadOnlyMemory polyline) -> System.Collections.Generic.IEnumerable! +static PolylineAlgorithm.Extensions.PolylineDecoderExtensions.Decode(this PolylineAlgorithm.Abstraction.IPolylineDecoder, TValue>! decoder, string! polyline) -> System.Collections.Generic.IEnumerable! +static PolylineAlgorithm.Extensions.PolylineEncoderExtensions.Encode(this PolylineAlgorithm.Abstraction.IPolylineEncoder! encoder, System.Collections.Generic.List! coordinates) -> TPolyline +static PolylineAlgorithm.Extensions.PolylineEncoderExtensions.Encode(this PolylineAlgorithm.Abstraction.IPolylineEncoder! encoder, TCoordinate[]! coordinates) -> TPolyline +static PolylineAlgorithm.PolylineEncoding.Denormalize(int value, uint precision = 5) -> double +static PolylineAlgorithm.PolylineEncoding.GetRequiredBufferSize(int delta) -> int +static PolylineAlgorithm.PolylineEncoding.Normalize(double value, uint precision = 5) -> int +static PolylineAlgorithm.PolylineEncoding.TryReadValue(ref int delta, System.ReadOnlyMemory buffer, ref int position) -> bool +static PolylineAlgorithm.PolylineEncoding.TryWriteValue(int delta, System.Span buffer, ref int position) -> bool +static PolylineAlgorithm.PolylineEncoding.ValidateFormat(System.ReadOnlySpan polyline) -> void +static PolylineAlgorithm.PolylineEncodingOptionsBuilder.Create() -> PolylineAlgorithm.PolylineEncodingOptionsBuilder! +virtual PolylineAlgorithm.Abstraction.AbstractPolylineDecoder.ValidateFormat(System.ReadOnlyMemory sequence, Microsoft.Extensions.Logging.ILogger? logger) -> void diff --git a/src/PolylineAlgorithm/PublicAPI.Unshipped.txt b/src/PolylineAlgorithm/PublicAPI.Unshipped.txt index 7dc5c581..ab058de6 100644 --- a/src/PolylineAlgorithm/PublicAPI.Unshipped.txt +++ b/src/PolylineAlgorithm/PublicAPI.Unshipped.txt @@ -1 +1 @@ -#nullable enable +#nullable enable diff --git a/src/PolylineAlgorithm/README.md b/src/PolylineAlgorithm/README.md index d1bf97e0..97c4bb98 100644 --- a/src/PolylineAlgorithm/README.md +++ b/src/PolylineAlgorithm/README.md @@ -1,6 +1,8 @@ # PolylineAlgorithm for .NET -A modern, fully compliant Google Encoded Polyline Algorithm library for .NET Standard 2.1+, supporting strong input validation, extensibility for custom coordinate types, and robust performance. +[![NuGet](https://img.shields.io/nuget/v/PolylineAlgorithm)](https://www.nuget.org/packages/PolylineAlgorithm) + +Google's Encoded Polyline Algorithm compresses sequences of geographic coordinates into a compact ASCII string, widely used in mapping APIs. This library provides a fully compliant .NET implementation with extensible, type-safe encoding and decoding APIs. ## Features @@ -59,6 +61,7 @@ var encoder = new MyPolylineEncoder(); string encoded = encoder.Encode(coordinates); // extension method for List Console.WriteLine(encoded); +// Output: "yseiHoc_MwacOjnwM" ``` ### Implement a custom decoder @@ -84,8 +87,22 @@ IEnumerable<(double Latitude, double Longitude)> decoded = decoder.Decode(encode ## Advanced Usage -- Pass a `PolylineEncodingOptions` (built via `PolylineEncodingOptionsBuilder`) to the encoder/decoder constructor for custom precision, stack-alloc limit, and logging. -- Use static methods on `PolylineEncoding` for low-level normalization, validation, and bit-level read/write operations. +Use `PolylineEncodingOptionsBuilder` to customize precision, buffer size, and logging, then pass the built options to the encoder or decoder constructor: + +```csharp +using Microsoft.Extensions.Logging; + +PolylineEncodingOptions options = PolylineEncodingOptionsBuilder.Create() + .WithPrecision(6) // 6 decimal places instead of the default 5 + .WithStackAllocLimit(1024) // increase stack-alloc buffer + .WithLoggerFactory(loggerFactory) // plug in your ILoggerFactory + .Build(); + +var encoder = new MyPolylineEncoder(options); +var decoder = new MyPolylineDecoder(options); +``` + +Use static methods on `PolylineEncoding` for low-level normalization, validation, and bit-level read/write operations. > See [API Reference](https://petesramek.github.io/polyline-algorithm-csharp/) for full documentation. @@ -93,6 +110,8 @@ IEnumerable<(double Latitude, double Longitude)> decoded = decoder.Decode(encode - **What coordinate ranges are valid?** Latitude: -90..90, Longitude: -180..180 (throws `ArgumentOutOfRangeException` for invalid input) +- **What happens if I pass malformed input to the decoder?** + The decoder throws `InvalidPolylineException` with a descriptive message. Wrap calls in a try/catch in your application. - **What .NET versions are supported?** Any environment supporting `netstandard2.1` - **How do I customize encoder options?** @@ -100,6 +119,11 @@ IEnumerable<(double Latitude, double Longitude)> decoded = decoder.Decode(encode - **Where can I get help?** [GitHub issues](https://github.com/petesramek/polyline-algorithm-csharp/issues) +## Resources + +- [GitHub Repository](https://github.com/petesramek/polyline-algorithm-csharp) — source code, issues, changelog, and samples +- [API Reference](https://petesramek.github.io/polyline-algorithm-csharp/) — full auto-generated documentation + ## License MIT License © Pete Sramek diff --git a/tests/PolylineAlgorithm.Tests/Abstraction/AbstractPolylineDecoderTests.cs b/tests/PolylineAlgorithm.Tests/Abstraction/AbstractPolylineDecoderTests.cs index c6734c6c..f963ffb2 100644 --- a/tests/PolylineAlgorithm.Tests/Abstraction/AbstractPolylineDecoderTests.cs +++ b/tests/PolylineAlgorithm.Tests/Abstraction/AbstractPolylineDecoderTests.cs @@ -38,7 +38,7 @@ public void Decode_With_Null_Polyline_Throws_ArgumentNullException() { // Act & Assert ArgumentNullException ex = Assert.ThrowsExactly(() => decoder.Decode((string?)null!).ToList()); - Assert.AreEqual("polyline", ex.ParamName); + Assert.AreEqual("polyline", ex.ParamName, StringComparer.Ordinal); } /// @@ -94,7 +94,7 @@ public void Decode_With_Valid_Polyline_Returns_Expected_Coordinates() { public void Constructor_With_Null_Options_Throws_ArgumentNullException() { // Act & Assert ArgumentNullException ex = Assert.ThrowsExactly(() => new TestStringDecoderWithOptions(null!)); - Assert.AreEqual("options", ex.ParamName); + Assert.AreEqual("options", ex.ParamName, StringComparer.Ordinal); } /// diff --git a/tests/PolylineAlgorithm.Tests/Abstraction/AbstractPolylineEncoderTests.cs b/tests/PolylineAlgorithm.Tests/Abstraction/AbstractPolylineEncoderTests.cs index f537dda2..70235f1e 100644 --- a/tests/PolylineAlgorithm.Tests/Abstraction/AbstractPolylineEncoderTests.cs +++ b/tests/PolylineAlgorithm.Tests/Abstraction/AbstractPolylineEncoderTests.cs @@ -48,7 +48,7 @@ public void Constructor_With_Default_Options_Creates_Instance() { public void Constructor_With_Null_Options_Throws_ArgumentNullException() { // Act & Assert ArgumentNullException ex = Assert.ThrowsExactly(() => new TestStringEncoder(null!)); - Assert.AreEqual("options", ex.ParamName); + Assert.AreEqual("options", ex.ParamName, StringComparer.Ordinal); } /// @@ -111,7 +111,7 @@ public void Encode_With_Known_Coordinates_Returns_Expected_Polyline() { string result = encoder.Encode(coordinates.AsSpan()); // Assert - Assert.AreEqual(expected, result); + Assert.AreEqual(expected, result, StringComparer.Ordinal); } /// @@ -147,6 +147,6 @@ public void Encode_With_Small_Stack_Alloc_Limit_Uses_Heap_Allocation_And_Produce string result = encoder.Encode(coordinates.AsSpan()); // Assert - Assert.AreEqual(expected, result); + Assert.AreEqual(expected, result, StringComparer.Ordinal); } } diff --git a/tests/PolylineAlgorithm.Tests/Extensions/PolylineDecoderExtensionsTests.cs b/tests/PolylineAlgorithm.Tests/Extensions/PolylineDecoderExtensionsTests.cs index c44c914d..4514bd52 100644 --- a/tests/PolylineAlgorithm.Tests/Extensions/PolylineDecoderExtensionsTests.cs +++ b/tests/PolylineAlgorithm.Tests/Extensions/PolylineDecoderExtensionsTests.cs @@ -40,7 +40,7 @@ public void Decode_With_Char_Array_Null_Decoder_Throws_ArgumentNullException() { // Act & Assert ArgumentNullException ex = Assert.ThrowsExactly( () => decoder!.Decode(polyline).ToList()); - Assert.AreEqual("decoder", ex.ParamName); + Assert.AreEqual("decoder", ex.ParamName, StringComparer.Ordinal); } /// @@ -55,7 +55,7 @@ public void Decode_With_Char_Array_Null_Polyline_Throws_ArgumentNullException() // Act & Assert ArgumentNullException ex = Assert.ThrowsExactly( () => decoder.Decode(polyline!).ToList()); - Assert.AreEqual("polyline", ex.ParamName); + Assert.AreEqual("polyline", ex.ParamName, StringComparer.Ordinal); } /// @@ -93,7 +93,7 @@ public void Decode_With_Memory_Null_Decoder_Throws_ArgumentNullException() { // Act & Assert ArgumentNullException ex = Assert.ThrowsExactly( () => decoder!.Decode(polyline).ToList()); - Assert.AreEqual("decoder", ex.ParamName); + Assert.AreEqual("decoder", ex.ParamName, StringComparer.Ordinal); } /// @@ -131,7 +131,7 @@ public void Decode_With_String_Null_Decoder_Throws_ArgumentNullException() { // Act & Assert ArgumentNullException ex = Assert.ThrowsExactly( () => PolylineDecoderExtensions.Decode<(double, double)>(decoder!, polyline).ToList()); - Assert.AreEqual("decoder", ex.ParamName); + Assert.AreEqual("decoder", ex.ParamName, StringComparer.Ordinal); } /// @@ -146,7 +146,7 @@ public void Decode_With_String_Null_Polyline_Throws_ArgumentNullException() { // Act & Assert ArgumentNullException ex = Assert.ThrowsExactly( () => decoder.Decode(polyline!).ToList()); - Assert.AreEqual("polyline", ex.ParamName); + Assert.AreEqual("polyline", ex.ParamName, StringComparer.Ordinal); } /// diff --git a/tests/PolylineAlgorithm.Tests/Extensions/PolylineEncoderExtensionsTests.cs b/tests/PolylineAlgorithm.Tests/Extensions/PolylineEncoderExtensionsTests.cs index da8a622e..da885678 100644 --- a/tests/PolylineAlgorithm.Tests/Extensions/PolylineEncoderExtensionsTests.cs +++ b/tests/PolylineAlgorithm.Tests/Extensions/PolylineEncoderExtensionsTests.cs @@ -36,7 +36,7 @@ public void Encode_With_List_Null_Encoder_Throws_ArgumentNullException() { // Act & Assert ArgumentNullException ex = Assert.ThrowsExactly( () => encoder!.Encode(coordinates)); - Assert.AreEqual("encoder", ex.ParamName); + Assert.AreEqual("encoder", ex.ParamName, StringComparer.Ordinal); } /// @@ -51,7 +51,7 @@ public void Encode_With_List_Null_Coordinates_Throws_ArgumentNullException() { // Act & Assert ArgumentNullException ex = Assert.ThrowsExactly( () => encoder.Encode(coordinates!)); - Assert.AreEqual("coordinates", ex.ParamName); + Assert.AreEqual("coordinates", ex.ParamName, StringComparer.Ordinal); } /// @@ -68,7 +68,7 @@ public void Encode_With_List_Valid_Coordinates_Returns_Expected_Polyline() { string result = encoder.Encode(coordinates); // Assert - Assert.AreEqual(expected, result); + Assert.AreEqual(expected, result, StringComparer.Ordinal); } // ----- Encode(T[]) ----- @@ -86,7 +86,7 @@ public void Encode_With_Array_Null_Encoder_Throws_ArgumentNullException() { // Act & Assert ArgumentNullException ex = Assert.ThrowsExactly( () => PolylineEncoderExtensions.Encode<(double, double), string>(encoder!, coordinates)); - Assert.AreEqual("encoder", ex.ParamName); + Assert.AreEqual("encoder", ex.ParamName, StringComparer.Ordinal); } /// @@ -101,7 +101,7 @@ public void Encode_With_Array_Null_Coordinates_Throws_ArgumentNullException() { // Act & Assert ArgumentNullException ex = Assert.ThrowsExactly( () => PolylineEncoderExtensions.Encode<(double, double), string>(encoder, coordinates!)); - Assert.AreEqual("coordinates", ex.ParamName); + Assert.AreEqual("coordinates", ex.ParamName, StringComparer.Ordinal); } /// @@ -118,6 +118,6 @@ public void Encode_With_Array_Valid_Coordinates_Returns_Expected_Polyline() { string result = encoder.Encode(coordinates); // Assert - Assert.AreEqual(expected, result); + Assert.AreEqual(expected, result, StringComparer.Ordinal); } } diff --git a/tests/PolylineAlgorithm.Tests/Internal/Diagnostics/ExceptionGuardTests.cs b/tests/PolylineAlgorithm.Tests/Internal/Diagnostics/ExceptionGuardTests.cs index b49e719a..a05da459 100644 --- a/tests/PolylineAlgorithm.Tests/Internal/Diagnostics/ExceptionGuardTests.cs +++ b/tests/PolylineAlgorithm.Tests/Internal/Diagnostics/ExceptionGuardTests.cs @@ -23,7 +23,7 @@ public void ThrowNotFiniteNumber_With_Param_Name_Throws_ArgumentOutOfRangeExcept // Act & Assert var ex = Assert.ThrowsExactly(() => ExceptionGuard.ThrowNotFiniteNumber(paramName)); - Assert.AreEqual(paramName, ex.ParamName); + Assert.AreEqual(paramName, ex.ParamName, StringComparer.Ordinal); Assert.IsNotNull(ex.Message); } @@ -37,7 +37,7 @@ public void ThrowArgumentNull_With_Param_Name_Throws_ArgumentNullException() { // Act & Assert var ex = Assert.ThrowsExactly(() => ExceptionGuard.ThrowArgumentNull(paramName)); - Assert.AreEqual(paramName, ex.ParamName); + Assert.AreEqual(paramName, ex.ParamName, StringComparer.Ordinal); } /// @@ -50,7 +50,7 @@ public void ThrowBufferOverflow_With_Message_Throws_OverflowException() { // Act & Assert var ex = Assert.ThrowsExactly(() => ExceptionGuard.ThrowBufferOverflow(message)); - Assert.AreEqual(message, ex.Message); + Assert.AreEqual(message, ex.Message, StringComparer.Ordinal); } /// @@ -66,7 +66,7 @@ public void ThrowCoordinateValueOutOfRange_With_Parameters_Throws_ArgumentOutOfR // Act & Assert var ex = Assert.ThrowsExactly(() => ExceptionGuard.ThrowCoordinateValueOutOfRange(value, min, max, paramName)); - Assert.AreEqual(paramName, ex.ParamName); + Assert.AreEqual(paramName, ex.ParamName, StringComparer.Ordinal); Assert.IsNotNull(ex.Message); } @@ -81,7 +81,7 @@ public void StackAllocLimitMustBeEqualOrGreaterThan_With_Parameters_Throws_Argum // Act & Assert var ex = Assert.ThrowsExactly(() => ExceptionGuard.StackAllocLimitMustBeEqualOrGreaterThan(minValue, paramName)); - Assert.AreEqual(paramName, ex.ParamName); + Assert.AreEqual(paramName, ex.ParamName, StringComparer.Ordinal); Assert.IsNotNull(ex.Message); } @@ -95,7 +95,7 @@ public void ThrowArgumentCannotBeEmptyEnumerationMessage_With_Param_Name_Throws_ // Act & Assert var ex = Assert.ThrowsExactly(() => ExceptionGuard.ThrowArgumentCannotBeEmptyEnumerationMessage(paramName)); - Assert.AreEqual(paramName, ex.ParamName); + Assert.AreEqual(paramName, ex.ParamName, StringComparer.Ordinal); Assert.IsNotNull(ex.Message); } @@ -121,7 +121,7 @@ public void ThrowDestinationArrayLengthMustBeEqualOrGreaterThanPolylineLength_Wi // Act & Assert var ex = Assert.ThrowsExactly(() => ExceptionGuard.ThrowDestinationArrayLengthMustBeEqualOrGreaterThanPolylineLength(destinationLength, polylineLength, paramName)); - Assert.AreEqual(paramName, ex.ParamName); + Assert.AreEqual(paramName, ex.ParamName, StringComparer.Ordinal); Assert.IsNotNull(ex.Message); } diff --git a/tests/PolylineAlgorithm.Tests/InvalidPolylineExceptionTests.cs b/tests/PolylineAlgorithm.Tests/InvalidPolylineExceptionTests.cs index 9c0ffcc3..c0c1c2b9 100644 --- a/tests/PolylineAlgorithm.Tests/InvalidPolylineExceptionTests.cs +++ b/tests/PolylineAlgorithm.Tests/InvalidPolylineExceptionTests.cs @@ -38,7 +38,7 @@ public void Constructor_With_Message_And_Inner_Exception_Stores_Both() { InvalidPolylineException ex = new(message, inner); // Assert - Assert.AreEqual(message, ex.Message); + Assert.AreEqual(message, ex.Message, StringComparer.Ordinal); Assert.AreSame(inner, ex.InnerException); } } diff --git a/tests/PolylineAlgorithm.Tests/PolylineAlgorithm.Tests.csproj b/tests/PolylineAlgorithm.Tests/PolylineAlgorithm.Tests.csproj index ff88dcb7..ebb293c9 100644 --- a/tests/PolylineAlgorithm.Tests/PolylineAlgorithm.Tests.csproj +++ b/tests/PolylineAlgorithm.Tests/PolylineAlgorithm.Tests.csproj @@ -16,19 +16,24 @@ - - - - - - - - + + + + + + + + + + + + + diff --git a/tests/PolylineAlgorithm.Tests/PolylineEncodingOptionsBuilderTests.cs b/tests/PolylineAlgorithm.Tests/PolylineEncodingOptionsBuilderTests.cs index 26dafc74..ce10f32d 100644 --- a/tests/PolylineAlgorithm.Tests/PolylineEncodingOptionsBuilderTests.cs +++ b/tests/PolylineAlgorithm.Tests/PolylineEncodingOptionsBuilderTests.cs @@ -199,7 +199,7 @@ public void WithStackAllocLimit_With_Zero_Throws_ArgumentOutOfRangeException() { var exception = Assert.ThrowsExactly(() => builder.WithStackAllocLimit(0)); // Assert - Assert.AreEqual("stackAllocLimit", exception.ParamName); + Assert.AreEqual("stackAllocLimit", exception.ParamName, StringComparer.Ordinal); } /// @@ -214,7 +214,7 @@ public void WithStackAllocLimit_With_Negative_Value_Throws_ArgumentOutOfRangeExc var exception = Assert.ThrowsExactly(() => builder.WithStackAllocLimit(-10)); // Assert - Assert.AreEqual("stackAllocLimit", exception.ParamName); + Assert.AreEqual("stackAllocLimit", exception.ParamName, StringComparer.Ordinal); } /// diff --git a/tests/PolylineAlgorithm.Tests/PolylineEncodingTests.cs b/tests/PolylineAlgorithm.Tests/PolylineEncodingTests.cs index d598e3c2..b2eadce8 100644 --- a/tests/PolylineAlgorithm.Tests/PolylineEncodingTests.cs +++ b/tests/PolylineAlgorithm.Tests/PolylineEncodingTests.cs @@ -344,7 +344,7 @@ public void TryWriteValue_With_Positive_Value_Writes_Correctly() { // Assert Assert.IsTrue(result); Assert.AreEqual(5, position); - Assert.AreEqual("_p~iF", new string(buffer[..5])); + Assert.AreEqual("_p~iF", new string(buffer[..5]), StringComparer.Ordinal); } /// @@ -362,7 +362,7 @@ public void TryWriteValue_With_Negative_Value_Writes_Correctly() { // Assert Assert.IsTrue(result); Assert.AreEqual(5, position); - Assert.AreEqual("~ps|U", new string(buffer[..5])); + Assert.AreEqual("~ps|U", new string(buffer[..5]), StringComparer.Ordinal); } /// @@ -381,7 +381,7 @@ public void TryWriteValue_With_Multiple_Values_Writes_Sequentially() { // Assert Assert.IsTrue(first); Assert.IsTrue(second); - Assert.AreEqual("_p~iF~ps|U", new string(buffer[..10])); + Assert.AreEqual("_p~iF~ps|U", new string(buffer[..10]), StringComparer.Ordinal); } /// @@ -613,69 +613,4 @@ public void ValidateFormat_With_Block_Too_Long_Throws_InvalidPolylineException() } #endregion - - #region ValidateCharRange Tests - - /// - /// Tests that ValidateCharRange succeeds for valid polyline strings. - /// - [TestMethod] - [DataRow("_p~iF~ps|U_ulLnnqC_mqNvxq`@")] - [DataRow("?")] // min valid char (ASCII 63) - [DataRow("~")] // max valid char (ASCII 126) - [DataRow("")] // empty is valid - [DataRow("????????????????????????????????")] // long string to trigger SIMD path - public void ValidateCharRange_With_Valid_Polyline_Does_Not_Throw(string polyline) { - // Act & Assert - PolylineEncoding.ValidateCharRange(polyline); - } - - /// - /// Tests that ValidateCharRange throws for polylines containing invalid characters. - /// - [TestMethod] - [DataRow(">")] // ASCII 62 (below min of 63) - [DataRow("\u007F")] // ASCII 127 (above max of 126) - [DataRow("_p~iF!ps|U")] // '!' in middle (ASCII 33) - [DataRow("_p~iF~ps|U!")] // '!' at end - [DataRow("????????!???????????????????????")] // invalid in SIMD section - [DataRow("????????????????\u007F")] // invalid in scalar remainder - public void ValidateCharRange_With_Invalid_Character_Throws_InvalidPolylineException(string polyline) { - // Act & Assert - Assert.ThrowsExactly(() => PolylineEncoding.ValidateCharRange(polyline)); - } - - #endregion - - #region ValidateBlockLength Tests - - /// - /// Tests that ValidateBlockLength succeeds for valid block structures. - /// - [TestMethod] - [DataRow("?")] // single terminator - [DataRow("_p~iF~ps|U")] // multiple blocks - [DataRow("______?")] // 6 continuation chars + terminator (maximum block length) - [DataRow("??")] // consecutive terminators - [DataRow("?__?_____?")] // mixed block lengths - public void ValidateBlockLength_With_Valid_Polyline_Does_Not_Throw(string polyline) { - // Act & Assert - PolylineEncoding.ValidateBlockLength(polyline); - } - - /// - /// Tests that ValidateBlockLength throws for invalid block structures. - /// - [TestMethod] - [DataRow("________?")] // 8-char block (exceeds max of 7) - [DataRow("________")] // all continuation chars, no terminator - [DataRow("")] // empty polyline has no terminator - [DataRow("?________?")] // valid block then too-long block - [DataRow("________?_?")] // first block too long - public void ValidateBlockLength_With_Invalid_Polyline_Throws_InvalidPolylineException(string polyline) { - // Act & Assert - Assert.ThrowsExactly(() => PolylineEncoding.ValidateBlockLength(polyline)); - } - - #endregion } diff --git a/utilities/PolylineAlgorithm.Utility/PolylineAlgorithm.Utility.csproj b/utilities/PolylineAlgorithm.Utility/PolylineAlgorithm.Utility.csproj index 16e415f8..7d646ec4 100644 --- a/utilities/PolylineAlgorithm.Utility/PolylineAlgorithm.Utility.csproj +++ b/utilities/PolylineAlgorithm.Utility/PolylineAlgorithm.Utility.csproj @@ -30,4 +30,9 @@ + + + + +