diff --git a/.github/workflows/develop-to-release.yml b/.github/workflows/develop-to-release.yml index 1bcd759..02dd580 100644 --- a/.github/workflows/develop-to-release.yml +++ b/.github/workflows/develop-to-release.yml @@ -31,31 +31,27 @@ jobs: with: fetch-depth: 0 - - name: Determine version bump + - name: Determine version and extract info id: version run: | - # Fetch all tags to ensure we have the latest git fetch --tags --force - # Get the latest tag (preferably from main branch) LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") echo "Last tag: $LAST_TAG" - # Parse version LAST_VERSION=${LAST_TAG#v} IFS='.' read -ra VERSION_PARTS <<< "$LAST_VERSION" MAJOR=${VERSION_PARTS[0]:-0} MINOR=${VERSION_PARTS[1]:-0} PATCH=${VERSION_PARTS[2]:-0} - # Get commits since last tag if [ "$LAST_TAG" = "v0.0.0" ]; then COMMITS=$(git log --pretty=format:"%s") else COMMITS=$(git log "${LAST_TAG}..HEAD" --pretty=format:"%s") fi - # Determine bump type based on conventional commits + # Determine bump type if echo "$COMMITS" | grep -qiE "^(BREAKING CHANGE|feat!|fix!):"; then MAJOR=$((MAJOR + 1)) MINOR=0 @@ -72,9 +68,7 @@ jobs: NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" - # Check if this version already exists as a tag and increment if needed while git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; do - echo "⚠️ Tag v${NEW_VERSION} already exists, incrementing patch version..." PATCH=$((PATCH + 1)) NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" done @@ -82,7 +76,20 @@ jobs: echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "bump_type=$BUMP_TYPE" >> "$GITHUB_OUTPUT" echo "last_tag=$LAST_TAG" >> "$GITHUB_OUTPUT" - echo "βœ… New version will be: v$NEW_VERSION (${BUMP_TYPE} bump from $LAST_TAG)" + + # Extract issues + ISSUES=$(echo "$COMMITS" | grep -oiE '(close[sd]?|fix(es|ed)?|resolve[sd]?) #[0-9]+' | grep -oE '#[0-9]+' | sort -u | tr '\n' ' ' || echo "") + echo "issues=$ISSUES" >> "$GITHUB_OUTPUT" + + # Generate changelog + if [ "$LAST_TAG" = "v0.0.0" ]; then + CHANGELOG=$(git log --pretty=format:'- %s `%h`' | head -20) + else + CHANGELOG=$(git log "${LAST_TAG}..HEAD" --pretty=format:'- %s `%h`') + fi + echo "changelog<> "$GITHUB_OUTPUT" + echo "$CHANGELOG" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" - name: Create or update release branch env: @@ -94,98 +101,38 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" if git ls-remote --heads origin "$RELEASE_BRANCH" | grep -q "$RELEASE_BRANCH"; then - echo "πŸ“Œ Branch $RELEASE_BRANCH jΓ‘ existe, atualizando..." git fetch origin "$RELEASE_BRANCH" git checkout "$RELEASE_BRANCH" git merge origin/develop --no-edit else - echo "πŸ†• Criando nova branch $RELEASE_BRANCH" git checkout -b "$RELEASE_BRANCH" fi git push origin "$RELEASE_BRANCH" - echo "RELEASE_BRANCH=$RELEASE_BRANCH" >> "$GITHUB_ENV" - - - name: Check if PR exists - id: check-pr - env: - GH_TOKEN: ${{ github.token }} - NEW_VERSION: ${{ steps.version.outputs.new_version }} - run: | - RELEASE_BRANCH="release/v${NEW_VERSION}" - - PR_EXISTS=$(gh pr list --head "$RELEASE_BRANCH" --base main --json number --jq 'length') - echo "pr_exists=$PR_EXISTS" >> "$GITHUB_OUTPUT" - - if [ "$PR_EXISTS" -gt 0 ]; then - PR_NUMBER=$(gh pr list --head "$RELEASE_BRANCH" --base main --json number --jq '.[0].number') - echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - fi - - - name: Extract issues from commits - id: extract-issues - env: - LAST_TAG: ${{ steps.version.outputs.last_tag }} - run: | - if [ "$LAST_TAG" = "v0.0.0" ]; then - COMMITS=$(git log --pretty=format:'%s' | head -50) - else - COMMITS=$(git log "${LAST_TAG}..HEAD" --pretty=format:'%s') - fi - - # Extract issue numbers (Closes #123, Fixes #456, #789) - ISSUES=$(echo "$COMMITS" | grep -oiE '(close[sd]?|fix(es|ed)?|resolve[sd]?) #[0-9]+' | grep -oE '#[0-9]+' | sort -u | tr '\n' ' ' || echo "") - echo "issues=$ISSUES" >> "$GITHUB_OUTPUT" - - if [ -n "$ISSUES" ]; then - echo "Found issues: $ISSUES" - else - echo "No issues referenced in commits" - fi - - - name: Generate changelog - id: changelog - env: - LAST_TAG: ${{ steps.version.outputs.last_tag }} - run: | - if [ "$LAST_TAG" = "v0.0.0" ]; then - CHANGELOG=$(git log --pretty=format:'- %s `%h`' | head -20) - else - CHANGELOG=$(git log "${LAST_TAG}..HEAD" --pretty=format:'- %s `%h`') - fi - echo "$CHANGELOG" > /tmp/changelog.txt - - - name: Create or update PR to main + - name: Check and create PR env: GH_TOKEN: ${{ github.token }} NEW_VERSION: ${{ steps.version.outputs.new_version }} BUMP_TYPE: ${{ steps.version.outputs.bump_type }} - ISSUES: ${{ steps.extract-issues.outputs.issues }} + ISSUES: ${{ steps.version.outputs.issues }} LAST_TAG: ${{ steps.version.outputs.last_tag }} + CHANGELOG: ${{ steps.version.outputs.changelog }} run: | RELEASE_BRANCH="release/v${NEW_VERSION}" - CHANGELOG=$(cat /tmp/changelog.txt) - - # Format issues list for PR body (optional section) - ISSUES_SECTION="" - if [ -n "$ISSUES" ]; then - ISSUES_LIST="" - for ISSUE in $ISSUES; do - ISSUE_NUM=${ISSUE#\#} - ISSUES_LIST+="- $ISSUE\n" - done - ISSUES_SECTION="### πŸ› Issues Resolvidas - $ISSUES_LIST - - _Essas issues serΓ£o fechadas automaticamente quando este PR for mergeado._ - - --- + + PR_EXISTS=$(gh pr list --head "$RELEASE_BRANCH" --base main --json number --jq 'length') - " - fi + if [ "$PR_EXISTS" -eq 0 ]; then + ISSUES_SECTION="" + if [ -n "$ISSUES" ]; then + ISSUES_LIST="" + for ISSUE in $ISSUES; do + ISSUES_LIST+="- $ISSUE\n" + done + ISSUES_SECTION="### πŸ› Issues Resolvidas\n$ISSUES_LIST\n\n---\n\n" + fi - if [ "${{ steps.check-pr.outputs.pr_exists }}" -eq 0 ]; then gh pr create \ --base main \ --head "$RELEASE_BRANCH" \ @@ -197,16 +144,9 @@ jobs: --- ### βš™οΈ Release Configuration - **Escolha o tipo de release editando abaixo:** - Release Type: [lts] - **OpΓ§Γ΅es vΓ‘lidas:** - - \`alpha\` - Release alpha (instΓ‘vel, desenvolvimento) - - \`beta\` - Release beta (prΓ©-release, testes) - - \`lts\` - Release estΓ‘vel de longo prazo (produΓ§Γ£o) - - **πŸ“ InstruΓ§Γ΅es:** Edite este PR e substitua \`lts\` acima por \`alpha\`, \`beta\` ou mantenha \`lts\`. + **Options:** \`alpha\`, \`beta\`, \`lts\` --- @@ -215,20 +155,8 @@ jobs: --- - ### βœ… Checklist antes do merge - - [ ] Revisei as mudanΓ§as - - [ ] Defini o tipo de release correto - - [ ] Testes passaram em develop - - [ ] DocumentaΓ§Γ£o atualizada (se necessΓ‘rio) - - --- - - _Este PR foi gerado automaticamente pelo workflow develop-to-release_" \ + _Auto-generated by develop-to-release workflow_" \ --draft - - echo "βœ… PR criado: release/v${NEW_VERSION} β†’ main (draft)" - else - echo "ℹ️ PR jΓ‘ existe (#${{ steps.check-pr.outputs.pr_number }})" fi close-issues-on-release: @@ -243,20 +171,13 @@ jobs: with: fetch-depth: 0 - - name: Extract version from branch - id: version - run: | - BRANCH_NAME="${{ github.head_ref }}" - VERSION=${BRANCH_NAME#release/} - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Checking for issues to close in release $VERSION" - - name: Extract and close issues env: GH_TOKEN: ${{ github.token }} - VERSION: ${{ steps.version.outputs.version }} run: | - # Get the last tag before this release + BRANCH_NAME="${{ github.head_ref }}" + VERSION=${BRANCH_NAME#release/} + LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") if [ -n "$LAST_TAG" ]; then @@ -265,40 +186,21 @@ jobs: COMMITS=$(git log --pretty=format:'%s') fi - # Extract all issue references ISSUES=$(echo "$COMMITS" | grep -oiE '(close[sd]?|fix(es|ed)?|resolve[sd]?|#)[[:space:]]*#[0-9]+' | grep -oE '#[0-9]+' | sort -u || echo "") if [ -z "$ISSUES" ]; then - echo "ℹ️ No issues referenced in commits - skipping issue closure" - echo "This is normal if commits didn't reference any issues." exit 0 fi - echo "Found issues to close: $ISSUES" - for ISSUE in $ISSUES; do ISSUE_NUM=${ISSUE#\#} - echo "Processing issue #$ISSUE_NUM" - # Check if issue exists if gh issue view "$ISSUE_NUM" &>/dev/null; then - # Add comment to issue gh issue comment "$ISSUE_NUM" --body "βœ… **Resolved in Release $VERSION** - This issue has been fixed and released in version \`$VERSION\`. - - **Release Details:** - - 🏷️ Version: $VERSION - - πŸ“¦ Release: [View Release](https://github.com/${{ github.repository }}/releases/tag/$VERSION) - - πŸ”€ PR: #${{ github.event.pull_request.number }} + **Release:** [View Release](https://github.com/${{ github.repository }}/releases/tag/$VERSION) + **PR:** #${{ github.event.pull_request.number }}" 2>/dev/null || true - Thank you for your contribution!" 2>/dev/null || echo "⚠️ Could not comment on issue #$ISSUE_NUM" - - # Close the issue - gh issue close "$ISSUE_NUM" --reason completed 2>/dev/null && echo "βœ… Issue #$ISSUE_NUM closed" || echo "⚠️ Could not close issue #$ISSUE_NUM (may already be closed)" - else - echo "⚠️ Issue #$ISSUE_NUM not found - skipping" + gh issue close "$ISSUE_NUM" --reason completed 2>/dev/null || true fi done - - echo "βœ… Issue processing complete" diff --git a/.github/workflows/feature-fix-workflow.yml b/.github/workflows/feature-fix-workflow.yml index 6064571..7447aaa 100644 --- a/.github/workflows/feature-fix-workflow.yml +++ b/.github/workflows/feature-fix-workflow.yml @@ -5,6 +5,7 @@ on: branches: - "feature/**" - "fix/**" + - "feat**" pull_request: types: - opened @@ -29,195 +30,137 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 1 - name: Setup Rust uses: dtolnay/rust-toolchain@stable + with: + components: clippy - name: Rust cache uses: Swatinem/rust-cache@v2 with: cache-on-failure: true + shared-key: "build-test" - - name: Build - run: cargo build --release --all-features - - - name: Run tests - run: cargo test --all-features + - name: Build and test + run: | + cargo build --release --all-features + cargo test --all-features - name: Run clippy run: cargo clippy --all-features -- -D warnings continue-on-error: true - create-pr-to-develop: + manage-pr-and-issues: if: github.event_name == 'push' needs: build-and-test runs-on: ubuntu-latest permissions: pull-requests: write contents: read + issues: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Check for commits difference - id: check-commits + - name: Check commits and extract issues + id: analyze run: | - git fetch origin develop + git fetch origin develop --depth=50 COMMITS_AHEAD=$(git rev-list --count "origin/develop..${{ github.ref_name }}") echo "commits_ahead=$COMMITS_AHEAD" >> "$GITHUB_OUTPUT" - echo "Branch estΓ‘ $COMMITS_AHEAD commits Γ  frente de develop" - - - name: Extract issues from commits - id: extract-issues - if: steps.check-commits.outputs.commits_ahead > 0 - run: | - git fetch origin develop - COMMITS=$(git log "origin/develop..${{ github.ref_name }}" --pretty=format:'%s') - - # Extract issue numbers - ISSUES=$(echo "$COMMITS" | grep -oiE '(close[sd]?|fix(es|ed)?|resolve[sd]?|#)[[:space:]]*#[0-9]+' | grep -oE '#[0-9]+' | sort -u | tr '\n' ' ' || echo "") - echo "issues=$ISSUES" >> "$GITHUB_OUTPUT" - if [ -n "$ISSUES" ]; then - echo "Found issues: $ISSUES" - else - echo "No issues referenced in commits" + if [ "$COMMITS_AHEAD" -gt 0 ]; then + COMMITS=$(git log "origin/develop..${{ github.ref_name }}" --pretty=format:'%s') + ISSUES=$(echo "$COMMITS" | grep -oiE '(close[sd]?|fix(es|ed)?|resolve[sd]?|#)[[:space:]]*#[0-9]+' | grep -oE '#[0-9]+' | sort -u | tr '\n' ' ' || echo "") + echo "issues=$ISSUES" >> "$GITHUB_OUTPUT" + + RECENT_COMMITS=$(git log "origin/develop..${{ github.ref_name }}" -n 5 --pretty=format:'- %s (%h)') + echo "recent_commits<> "$GITHUB_OUTPUT" + echo "$RECENT_COMMITS" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" fi - - name: Check if PR already exists + - name: Check existing PR id: check-pr - if: steps.check-commits.outputs.commits_ahead > 0 + if: steps.analyze.outputs.commits_ahead > 0 env: GH_TOKEN: ${{ github.token }} run: | - BRANCH_NAME="${{ github.ref_name }}" - PR_LIST=$(gh pr list --head "$BRANCH_NAME" --base develop --json number) - PR_EXISTS=$(echo "$PR_LIST" | jq 'length') - echo "pr_exists=$PR_EXISTS" >> "$GITHUB_OUTPUT" - - if [ "$PR_EXISTS" -gt 0 ]; then + PR_LIST=$(gh pr list --head "${{ github.ref_name }}" --base develop --json number,state) + PR_COUNT=$(echo "$PR_LIST" | jq 'length') + echo "pr_exists=$PR_COUNT" >> "$GITHUB_OUTPUT" + + if [ "$PR_COUNT" -gt 0 ]; then PR_NUMBER=$(echo "$PR_LIST" | jq -r '.[0].number') echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" fi - - name: Create Pull Request to develop - if: steps.check-commits.outputs.commits_ahead > 0 && steps.check-pr.outputs.pr_exists == '0' + - name: Create or update PR and issues + if: steps.analyze.outputs.commits_ahead > 0 env: GH_TOKEN: ${{ github.token }} - ISSUES: ${{ steps.extract-issues.outputs.issues }} + ISSUES: ${{ steps.analyze.outputs.issues }} + RECENT_COMMITS: ${{ steps.analyze.outputs.recent_commits }} + PR_EXISTS: ${{ steps.check-pr.outputs.pr_exists }} run: | - # Format issues list (optional section) + # Format issues section ISSUES_SECTION="" if [ -n "$ISSUES" ]; then ISSUES_LIST="" for ISSUE in $ISSUES; do ISSUES_LIST+="- $ISSUE\n" done - ISSUES_SECTION="### πŸ› Issues Addressed - $ISSUES_LIST - - " + ISSUES_SECTION="### πŸ› Issues Addressed\n$ISSUES_LIST\n\n" fi - - gh pr create \ - --base develop \ - --head "${{ github.ref_name }}" \ - --title "[${{ github.ref_name }}] Merge to develop" \ - --body "## πŸ€– Automated PR + + # Create PR if doesn't exist + if [ "$PR_EXISTS" -eq 0 ]; then + gh pr create \ + --base develop \ + --head "${{ github.ref_name }}" \ + --title "[${{ github.ref_name }}] Merge to develop" \ + --body "## πŸ€– Automated PR βœ… **Build:** Passed βœ… **Tests:** Passed ${ISSUES_SECTION}### πŸ“Š Details - - **Commits:** ${{ steps.check-commits.outputs.commits_ahead }} + - **Commits:** ${{ steps.analyze.outputs.commits_ahead }} - **Branch:** \`${{ github.ref_name }}\` - - **Commit:** \`${{ github.sha }}\` - **Author:** @${{ github.actor }} ### πŸ“ Recent Commits - $(git log "origin/develop..${{ github.ref_name }}" --pretty=format:'- %s (%h)' | head -5) + $RECENT_COMMITS --- - _This PR was automatically created by the feature-fix-workflow_" - - - name: Skip PR creation - No new commits - if: steps.check-commits.outputs.commits_ahead == 0 - run: | - echo "⏭️ Nenhum commit novo entre ${{ github.ref_name }} e develop" - echo "PR nΓ£o serΓ‘ criado pois nΓ£o hΓ‘ mudanΓ§as para mergear" - - - name: Skip PR creation - PR already exists - if: steps.check-commits.outputs.commits_ahead > 0 && steps.check-pr.outputs.pr_exists != '0' - run: | - echo "ℹ️ PR jΓ‘ existe para a branch ${{ github.ref_name }}" - - add-issue-comments: - if: github.event_name == 'push' - needs: build-and-test - runs-on: ubuntu-latest - permissions: - issues: write - contents: read - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Extract and comment on issues - env: - GH_TOKEN: ${{ github.token }} - run: | - git fetch origin develop - COMMITS=$(git log "origin/develop..${{ github.ref_name }}" --pretty=format:'%s' 2>/dev/null || echo "") - - if [ -z "$COMMITS" ]; then - echo "ℹ️ No commits to process" - exit 0 + _Auto-created by feature-fix-workflow_" fi - - # Extract issue numbers - ISSUES=$(echo "$COMMITS" | grep -oiE '(close[sd]?|fix(es|ed)?|resolve[sd]?|#)[[:space:]]*#[0-9]+' | grep -oE '#[0-9]+' | sort -u || echo "") - - if [ -z "$ISSUES" ]; then - echo "ℹ️ No issues referenced in commits - skipping" - echo "This is normal for commits that don't reference issues." - exit 0 - fi - - echo "Found issues to comment on: $ISSUES" - - for ISSUE in $ISSUES; do - ISSUE_NUM=${ISSUE#\#} - echo "Processing issue #$ISSUE_NUM" - - # Check if issue exists and is open - ISSUE_STATE=$(gh issue view "$ISSUE_NUM" --json state --jq .state 2>/dev/null || echo "NOT_FOUND") - - if [ "$ISSUE_STATE" = "OPEN" ]; then - # Get recent commits for this branch - RECENT_COMMITS=$(git log "origin/develop..${{ github.ref_name }}" -n 3 --pretty=format:'- %s (%h)') - - gh issue comment "$ISSUE_NUM" --body "πŸ”„ **Update from \`${{ github.ref_name }}\`** + + # Comment on open issues + if [ -n "$ISSUES" ]; then + for ISSUE in $ISSUES; do + ISSUE_NUM=${ISSUE#\#} + ISSUE_STATE=$(gh issue view "$ISSUE_NUM" --json state --jq .state 2>/dev/null || echo "NOT_FOUND") + + if [ "$ISSUE_STATE" = "OPEN" ]; then + gh issue comment "$ISSUE_NUM" --body "πŸ”„ **Update from \`${{ github.ref_name }}\`** New commits pushed: $RECENT_COMMITS **Status:** In development - **Branch:** [${{ github.ref_name }}](https://github.com/${{ github.repository }}/tree/${{ github.ref_name }}) **Latest commit:** [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}) - --- - _Automated update from feature/fix workflow_" 2>/dev/null && echo "βœ… Comment added to issue #$ISSUE_NUM" || echo "⚠️ Could not comment on issue #$ISSUE_NUM" - else - echo "⏭️ Skipping issue #$ISSUE_NUM (state: $ISSUE_STATE)" - fi - done - - echo "βœ… Issue comment processing complete" + _Auto-update from feature/fix workflow_" 2>/dev/null || true + fi + done + fi close-issues-on-merge: if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.base_ref == 'develop' @@ -225,74 +168,39 @@ jobs: permissions: issues: write contents: read - pull-requests: read steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 - ref: develop + fetch-depth: 1 - - name: Extract and close issues + - name: Close referenced issues env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} - HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | - echo "PR #$PR_NUMBER merged from $HEAD_REF into develop" - - # Get commits from the merged PR COMMITS=$(gh pr view "$PR_NUMBER" --json commits --jq '.commits[].messageHeadline') - - if [ -z "$COMMITS" ]; then - echo "ℹ️ No commits found in PR" - exit 0 - fi - - echo "Commits in PR:" - echo "$COMMITS" - - # Extract all issue references (Closes #123, Fixes #456, #789) ISSUES=$(echo "$COMMITS" | grep -oiE '(close[sd]?|fix(es|ed)?|resolve[sd]?)[[:space:]]*#[0-9]+' | grep -oE '#[0-9]+' | sort -u || echo "") if [ -z "$ISSUES" ]; then - echo "ℹ️ No issues referenced with close/fix/resolve keywords" + echo "No issues to close" exit 0 fi - echo "Found issues to close: $ISSUES" - for ISSUE in $ISSUES; do ISSUE_NUM=${ISSUE#\#} - echo "Processing issue #$ISSUE_NUM" - - # Check if issue exists and is open ISSUE_STATE=$(gh issue view "$ISSUE_NUM" --json state --jq .state 2>/dev/null || echo "NOT_FOUND") if [ "$ISSUE_STATE" = "OPEN" ]; then - # Add completion comment gh issue comment "$ISSUE_NUM" --body "βœ… **Completed and merged to develop** - This issue has been resolved and merged into the \`develop\` branch. - **Merge Details:** - πŸ”€ PR: #$PR_NUMBER - - 🌿 Branch: \`$HEAD_REF\` + - 🌿 Branch: \`${{ github.event.pull_request.head.ref }}\` - πŸ‘€ Merged by: @${{ github.event.pull_request.merged_by.login }} - - πŸ“… Merged at: ${{ github.event.pull_request.merged_at }} - This fix will be included in the next release. - - --- - _Automatically closed by feature-fix workflow_" 2>/dev/null && echo "βœ… Comment added to issue #$ISSUE_NUM" || echo "⚠️ Could not comment on issue #$ISSUE_NUM" + This fix will be included in the next release." 2>/dev/null || true - # Close the issue - gh issue close "$ISSUE_NUM" --reason completed 2>/dev/null && echo "βœ… Issue #$ISSUE_NUM closed" || echo "⚠️ Could not close issue #$ISSUE_NUM" - elif [ "$ISSUE_STATE" = "CLOSED" ]; then - echo "ℹ️ Issue #$ISSUE_NUM already closed" - else - echo "⚠️ Issue #$ISSUE_NUM not found" + gh issue close "$ISSUE_NUM" --reason completed 2>/dev/null || true fi done - - echo "βœ… Issue closure processing complete" diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index 79005b2..66d255b 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -29,63 +29,41 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 - - name: Check if PR already exists - id: check-pr + - name: Check and create PR env: GH_TOKEN: ${{ github.token }} run: | HEAD_BRANCH="${{ github.ref_name }}" PR_EXISTS=$(gh pr list --head "$HEAD_BRANCH" --base main --json number --jq 'length') - echo "pr_exists=$PR_EXISTS" >> "$GITHUB_OUTPUT" - if [ "$PR_EXISTS" -gt 0 ]; then - PR_NUMBER=$(gh pr list --head "$HEAD_BRANCH" --base main --json number --jq '.[0].number') - echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - fi + if [ "$PR_EXISTS" -eq 0 ]; then + VERSION="${HEAD_BRANCH#release/v}" - - name: Create PR to main - if: steps.check-pr.outputs.pr_exists == '0' - env: - GH_TOKEN: ${{ github.token }} - run: | - HEAD_BRANCH="${{ github.ref_name }}" - VERSION="${HEAD_BRANCH#release/v}" - - gh pr create \ - --base main \ - --head "$HEAD_BRANCH" \ - --title "πŸš€ Release v${VERSION}" \ - --body "## πŸš€ Release v${VERSION} + gh pr create \ + --base main \ + --head "$HEAD_BRANCH" \ + --title "πŸš€ Release v${VERSION}" \ + --body "## πŸš€ Release v${VERSION} ### βš™οΈ Release Configuration - **Escolha o tipo de release editando abaixo:** - Release Type: [lts] - **OpΓ§Γ΅es vΓ‘lidas:** - - \`alpha\` - Release alpha (instΓ‘vel, desenvolvimento) - - \`beta\` - Release beta (prΓ©-release, testes) - - \`lts\` - Release estΓ‘vel de longo prazo (produΓ§Γ£o) + **Options:** \`alpha\`, \`beta\`, \`lts\` --- - _PR criado automaticamente pelo release-workflow_" \ - --draft - - - name: PR already exists - if: steps.check-pr.outputs.pr_exists != '0' - run: | - echo "ℹ️ PR jΓ‘ existe (#${{ steps.check-pr.outputs.pr_number }}) para ${{ github.ref_name }} -> main" + _Auto-created by release-workflow_" \ + --draft + fi create-release: - name: Create GitHub Release (on merge) + name: Create GitHub Release if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/') runs-on: ubuntu-latest permissions: contents: write - pull-requests: write steps: - uses: actions/checkout@v4 @@ -93,7 +71,7 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.merge_commit_sha }} - - name: Extract version and release type + - name: Extract version and type id: extract env: PR_BODY: ${{ github.event.pull_request.body }} @@ -103,12 +81,7 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" PR_BODY_CLEAN=$(printf '%s' "$PR_BODY" | tr -d '\r') - - RELEASE_TYPE=$(printf '%s' "$PR_BODY_CLEAN" | grep -oP 'Release Type:\s*\[\K(alpha|beta|lts)(?=\])' || true) - if [ -z "$RELEASE_TYPE" ]; then - RELEASE_TYPE=$(printf '%s' "$PR_BODY_CLEAN" | grep -oP 'Release Type:\s*\K(alpha|beta|lts)' || echo "lts") - fi - + RELEASE_TYPE=$(printf '%s' "$PR_BODY_CLEAN" | grep -oP 'Release Type:\s*\[\K(alpha|beta|lts)(?=\])' || echo "lts") echo "release_type=$RELEASE_TYPE" >> "$GITHUB_OUTPUT" if [ "$RELEASE_TYPE" = "lts" ]; then @@ -118,97 +91,63 @@ jobs: fi echo "full_tag=$FULL_TAG" >> "$GITHUB_OUTPUT" - echo "βœ… Version: $VERSION" - echo "βœ… Release Type: $RELEASE_TYPE" - echo "βœ… Full Tag: $FULL_TAG" - - name: Setup Rust + - name: Setup Rust and build uses: dtolnay/rust-toolchain@stable - name: Rust cache uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: true - name: Build release artifacts run: | - echo "πŸ”¨ Building release artifacts..." cargo build --release - mkdir -p artifacts - if [ -d "target/release" ]; then - find target/release -maxdepth 1 -type f -executable -exec cp {} artifacts/ \; - fi + find target/release -maxdepth 1 -type f -executable -exec cp {} artifacts/ \; || true - echo "πŸ“¦ Artifacts criados:" - ls -lh artifacts/ || true - - - name: Create and push tag + - name: Create tag and release env: + GH_TOKEN: ${{ github.token }} FULL_TAG: ${{ steps.extract.outputs.full_tag }} + VERSION: ${{ steps.extract.outputs.version }} + RELEASE_TYPE: ${{ steps.extract.outputs.release_type }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - echo "🏷️ Creating tag $FULL_TAG" git tag -a "$FULL_TAG" -m "Release $FULL_TAG" git push origin "$FULL_TAG" - echo "βœ… Tag $FULL_TAG created and pushed" - - name: Generate changelog - id: changelog - run: | LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") if [ -z "$LAST_TAG" ]; then - echo "πŸ“ Generating full changelog (first release)" CHANGELOG=$(git log --pretty=format:"- %s (\`%h\`)" | head -50) else - echo "πŸ“ Generating changelog from $LAST_TAG" CHANGELOG=$(git log "${LAST_TAG}..HEAD" --pretty=format:"- %s (\`%h\`)") fi - echo "$CHANGELOG" > /tmp/changelog.txt - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ github.token }} - FULL_TAG: ${{ steps.extract.outputs.full_tag }} - VERSION: ${{ steps.extract.outputs.version }} - RELEASE_TYPE: ${{ steps.extract.outputs.release_type }} - run: | PRERELEASE_FLAG="" RELEASE_LABEL="Stable" if [ "$RELEASE_TYPE" = "alpha" ]; then PRERELEASE_FLAG="--prerelease" - RELEASE_LABEL="Alpha (Unstable)" + RELEASE_LABEL="Alpha" elif [ "$RELEASE_TYPE" = "beta" ]; then PRERELEASE_FLAG="--prerelease" - RELEASE_LABEL="Beta (Pre-release)" + RELEASE_LABEL="Beta" elif [ "$RELEASE_TYPE" = "lts" ]; then - RELEASE_LABEL="LTS (Long Term Support)" + RELEASE_LABEL="LTS" fi - CHANGELOG=$(cat /tmp/changelog.txt) - gh release create "$FULL_TAG" \ --title "Release $FULL_TAG" \ - --notes "## πŸŽ‰ Release $VERSION - - ### πŸ“¦ Release Type - **$RELEASE_LABEL** + --notes "## πŸŽ‰ Release $VERSION ($RELEASE_LABEL) ### πŸ“ Changes $CHANGELOG --- - - **Pull Request**: #${{ github.event.pull_request.number }} - - --- - - _Released on: $(date +'%Y-%m-%d %H:%M:%S UTC')_ - _Built from commit: \`${{ github.sha }}\`_" \ + **PR:** #${{ github.event.pull_request.number }} + **Commit:** \`${{ github.sha }}\`" \ $PRERELEASE_FLAG \ artifacts/* 2>/dev/null || true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a9f1c9e..0a39c0e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,10 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + jobs: test: name: Test Suite @@ -17,101 +21,51 @@ jobs: strategy: fail-fast: false matrix: - rust: [stable, beta, nightly] + rust: [stable, nightly] steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 1 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: toolchain: ${{ matrix.rust }} - - name: Cache cargo registry - uses: actions/cache@v4 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-registry- - - - name: Cache cargo index - uses: actions/cache@v4 + - name: Rust cache + uses: Swatinem/rust-cache@v2 with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-git- - - - name: Cache target directory - uses: actions/cache@v4 - with: - path: target - key: ${{ runner.os }}-target-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-target-${{ matrix.rust }}- - ${{ runner.os }}-target- - - - name: Build project - run: cargo build --all-features --verbose + cache-on-failure: true + key: ${{ matrix.rust }} - - name: Run tests (all features) - run: cargo test --all-features --verbose + - name: Build and test + run: | + cargo build --all-features --verbose + cargo test --all-features --verbose + cargo test --doc --all-features --verbose - - name: Run doctests - run: cargo test --doc --all-features --verbose - - fmt: - name: Rustfmt + quality: + name: Code Quality runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable with: - components: rustfmt - - - name: Check formatting - run: cargo fmt --all -- --check - - clippy: - name: Clippy - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 + fetch-depth: 1 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: - components: clippy + components: rustfmt, clippy - - name: Cache cargo registry - uses: actions/cache@v4 + - name: Rust cache + uses: Swatinem/rust-cache@v2 with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-registry- + cache-on-failure: true - - name: Cache cargo index - uses: actions/cache@v4 - with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-git- - - - name: Cache target directory - uses: actions/cache@v4 - with: - path: target - key: ${{ runner.os }}-target-clippy-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-target-clippy- - ${{ runner.os }}-target- + - name: Check formatting + run: cargo fmt --all -- --check - name: Run Clippy run: cargo clippy --all-features --all-targets -- -D warnings @@ -122,12 +76,17 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 1 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + - name: Rust cache + uses: Swatinem/rust-cache@v2 + - name: Install tarpaulin - run: cargo install cargo-tarpaulin + run: cargo install cargo-tarpaulin --locked - name: Generate coverage run: cargo tarpaulin --all-features --workspace --timeout 300 --out Xml diff --git a/Cargo.toml b/Cargo.toml index ff97d6b..1eb9856 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "apex-store-rs" # Alterado para ser ΓΊnico no crates.io +name = "apex-store-rs" version = "2.1.0" edition = "2021" authors = ["Elio Neto "] @@ -11,7 +11,6 @@ readme = "README.md" description = "A high-performance, embedded LSM-tree storage engine with SSTable V2 format, LZ4 compression, and Bloom Filters." keywords = ["database", "lsm-tree", "key-value", "storage-engine", "embedded"] categories = ["database-implementations", "data-structures"] -# Impede que arquivos de banco de dados locais sejam enviados no pacote exclude = [ "data/*", "target/*", @@ -26,11 +25,15 @@ name = "apexstore-server" path = "src/bin/server.rs" [[bin]] -name = "apexstore-cli" # Renomeado de 'cli' para evitar conflitos globais +name = "apexstore-cli" path = "src/bin/cli.rs" +[[bin]] +name = "apexstore-tui" +path = "src/bin/tui.rs" + [lib] -name = "apexstore" # Mantido para que vocΓͺ use `use apexstore;` no seu cΓ³digo +name = "apexstore" path = "src/lib.rs" [features] @@ -60,6 +63,11 @@ parking_lot = "0.12" tracing = "0.1" tracing-subscriber = "0.3" rand = "0.8" +# TUI dependencies +ratatui = "0.29" +crossterm = { version = "0.28", features = ["event-stream"] } +chrono = "0.4" +tui-input = "0.10" [dev-dependencies] tempfile = "3.24" @@ -69,7 +77,7 @@ criterion = { version = "0.5", features = ["html_reports"] } opt-level = 3 lto = true codegen-units = 1 -panic = "abort" # Opcional: reduz o tamanho do binΓ‘rio +panic = "abort" [profile.bench] -inherits = "release" \ No newline at end of file +inherits = "release" diff --git a/src/bin/tui.rs b/src/bin/tui.rs new file mode 100644 index 0000000..b9745e0 --- /dev/null +++ b/src/bin/tui.rs @@ -0,0 +1,858 @@ +//! ApexStore β€” Interactive TUI Dashboard +//! +//! Run : `cargo run --bin apexstore-tui` +//! Quit: `q`, `quit`, `exit`, or Esc / Ctrl-C +//! +//! Commands (identical to CLI): +//! SET | GET | DEL +//! SEARCH [--prefix] | SCAN | ALL | KEYS | COUNT +//! STATS [ALL] | BATCH | BATCH SET | DEMO | CLEAR | HELP + +use apexstore::{ + core::engine::LsmStats, + infra::config::LsmConfig, + LsmEngine, +}; +use chrono::Local; +use crossterm::{ + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, + KeyModifiers, MouseEventKind, + }, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Alignment, Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{ + BarChart, Block, Borders, Clear, Gauge, List, ListItem, Padding, Paragraph, Wrap, + }, + Frame, Terminal, +}; +use std::{ + collections::VecDeque, + io, + panic, + path::PathBuf, + time::{Duration, Instant}, +}; +use tui_input::backend::crossterm::EventHandler; +use tui_input::Input; + +// ─── Palette ────────────────────────────────────────────────────────────────── +const C_ORANGE: Color = Color::Rgb(255, 110, 30); +const C_AMBER: Color = Color::Rgb(255, 185, 0); +const C_DEEP: Color = Color::Rgb( 10, 14, 26); +const C_PANEL: Color = Color::Rgb( 18, 22, 38); +const C_BORDER: Color = Color::Rgb( 55, 65,100); +const C_ACTIVE: Color = Color::Rgb(100, 140,240); +const C_TEXT: Color = Color::Rgb(220, 225,245); +const C_DIM: Color = Color::Rgb( 90, 100,140); +const C_OK: Color = Color::Rgb( 80, 220,130); +const C_WARN: Color = Color::Rgb(255, 200, 50); +const C_ERR: Color = Color::Rgb(255, 80, 80); +const C_CLOCK: Color = Color::Rgb(130, 200,255); +const C_BAR: Color = Color::Rgb(255, 110, 30); +const C_BAR2: Color = Color::Rgb(100, 180,255); + +// ─── Focus ──────────────────────────────────────────────────────────────────── +#[derive(PartialEq, Clone, Copy)] +enum Focus { Stats, Log, Input } + +// ─── App ────────────────────────────────────────────────────────────────────── +struct App { + engine: LsmEngine, + focus: Focus, + input: Input, + log: VecDeque<(String, Color)>, + stats: Option, + ops_count: u64, + ops_last_count: u64, + ops_last_sample: Instant, + ops_per_sec: f64, + ops_history: VecDeque, + start: Instant, + uptime: u64, + mouse_pos: (u16, u16), + should_quit: bool, +} + +impl App { + fn new(engine: LsmEngine) -> Self { + let mut log = VecDeque::with_capacity(300); + log.push_back(("ApexStore TUI Dashboard \u{2014} engine ready.".into(), C_AMBER)); + log.push_back(("Type HELP for available commands.".into(), C_DIM)); + log.push_back(("\u{2500}".repeat(54), C_BORDER)); + let mut ops_history = VecDeque::with_capacity(24); + for _ in 0..24 { ops_history.push_back(0u64); } + Self { + engine, + focus: Focus::Input, + input: Input::default(), + log, + stats: None, + ops_count: 0, + ops_last_count: 0, + ops_last_sample: Instant::now(), + ops_per_sec: 0.0, + ops_history, + start: Instant::now(), + uptime: 0, + mouse_pos: (0, 0), + should_quit: false, + } + } + + fn tick(&mut self) { + self.uptime = self.start.elapsed().as_secs(); + self.stats = self.engine.stats_all().ok(); + + let elapsed = self.ops_last_sample.elapsed().as_secs_f64(); + if elapsed >= 0.25 { + let delta = self.ops_count.saturating_sub(self.ops_last_count); + self.ops_per_sec = delta as f64 / elapsed; + self.ops_last_count = self.ops_count; + self.ops_last_sample = Instant::now(); + if self.ops_history.len() >= 24 { self.ops_history.pop_front(); } + self.ops_history.push_back(self.ops_per_sec as u64); + } + } + + fn log_push(&mut self, msg: impl Into, color: Color) { + if self.log.len() >= 300 { self.log.pop_front(); } + self.log.push_back((msg.into(), color)); + } + + fn incr_ops(&mut self) { self.ops_count += 1; } + + // ── Command dispatcher ──────────────────────────────────────────────────── + fn execute(&mut self, raw: &str) { + let cmd = raw.trim(); + if cmd.is_empty() { return; } + self.log_push(format!("\u{203a} {}", cmd), C_TEXT); + + let parts: Vec<&str> = cmd.splitn(3, ' ').collect(); + match parts[0].to_uppercase().as_str() { + + // SET ────────────────────────────────────────────────────────────── + "SET" => { + if parts.len() < 3 { + self.log_push("\u{274c} Usage: SET ", C_ERR); + return; + } + let key = parts[1].to_string(); + let val = parts[2].as_bytes().to_vec(); + match self.engine.set(key.clone(), val) { + Ok(_) => { self.log_push(format!("\u{2713} SET '{}' OK", key), C_OK); self.incr_ops(); } + Err(e) => self.log_push(format!("\u{274c} {}", e), C_ERR), + } + } + + // GET ────────────────────────────────────────────────────────────── + "GET" => { + if parts.len() < 2 { + self.log_push("\u{274c} Usage: GET ", C_ERR); + return; + } + match self.engine.get(parts[1]) { + Ok(Some(v)) => { + self.log_push(format!("\u{2713} '{}' = '{}'", parts[1], String::from_utf8_lossy(&v)), C_OK); + self.incr_ops(); + } + Ok(None) => self.log_push(format!("\u{26a0} Key '{}' not found", parts[1]), C_WARN), + Err(e) => self.log_push(format!("\u{274c} {}", e), C_ERR), + } + } + + // DEL / DELETE ───────────────────────────────────────────────────── + "DEL" | "DELETE" => { + if parts.len() < 2 { + self.log_push("\u{274c} Usage: DEL ", C_ERR); + return; + } + let key = parts[1].to_string(); + match self.engine.delete(key.clone()) { + Ok(_) => { self.log_push(format!("\u{2713} DEL '{}' (tombstone written)", key), C_OK); self.incr_ops(); } + Err(e) => self.log_push(format!("\u{274c} {}", e), C_ERR), + } + } + + // SEARCH ─────────────────────────────────────────────────────────── + "SEARCH" => { + if parts.len() < 2 { + self.log_push("\u{274c} Usage: SEARCH [--prefix]", C_ERR); + return; + } + let query = parts[1]; + let prefix_mode = parts.len() > 2 && parts[2] == "--prefix"; + let result = if prefix_mode { self.engine.search_prefix(query) } + else { self.engine.search(query) }; + match result { + Ok(rows) if rows.is_empty() => self.log_push("\u{26a0} No records found", C_WARN), + Ok(rows) => { + self.log_push(format!("\u{2713} {} record(s) found:", rows.len()), C_OK); + for (k, v) in rows.iter().take(20) { + self.log_push(format!(" {} = {}", k, String::from_utf8_lossy(v)), C_TEXT); + } + if rows.len() > 20 { self.log_push(format!(" ... and {} more", rows.len()-20), C_DIM); } + self.incr_ops(); + } + Err(e) => self.log_push(format!("\u{274c} {}", e), C_ERR), + } + } + + // SCAN ───────────────────────────────────────────────────────────── + "SCAN" => { + if parts.len() < 2 { + self.log_push("\u{274c} Usage: SCAN ", C_ERR); + return; + } + match self.engine.search_prefix(parts[1]) { + Ok(rows) if rows.is_empty() => self.log_push(format!("\u{26a0} No records with prefix '{}'", parts[1]), C_WARN), + Ok(rows) => { + self.log_push(format!("\u{2713} {} record(s) [prefix='{}']:", rows.len(), parts[1]), C_OK); + for (k, v) in rows.iter().take(20) { + self.log_push(format!(" {} = {}", k, String::from_utf8_lossy(v)), C_TEXT); + } + if rows.len() > 20 { self.log_push(format!(" ... and {} more", rows.len()-20), C_DIM); } + self.incr_ops(); + } + Err(e) => self.log_push(format!("\u{274c} {}", e), C_ERR), + } + } + + // ALL ────────────────────────────────────────────────────────────── + "ALL" => { + match self.engine.scan() { + Ok(rows) if rows.is_empty() => self.log_push("\u{26a0} Database is empty", C_WARN), + Ok(rows) => { + self.log_push(format!("\u{2713} {} record(s):", rows.len()), C_OK); + for (k, v) in rows.iter().take(30) { + self.log_push(format!(" {} = {}", k, String::from_utf8_lossy(v)), C_TEXT); + } + if rows.len() > 30 { self.log_push(format!(" ... and {} more", rows.len()-30), C_DIM); } + self.incr_ops(); + } + Err(e) => self.log_push(format!("\u{274c} {}", e), C_ERR), + } + } + + // KEYS ───────────────────────────────────────────────────────────── + "KEYS" => { + match self.engine.keys() { + Ok(keys) if keys.is_empty() => self.log_push("\u{26a0} No keys found", C_WARN), + Ok(keys) => { + self.log_push(format!("\u{2713} {} key(s):", keys.len()), C_OK); + for (i, k) in keys.iter().enumerate().take(30) { + self.log_push(format!(" {}. {}", i+1, k), C_TEXT); + } + if keys.len() > 30 { self.log_push(format!(" ... and {} more", keys.len()-30), C_DIM); } + self.incr_ops(); + } + Err(e) => self.log_push(format!("\u{274c} {}", e), C_ERR), + } + } + + // COUNT ──────────────────────────────────────────────────────────── + "COUNT" => { + match self.engine.count() { + Ok(n) => { self.log_push(format!("\u{2713} Total active records: {}", n), C_OK); self.incr_ops(); } + Err(e) => self.log_push(format!("\u{274c} {}", e), C_ERR), + } + } + + // STATS ──────────────────────────────────────────────────────────── + "STATS" => { + let all_mode = parts.len() > 1 && parts[1].to_uppercase() == "ALL"; + if all_mode { + match self.engine.stats_all() { + Ok(s) => { + self.log_push("\u{2500}\u{2500}\u{2500} Detailed Statistics \u{2500}\u{2500}\u{2500}".to_string(), C_ORANGE); + self.log_push(format!(" MemTable records : {}", s.mem_records), C_TEXT); + self.log_push(format!(" MemTable size : {} KB / {} KB", s.mem_kb, s.memtable_max_size), C_TEXT); + self.log_push(format!(" SSTable files : {}", s.sst_files), C_TEXT); + self.log_push(format!(" SSTable records : {}", s.sst_records), C_TEXT); + self.log_push(format!(" SSTable size : {} KB", s.sst_kb), C_TEXT); + self.log_push(format!(" WAL size : {} KB", s.wal_kb), C_TEXT); + self.log_push(format!(" Total records : {}", s.total_records), C_TEXT); + } + Err(e) => self.log_push(format!("\u{274c} {}", e), C_ERR), + } + } else { + for line in self.engine.stats().lines() { + self.log_push(line.to_string(), C_TEXT); + } + } + } + + // BATCH ──────────────────────────────────────────────────────────── + "BATCH" => { + if parts.len() >= 3 && parts[1].to_uppercase() == "SET" { + let file_path = parts[2]; + match std::fs::read_to_string(file_path) { + Ok(content) => { + let (mut ok, mut err) = (0usize, 0usize); + let t = Instant::now(); + for (line_no, line) in content.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { continue; } + if let Some((k, v)) = line.split_once('=') { + match self.engine.set(k.trim().to_string(), v.trim().as_bytes().to_vec()) { + Ok(_) => { ok += 1; self.incr_ops(); } + Err(e) => { self.log_push(format!(" line {}: {}", line_no+1, e), C_ERR); err += 1; } + } + } else { + self.log_push(format!(" line {}: bad format (expected key=value)", line_no+1), C_WARN); + err += 1; + } + } + self.log_push(format!("\u{2713} {} imported, {} errors [{:.1?}]", ok, err, t.elapsed()), C_OK); + } + Err(e) => self.log_push(format!("\u{274c} Cannot read '{}': {}", file_path, e), C_ERR), + } + } else if parts.len() >= 2 { + match parts[1].parse::() { + Ok(n) => { + let t = Instant::now(); + self.log_push(format!("Inserting {} records...", n), C_DIM); + let mut errs = 0usize; + for i in 0..n { + match self.engine.set(format!("batch:{:06}", i), format!("value_{}", i).into_bytes()) { + Ok(_) => { self.incr_ops(); } + Err(_) => { errs += 1; } + } + } + let elapsed = t.elapsed(); + let rate = n as f64 / elapsed.as_secs_f64(); + self.log_push(format!("\u{2713} {} records in {:.2?} ({:.0} ops/s) errors={}", n, elapsed, rate, errs), C_OK); + } + Err(_) => self.log_push("\u{274c} BATCH: invalid count".to_string(), C_ERR), + } + } else { + self.log_push("\u{274c} Usage: BATCH | BATCH SET ", C_ERR); + } + } + + // DEMO ───────────────────────────────────────────────────────────── + "DEMO" => { + self.log_push("\u{2500}\u{2500}\u{2500} Running Demo \u{2500}\u{2500}\u{2500}".to_string(), C_ORANGE); + let t = Instant::now(); + for i in 0..100 { + let _ = self.engine.set(format!("demo:{:04}", i), format!("demo-value-{}", i).into_bytes()); + self.incr_ops(); + } + self.log_push(" 100 SET ops done".to_string(), C_TEXT); + for i in (0..100).step_by(10) { + let _ = self.engine.get(&format!("demo:{:04}", i)); + self.incr_ops(); + } + self.log_push(" 10 GET ops done".to_string(), C_TEXT); + for i in 0..10 { + let _ = self.engine.delete(format!("demo:{:04}", i)); + self.incr_ops(); + } + self.log_push(" 10 DEL ops done".to_string(), C_TEXT); + let count = self.engine.count().unwrap_or(0); + self.log_push(format!("\u{2713} Demo done in {:.2?} active keys={}", t.elapsed(), count), C_OK); + } + + // CLEAR ──────────────────────────────────────────────────────────── + "CLEAR" => { + self.log.clear(); + self.log_push("Log cleared.".to_string(), C_DIM); + } + + // HELP ───────────────────────────────────────────────────────────── + "HELP" | "?" => { + self.log_push("\u{2500} Available Commands \u{2500}".to_string(), C_ORANGE); + for line in [ + " SET insert/update", + " GET retrieve value", + " DEL delete (tombstone)", + " SEARCH [--prefix] search records", + " SCAN scan by prefix", + " ALL list all records", + " KEYS list all keys", + " COUNT count active records", + " STATS [ALL] engine statistics", + " BATCH insert N test records", + " BATCH SET import key=value file", + " DEMO run quick demo", + " CLEAR clear this log", + " HELP show this help", + " Q / QUIT / EXIT quit dashboard", + ] { self.log_push(line.to_string(), C_DIM); } + } + + // QUIT ───────────────────────────────────────────────────────────── + "Q" | "QUIT" | "EXIT" => { self.should_quit = true; } + + unknown => { + self.log_push(format!("\u{274c} Unknown command '{}'. Type HELP.", unknown), C_ERR); + } + } + } +} + +// ─── Terminal setup / restore ───────────────────────────────────────────────── + +fn setup() -> io::Result>> { + enable_raw_mode()?; + let mut out = io::stdout(); + execute!(out, EnterAlternateScreen, EnableMouseCapture)?; + Terminal::new(CrosstermBackend::new(out)) +} + +fn restore(t: &mut Terminal>) -> io::Result<()> { + disable_raw_mode()?; + execute!(t.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; + t.show_cursor() +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +fn main() -> io::Result<()> { + // Panic hook: always restore terminal before printing the panic + let original = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); + original(info); + })); + + let config = LsmConfig::builder() + .dir_path(PathBuf::from("./.lsm_data")) + .memtable_max_size(64 * 1024) // 64 KB + .build() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + + let engine = LsmEngine::new(config) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + + let mut terminal = setup()?; + let mut app = App::new(engine); + let tick = Duration::from_millis(250); + + loop { + terminal.draw(|f| ui(f, &mut app))?; + + if event::poll(tick)? { + match event::read()? { + Event::Key(k) => { + if matches!(k.code, KeyCode::Char('c')) && k.modifiers.contains(KeyModifiers::CONTROL) { + app.should_quit = true; + } else if matches!(k.code, KeyCode::Esc) { + app.should_quit = true; + } else if app.focus == Focus::Input { + match k.code { + KeyCode::Enter => { + let cmd = app.input.value().to_string(); + app.input.reset(); + app.execute(&cmd); + } + KeyCode::Tab => app.focus = Focus::Log, + _ => { app.input.handle_event(&Event::Key(k)); } + } + } else { + match k.code { + KeyCode::Tab | KeyCode::Enter => app.focus = Focus::Input, + KeyCode::Char('1') => app.focus = Focus::Stats, + KeyCode::Char('2') => app.focus = Focus::Log, + KeyCode::Char('3') => app.focus = Focus::Input, + _ => {} + } + } + } + Event::Mouse(m) => { + app.mouse_pos = (m.column, m.row); + if m.kind == MouseEventKind::Down(event::MouseButton::Left) { + app.focus = Focus::Input; + } + } + _ => {} + } + } else { + app.tick(); + } + + if app.should_quit { break; } + } + + restore(&mut terminal) +} + +// ─── UI root ────────────────────────────────────────────────────────────────── + +fn ui(f: &mut Frame, app: &mut App) { + let area = f.area(); + f.render_widget(Block::default().style(Style::default().bg(C_DEEP)), area); + + let rows = Layout::vertical([ + Constraint::Length(3), + Constraint::Min(10), + Constraint::Length(1), + ]).split(area); + + render_title(f, rows[0], app); + render_body(f, rows[1], app); + render_statusbar(f, rows[2], app); +} + +// ─── Title ──────────────────────────────────────────────────────────────────── + +fn render_title(f: &mut Frame, area: Rect, app: &App) { + let now_str = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let uptime_str = fmt_uptime(app.uptime); + let total = app.stats.as_ref().map(|s| s.total_records).unwrap_or(0); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(C_ORANGE)) + .style(Style::default().bg(C_PANEL)) + .title(Line::from(vec![ + Span::styled(" \u{26a1} ", Style::default().fg(C_ORANGE).add_modifier(Modifier::BOLD)), + Span::styled("APEXSTORE", Style::default().fg(C_ORANGE).add_modifier(Modifier::BOLD)), + Span::styled(" TUI DASHBOARD ", Style::default().fg(C_DIM)), + ])) + .title_bottom(Line::from(vec![ + Span::styled(" \u{1f552} ", Style::default()), + Span::styled(&now_str, Style::default().fg(C_CLOCK).add_modifier(Modifier::BOLD)), + Span::styled(" \u{23f1} ", Style::default().fg(C_DIM)), + Span::styled(uptime_str, Style::default().fg(C_AMBER)), + Span::styled(" records: ", Style::default().fg(C_DIM)), + Span::styled(format!("{}", total), Style::default().fg(C_OK).add_modifier(Modifier::BOLD)), + Span::styled(" ops/s: ", Style::default().fg(C_DIM)), + Span::styled(format!("{:.0}", app.ops_per_sec), Style::default().fg(C_ORANGE).add_modifier(Modifier::BOLD)), + Span::styled(" ", Style::default()), + ])); + + f.render_widget(block, area); +} + +// ─── Body ───────────────────────────────────────────────────────────────────── + +fn render_body(f: &mut Frame, area: Rect, app: &mut App) { + let cols = Layout::horizontal([ + Constraint::Percentage(42), + Constraint::Percentage(58), + ]).split(area); + render_left(f, cols[0], app); + render_right(f, cols[1], app); +} + +// ─── Left: Stats + Clock ────────────────────────────────────────────────────── + +fn render_left(f: &mut Frame, area: Rect, app: &App) { + let rows = Layout::vertical([ + Constraint::Percentage(65), + Constraint::Percentage(35), + ]).split(area); + render_stats(f, rows[0], app); + render_clock(f, rows[1], app); +} + +fn render_stats(f: &mut Frame, area: Rect, app: &App) { + let focused = app.focus == Focus::Stats; + let border_col = if focused { C_ACTIVE } else { C_BORDER }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_col)) + .style(Style::default().bg(C_PANEL)) + .padding(Padding::horizontal(1)) + .title(Line::from(vec![ + Span::styled(" \u{1f4ca} ", Style::default()), + Span::styled("LSM-Tree Statistics ", Style::default().fg(C_AMBER).add_modifier(Modifier::BOLD)), + ])); + + let inner = block.inner(area); + f.render_widget(block, area); + + let rows = Layout::vertical([ + Constraint::Length(5), // ops/s bar chart + Constraint::Length(1), // sst/wal text + Constraint::Length(3), // memtable gauge + Constraint::Length(3), // sstable gauge + Constraint::Min(3), // metrics text + ]).split(inner); + + // Ops/s history bar chart + let hist_data: Vec<(&str, u64)> = app + .ops_history.iter().enumerate() + .map(|(i, &v)| (HIST_LABELS[i % HIST_LABELS.len()], v)) + .collect(); + + f.render_widget( + BarChart::default() + .data(&hist_data) + .bar_width(2).bar_gap(0) + .bar_style(Style::default().fg(C_BAR)) + .value_style(Style::default().fg(C_DEEP).bg(C_BAR)) + .label_style(Style::default().fg(Color::Reset)) + .block(Block::default() + .title(Span::styled(" Ops/s (last 6s) ", + Style::default().fg(C_DIM).add_modifier(Modifier::ITALIC))) + .borders(Borders::NONE)), + rows[0], + ); + + // SST / WAL sizes + let st = app.stats.as_ref(); + let sst_kb = st.map(|s| s.sst_kb).unwrap_or(0); + let wal_kb = st.map(|s| s.wal_kb).unwrap_or(0); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" SST: ", Style::default().fg(C_DIM)), + Span::styled(format!("{} KB", sst_kb), Style::default().fg(C_BAR2)), + Span::styled(" WAL: ", Style::default().fg(C_DIM)), + Span::styled(format!("{} KB", wal_kb), Style::default().fg(C_BAR2)), + ])).style(Style::default().bg(C_PANEL)), + rows[1], + ); + + // MemTable gauge + let (mem_pct, mem_kb, mem_max, mem_recs) = st.map_or((0.0, 0, 0, 0), |s| { + let pct = if s.memtable_max_size > 0 { + (s.mem_kb as f64 / s.memtable_max_size as f64).min(1.0) + } else { 0.0 }; + (pct, s.mem_kb, s.memtable_max_size, s.mem_records) + }); + f.render_widget( + Gauge::default() + .block(Block::default() + .title(Span::styled(" MemTable ", Style::default().fg(C_TEXT).add_modifier(Modifier::BOLD))) + .borders(Borders::LEFT | Borders::RIGHT) + .border_style(Style::default().fg(C_BORDER))) + .gauge_style(Style::default().fg(pct_color((mem_pct*100.0) as u8)) + .bg(Color::Rgb(25, 30, 50)).add_modifier(Modifier::BOLD)) + .ratio(mem_pct) + .label(Span::styled( + format!(" {} KB / {} KB ({} records) ", mem_kb, mem_max, mem_recs), + Style::default().fg(C_TEXT))), + rows[2], + ); + + // SSTable gauge + let sst_files = st.map(|s| s.sst_files).unwrap_or(0); + let sst_records = st.map(|s| s.sst_records).unwrap_or(0); + let sst_ratio = (sst_files as f64 / 20.0_f64).min(1.0); + f.render_widget( + Gauge::default() + .block(Block::default() + .title(Span::styled(" SSTables ", Style::default().fg(C_TEXT).add_modifier(Modifier::BOLD))) + .borders(Borders::LEFT | Borders::RIGHT) + .border_style(Style::default().fg(C_BORDER))) + .gauge_style(Style::default().fg(pct_color((sst_ratio*100.0) as u8)) + .bg(Color::Rgb(25, 30, 50)).add_modifier(Modifier::BOLD)) + .ratio(sst_ratio) + .label(Span::styled( + format!(" {} files ({} records) ", sst_files, sst_records), + Style::default().fg(C_TEXT))), + rows[3], + ); + + // Key metrics + let total = st.map(|s| s.total_records).unwrap_or(0); + f.render_widget( + Paragraph::new(vec![ + Line::from(vec![ + Span::styled(" Total records : ", Style::default().fg(C_DIM)), + Span::styled(format!("{}", total), Style::default().fg(C_OK).add_modifier(Modifier::BOLD)), + ]), + Line::from(vec![ + Span::styled(" Live ops/s : ", Style::default().fg(C_DIM)), + Span::styled(format!("{:.1}", app.ops_per_sec), Style::default().fg(C_ORANGE).add_modifier(Modifier::BOLD)), + ]), + Line::from(vec![ + Span::styled(" Cumul. ops : ", Style::default().fg(C_DIM)), + Span::styled(format!("{}", app.ops_count), Style::default().fg(C_BAR2)), + ]), + ]).style(Style::default().bg(C_PANEL)), + rows[4], + ); +} + +fn render_clock(f: &mut Frame, area: Rect, _app: &App) { + let now = Local::now(); + let time_str = now.format("%H:%M:%S").to_string(); + let date_str = now.format("%A, %d %B %Y").to_string(); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(C_BORDER)) + .style(Style::default().bg(C_PANEL)) + .title(Line::from(vec![ + Span::styled(" \u{1f550} ", Style::default()), + Span::styled("System Clock ", Style::default().fg(C_CLOCK).add_modifier(Modifier::BOLD)), + ])); + + let inner = block.inner(area); + f.render_widget(block, area); + + let rows = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + ]).split(inner); + + f.render_widget( + Paragraph::new(time_str).alignment(Alignment::Center) + .style(Style::default().fg(C_CLOCK).add_modifier(Modifier::BOLD)), + rows[1], + ); + f.render_widget( + Paragraph::new(date_str).alignment(Alignment::Center) + .style(Style::default().fg(C_DIM)), + rows[2], + ); +} + +// ─── Right: Log + Input ─────────────────────────────────────────────────────── + +fn render_right(f: &mut Frame, area: Rect, app: &mut App) { + let rows = Layout::vertical([Constraint::Min(5), Constraint::Length(5)]).split(area); + render_log(f, rows[0], app); + render_input(f, rows[1], app); +} + +fn render_log(f: &mut Frame, area: Rect, app: &App) { + let focused = app.focus == Focus::Log; + let border_col = if focused { C_ACTIVE } else { C_BORDER }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_col)) + .style(Style::default().bg(C_PANEL)) + .padding(Padding::horizontal(1)) + .title(Line::from(vec![ + Span::styled(" \u{1f4cb} ", Style::default()), + Span::styled("Command Log ", Style::default().fg(C_TEXT).add_modifier(Modifier::BOLD)), + Span::styled(format!("({} lines) ", app.log.len()), Style::default().fg(C_DIM)), + ])); + + let inner_h = block.inner(area).height as usize; + let inner = block.inner(area); + f.render_widget(block, area); + + let items: Vec = app.log.iter() + .rev().take(inner_h).rev() + .map(|(msg, col)| ListItem::new(Line::from( + Span::styled(msg.as_str(), Style::default().fg(*col)) + ))) + .collect(); + + f.render_widget(List::new(items).style(Style::default().bg(C_PANEL)), inner); +} + +fn render_input(f: &mut Frame, area: Rect, app: &App) { + let focused = app.focus == Focus::Input; + let border_col = if focused { C_ORANGE } else { C_BORDER }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_col)) + .style(Style::default().bg(C_PANEL)) + .padding(Padding::horizontal(1)) + .title(Line::from(vec![ + Span::styled(" \u{2328} ", Style::default()), + Span::styled("Command Input ", + Style::default().fg(if focused { C_ORANGE } else { C_DIM }).add_modifier(Modifier::BOLD)), + Span::styled(if focused { "(active) " } else { "(Tab to focus) " }, + Style::default().fg(C_DIM).add_modifier(Modifier::ITALIC)), + ])) + .title_bottom(Line::from(Span::styled( + " Enter: run | Esc: quit | Tab: switch panel ", + Style::default().fg(C_DIM), + ))); + + let inner = block.inner(area); + f.render_widget(block, area); + + let input_rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + ]).split(inner); + + // Prompt line + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("apex", Style::default().fg(C_ORANGE).add_modifier(Modifier::BOLD)), + Span::styled("://db ", Style::default().fg(C_DIM)), + Span::styled("\u{2192}", Style::default().fg(C_ACTIVE)), + ])).style(Style::default().bg(C_PANEL)), + input_rows[0], + ); + + // Input text + cursor block + let value = app.input.value(); + let cursor = app.input.visual_cursor(); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(value, Style::default().fg(C_TEXT).bg(Color::Rgb(25, 30, 50))), + if focused { + Span::styled("\u{2588}", Style::default().fg(C_ORANGE)) + } else { + Span::raw("") + }, + ])) + .wrap(Wrap { trim: false }) + .style(Style::default().bg(Color::Rgb(25, 30, 50))), + input_rows[1], + ); + + if focused { + f.set_cursor_position((input_rows[1].x + 2 + cursor as u16, input_rows[1].y)); + } +} + +// ─── Status bar ─────────────────────────────────────────────────────────────── + +fn render_statusbar(f: &mut Frame, area: Rect, app: &App) { + let focus_str = match app.focus { + Focus::Stats => "[STATS]", + Focus::Log => " [LOG]", + Focus::Input => "[INPUT]", + }; + let bar = Paragraph::new(Line::from(vec![ + Span::styled(" ApexStore v2.1.0 ", Style::default().fg(C_ORANGE).add_modifier(Modifier::BOLD)), + Span::styled("| ", Style::default().fg(C_BORDER)), + Span::styled(format!("{} ", focus_str), Style::default().fg(C_ACTIVE)), + Span::styled("| ", Style::default().fg(C_BORDER)), + Span::styled(format!(" {:.0} ops/s ", app.ops_per_sec), Style::default().fg(C_OK)), + Span::styled("| ", Style::default().fg(C_BORDER)), + Span::styled(" data: .lsm_data ", Style::default().fg(C_DIM)), + Span::styled("| ", Style::default().fg(C_BORDER)), + Span::styled(format!(" mouse ({},{}) ", app.mouse_pos.0, app.mouse_pos.1), Style::default().fg(C_DIM)), + ])).style(Style::default().bg(Color::Rgb(12, 16, 30))); + + f.render_widget(bar, area); + f.render_widget(Clear, Rect::new(area.right().saturating_sub(1), area.y, 1, 1)); +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const HIST_LABELS: &[&str] = &[ + "", "", "", "", "", "", "", "", + "", "", "", "", "", "", "", "", + "", "", "", "", "", "", "", "", +]; + +fn pct_color(pct: u8) -> Color { + if pct < 60 { Color::Rgb( 80, 220, 130) } + else if pct < 80 { Color::Rgb(255, 200, 50) } + else { Color::Rgb(255, 80, 80) } +} + +fn fmt_uptime(secs: u64) -> String { + let h = secs / 3600; + let m = (secs % 3600) / 60; + let s = secs % 60; + if h > 0 { format!("{}h {:02}m {:02}s", h, m, s) } + else if m > 0 { format!("{}m {:02}s", m, s) } + else { format!("{}s", s) } +}