diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2dcf67..c73084a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,17 +2,32 @@ name: Build & Release on: push: - tags: - - "v*" + branches: + - main + paths: + - "src/**" + - "Cargo.toml" + - "Cargo.lock" + - "Dockerfile" + - "Dockerfile.*" workflow_dispatch: inputs: - tag: - description: 'Version tag (e.g. v0.7.0-beta.1 or v0.7.0)' + chart_bump: + description: 'Chart version bump type' required: true - type: string - default: 'v' + type: choice + options: + - patch + - minor + - major + default: patch + release: + description: 'Stable release (no beta suffix)' + required: false + type: boolean + default: false dry_run: - description: 'Dry run (build only, no push)' + description: 'Dry run (show changes without committing)' required: false type: boolean default: false @@ -22,46 +37,7 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: - resolve-tag: - runs-on: ubuntu-latest - outputs: - tag: ${{ steps.resolve.outputs.tag }} - chart_version: ${{ steps.resolve.outputs.chart_version }} - is_prerelease: ${{ steps.resolve.outputs.is_prerelease }} - steps: - - name: Resolve and validate tag - id: resolve - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - TAG="${{ inputs.tag }}" - else - TAG="${GITHUB_REF_NAME}" - fi - - # Validate tag format - if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then - echo "::error::Invalid tag format '${TAG}'. Expected v{major}.{minor}.{patch}[-prerelease]" - exit 1 - fi - - CHART_VERSION="${TAG#v}" - - # Pre-release if version contains '-' (e.g. 0.7.0-beta.1) - if [[ "$CHART_VERSION" == *-* ]]; then - IS_PRERELEASE="true" - else - IS_PRERELEASE="false" - fi - - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "chart_version=${CHART_VERSION}" >> "$GITHUB_OUTPUT" - echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT" - - # ── Pre-release path: full build ────────────────────────────── - build-image: - needs: resolve-tag - if: ${{ needs.resolve-tag.outputs.is_prerelease == 'true' }} strategy: matrix: variant: @@ -69,7 +45,6 @@ jobs: - { suffix: "-codex", dockerfile: "Dockerfile.codex", artifact: "codex" } - { suffix: "-claude", dockerfile: "Dockerfile.claude", artifact: "claude" } - { suffix: "-gemini", dockerfile: "Dockerfile.gemini", artifact: "gemini" } - - { suffix: "-copilot", dockerfile: "Dockerfile.copilot", artifact: "copilot" } platform: - { os: linux/amd64, runner: ubuntu-latest } - { os: linux/arm64, runner: ubuntu-24.04-arm } @@ -78,11 +53,11 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v4 + - uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} @@ -90,7 +65,7 @@ jobs: - name: Docker metadata id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }} @@ -121,8 +96,8 @@ jobs: retention-days: 1 merge-manifests: - needs: [resolve-tag, build-image] - if: ${{ inputs.dry_run != true && needs.resolve-tag.outputs.is_prerelease == 'true' }} + needs: build-image + if: inputs.dry_run != true strategy: matrix: variant: @@ -130,11 +105,12 @@ jobs: - { suffix: "-codex", artifact: "codex" } - { suffix: "-claude", artifact: "claude" } - { suffix: "-gemini", artifact: "gemini" } - - { suffix: "-copilot", artifact: "copilot" } runs-on: ubuntu-latest permissions: contents: read packages: write + outputs: + version: ${{ steps.meta.outputs.version }} steps: - name: Download digests uses: actions/download-artifact@v4 @@ -145,7 +121,7 @@ jobs: - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v4 + - uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} @@ -153,12 +129,12 @@ jobs: - name: Docker metadata id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }} tags: | type=sha,prefix= - type=semver,pattern={{version}},value=${{ needs.resolve-tag.outputs.tag }} + type=raw,value=latest - name: Create manifest list working-directory: /tmp/digests @@ -166,98 +142,88 @@ jobs: docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }}@sha256:%s ' *) - # ── Stable path: promote pre-release image (no rebuild) ────── - - promote-stable: - needs: resolve-tag - if: ${{ inputs.dry_run != true && needs.resolve-tag.outputs.is_prerelease == 'false' }} - strategy: - matrix: - variant: - - { suffix: "" } - - { suffix: "-codex" } - - { suffix: "-claude" } - - { suffix: "-gemini" } - - { suffix: "-copilot" } + bump-chart: + needs: merge-manifests + if: inputs.dry_run != true runs-on: ubuntu-latest permissions: - contents: read - packages: write + contents: write + pull-requests: write steps: - - uses: actions/checkout@v6 + - name: Generate App token + id: app-token + uses: actions/create-github-app-token@v1 with: - fetch-depth: 0 + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - - uses: docker/setup-buildx-action@v3 - - - uses: docker/login-action@v4 + - uses: actions/checkout@v4 with: - registry: ${{ env.REGISTRY }} - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 - - name: Find pre-release image - id: find-prerelease + - name: Get current chart version + id: current run: | - CHART_VERSION="${{ needs.resolve-tag.outputs.chart_version }}" - # Find latest pre-release tag matching this version (e.g. v0.7.0-beta.1) - PRERELEASE_TAG=$(git tag -l "v${CHART_VERSION}-*" --sort=-v:refname | head -1) - if [ -z "$PRERELEASE_TAG" ]; then - echo "::error::No pre-release tag found for v${CHART_VERSION}-*. Run a pre-release build first." - exit 1 - fi - PRERELEASE_VERSION="${PRERELEASE_TAG#v}" - echo "Found pre-release: ${PRERELEASE_TAG} (${PRERELEASE_VERSION})" - echo "prerelease_version=${PRERELEASE_VERSION}" >> "$GITHUB_OUTPUT" + chart_version=$(grep '^version:' charts/openab/Chart.yaml | awk '{print $2}') + echo "chart_version=$chart_version" >> "$GITHUB_OUTPUT" - - name: Verify pre-release image exists + - name: Bump chart version + id: bump run: | - IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }}" - PRERELEASE_VERSION="${{ steps.find-prerelease.outputs.prerelease_version }}" - echo "Checking ${IMAGE}:${PRERELEASE_VERSION} ..." - docker buildx imagetools inspect "${IMAGE}:${PRERELEASE_VERSION}" || \ - { echo "::error::Image ${IMAGE}:${PRERELEASE_VERSION} not found — build the pre-release first"; exit 1; } + current="${{ steps.current.outputs.chart_version }}" + # Strip any existing pre-release suffix for base version + base="${current%%-*}" + IFS='.' read -r major minor patch <<< "$base" + bump_type="${{ inputs.chart_bump }}" + bump_type="${bump_type:-patch}" + case "$bump_type" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + esac + # Stable release: clean version. Otherwise: beta with run number. + if [ "${{ inputs.release }}" = "true" ]; then + new_version="${major}.${minor}.${patch}" + else + new_version="${major}.${minor}.${patch}-beta.${GITHUB_RUN_NUMBER}" + fi + echo "new_version=$new_version" >> "$GITHUB_OUTPUT" - - name: Promote to stable tags + - name: Resolve image SHA + id: image-sha run: | - IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }}" - PRERELEASE_VERSION="${{ steps.find-prerelease.outputs.prerelease_version }}" - CHART_VERSION="${{ needs.resolve-tag.outputs.chart_version }}" - MAJOR_MINOR="${CHART_VERSION%.*}" - - echo "Promoting ${IMAGE}:${PRERELEASE_VERSION} → ${CHART_VERSION}, ${MAJOR_MINOR}, latest" - docker buildx imagetools create \ - -t "${IMAGE}:${CHART_VERSION}" \ - -t "${IMAGE}:${MAJOR_MINOR}" \ - -t "${IMAGE}:latest" \ - "${IMAGE}:${PRERELEASE_VERSION}" - - # ── Chart release (runs after either path) ─────────────────── - - release-chart: - needs: [resolve-tag, merge-manifests, promote-stable] - if: >- - ${{ always() && inputs.dry_run != true && - needs.resolve-tag.result == 'success' && - (needs.merge-manifests.result == 'success' || needs.promote-stable.result == 'success') }} - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v6 - - - name: Install Helm - uses: azure/setup-helm@v4 - - - uses: docker/login-action@v4 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push chart to OCI + # Use the commit SHA that triggered this build — this is the SHA + # that merge-manifests tagged the Docker image with (type=sha,prefix=). + # We capture it here explicitly so it survives the bump commit. + IMAGE_SHA="${{ github.sha }}" + IMAGE_SHA="${IMAGE_SHA:0:7}" + echo "sha=${IMAGE_SHA}" >> "$GITHUB_OUTPUT" + + - name: Update Chart.yaml and values.yaml + run: | + IMAGE_SHA="${{ steps.image-sha.outputs.sha }}" + sed -i "s/^version: .*/version: ${{ steps.bump.outputs.new_version }}/" charts/openab/Chart.yaml + sed -i "s/^appVersion: .*/appVersion: \"${IMAGE_SHA}\"/" charts/openab/Chart.yaml + sed -i "s|repository: .*|repository: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}|" charts/openab/values.yaml + sed -i "s/tag: .*/tag: \"${IMAGE_SHA}\"/" charts/openab/values.yaml + + - name: Create and auto-merge bump PR + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - CHART_VERSION="${{ needs.resolve-tag.outputs.chart_version }}" - helm package charts/openab - helm push openab-${CHART_VERSION}.tgz oci://ghcr.io/${{ github.repository_owner }}/charts + VERSION="${{ steps.bump.outputs.new_version }}" + IMAGE_SHA="${{ steps.image-sha.outputs.sha }}" + BRANCH="chore/chart-${VERSION}" + git config user.name "openab-app[bot]" + git config user.email "274185012+openab-app[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add charts/openab/Chart.yaml charts/openab/values.yaml + git commit -m "chore: bump chart to ${VERSION} + + image: ${IMAGE_SHA}" + git push origin "$BRANCH" + PR_URL=$(gh pr create \ + --title "chore: bump chart to ${VERSION}" \ + --body "Auto-generated chart version bump for image \`${IMAGE_SHA}\`." \ + --base main --head "$BRANCH") + gh pr merge "$PR_URL" --squash --delete-branch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 4239edd..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: CI - -on: - pull_request: - paths: - - "src/**" - - "Cargo.toml" - - "Cargo.lock" - - "Dockerfile*" - -env: - CARGO_TERM_COLOR: always - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - uses: Swatinem/rust-cache@v2 - - - name: cargo check - run: cargo check - - - name: cargo clippy - run: cargo clippy -- -D warnings - - - name: cargo test - run: cargo test diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml deleted file mode 100644 index ee8ede3..0000000 --- a/.github/workflows/release-pr.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Release PR - -on: - workflow_dispatch: - inputs: - version: - description: "Version (leave empty for auto bump, or specify e.g. 0.8.0-beta.1)" - required: false - type: string - bump: - description: "Auto bump type (ignored when version is specified)" - required: false - type: choice - options: - - patch - - minor - - major - default: patch - -jobs: - create-release-pr: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - name: Generate App token - id: app-token - uses: actions/create-github-app-token@v3 - with: - client-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - - - uses: actions/checkout@v6 - with: - token: ${{ steps.app-token.outputs.token }} - fetch-depth: 0 - - - name: Resolve version - id: version - run: | - if [ -n "${{ inputs.version }}" ]; then - VERSION="${{ inputs.version }}" - else - CURRENT=$(grep '^version:' charts/openab/Chart.yaml | awk '{print $2}') - BASE="${CURRENT%%-*}" - if [[ "$CURRENT" == *-beta.* ]]; then - BETA_NUM="${CURRENT##*-beta.}" - VERSION="${BASE}-beta.$((BETA_NUM + 1))" - else - IFS='.' read -r major minor patch <<< "$BASE" - case "${{ inputs.bump }}" in - major) major=$((major + 1)); minor=0; patch=0 ;; - minor) minor=$((minor + 1)); patch=0 ;; - patch) patch=$((patch + 1)) ;; - esac - VERSION="${major}.${minor}.${patch}-beta.1" - fi - fi - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "::notice::Release version: ${VERSION}" - - # Determine stable version (strip pre-release suffix) - STABLE="${VERSION%%-*}" - echo "stable=${STABLE}" >> "$GITHUB_OUTPUT" - - - name: Update version files - run: | - VERSION="${{ steps.version.outputs.version }}" - STABLE="${{ steps.version.outputs.stable }}" - # Chart.yaml always gets the full version (beta or stable) - sed -i "s/^version: .*/version: ${VERSION}/" charts/openab/Chart.yaml - sed -i "s/^appVersion: .*/appVersion: \"${VERSION}\"/" charts/openab/Chart.yaml - # Cargo.toml only gets stable version (main stays clean) - sed -i "s/^version = .*/version = \"${STABLE}\"/" Cargo.toml - - - name: Create release PR - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - VERSION="${{ steps.version.outputs.version }}" - BRANCH="release/v${VERSION}" - git config user.name "openab-app[bot]" - git config user.email "274185012+openab-app[bot]@users.noreply.github.com" - git checkout -b "$BRANCH" - git add -A - git commit -m "release: v${VERSION}" - git push origin "$BRANCH" - gh pr create \ - --title "release: v${VERSION}" \ - --body "Merge this PR to tag \`v${VERSION}\` and trigger the build pipeline." \ - --base main --head "$BRANCH" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2be4c1..534bb7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,9 +14,10 @@ jobs: permissions: contents: write pages: write + packages: write steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -28,6 +29,13 @@ jobs: - name: Install Helm uses: azure/setup-helm@v4 + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Run chart-releaser uses: helm/chart-releaser-action@v1.6.0 with: @@ -35,7 +43,15 @@ jobs: env: CR_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Append install instructions to release notes + - name: Push chart to OCI registry + run: | + CHART=charts/openab + NAME=$(grep '^name:' ${CHART}/Chart.yaml | awk '{print $2}') + VERSION=$(grep '^version:' ${CHART}/Chart.yaml | awk '{print $2}') + helm package ${CHART} + helm push ${NAME}-${VERSION}.tgz oci://ghcr.io/${{ github.repository_owner }}/charts + + - name: Append OCI install instructions to release notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/.github/workflows/tag-on-merge.yml b/.github/workflows/tag-on-merge.yml deleted file mode 100644 index e414d93..0000000 --- a/.github/workflows/tag-on-merge.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Tag on Release PR merge - -on: - pull_request: - types: [closed] - branches: [main] - -jobs: - tag: - if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/') - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Generate App token - id: app-token - uses: actions/create-github-app-token@v3 - with: - client-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - - - uses: actions/checkout@v6 - with: - token: ${{ steps.app-token.outputs.token }} - - - name: Create and push tag - run: | - # release/v0.8.0-beta.1 → v0.8.0-beta.1 - VERSION="${GITHUB_HEAD_REF#release/}" - if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then - echo "::error::Invalid version format '${VERSION}'. Expected v{major}.{minor}.{patch}[-prerelease]" - exit 1 - fi - git config user.name "openab-app[bot]" - git config user.email "274185012+openab-app[bot]@users.noreply.github.com" - git tag "$VERSION" - git push origin "$VERSION" - echo "::notice::Tagged ${VERSION}" diff --git a/Cargo.toml b/Cargo.toml index 3b3b151..c4faf36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,5 @@ rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json"] } base64 = "0.22" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +pulldown-cmark = { version = "0.13", default-features = false } +unicode-width = "0.2" diff --git a/config.toml.example b/config.toml.example index 6b377e5..e1c2041 100644 --- a/config.toml.example +++ b/config.toml.example @@ -55,3 +55,7 @@ stall_soft_ms = 10000 stall_hard_ms = 30000 done_hold_ms = 1500 error_hold_ms = 2500 + +[markdown] +# How to render markdown tables: "code" (fenced code block), "bullets", or "off" +tables = "code" diff --git a/src/config.rs b/src/config.rs index c4ed3d3..7813e79 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use crate::markdown::TableMode; use regex::Regex; use serde::Deserialize; use std::collections::HashMap; @@ -13,6 +14,8 @@ pub struct Config { pub reactions: ReactionsConfig, #[serde(default)] pub stt: SttConfig, + #[serde(default)] + pub markdown: MarkdownConfig, } #[derive(Debug, Clone, Deserialize)] @@ -50,6 +53,18 @@ pub struct DiscordConfig { pub allowed_users: Vec, } +#[derive(Debug, Clone, Deserialize)] +pub struct MarkdownConfig { + #[serde(default)] + pub tables: TableMode, +} + +impl Default for MarkdownConfig { + fn default() -> Self { + Self { tables: TableMode::default() } + } +} + #[derive(Debug, Deserialize)] pub struct AgentConfig { pub command: String, diff --git a/src/discord.rs b/src/discord.rs index e267064..d709da8 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,7 +1,8 @@ use crate::acp::{classify_notification, AcpEvent, ContentBlock, SessionPool}; -use crate::config::{ReactionsConfig, SttConfig}; +use crate::config::{MarkdownConfig, ReactionsConfig, SttConfig}; use crate::error_display::{format_coded_error, format_user_error}; use crate::format; +use crate::markdown; use crate::reactions::StatusReactionController; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; @@ -33,6 +34,7 @@ pub struct Handler { pub allowed_users: HashSet, pub reactions_config: ReactionsConfig, pub stt_config: SttConfig, + pub markdown_config: MarkdownConfig, } #[async_trait] @@ -209,6 +211,7 @@ impl EventHandler for Handler { thread_channel, thinking_msg.id, reactions.clone(), + self.markdown_config.tables, ) .await; @@ -422,6 +425,7 @@ async fn stream_prompt( channel: ChannelId, msg_id: MessageId, reactions: Arc, + table_mode: markdown::TableMode, ) -> anyhow::Result<()> { let reactions = reactions.clone(); @@ -573,6 +577,7 @@ async fn stream_prompt( // Final edit let final_content = compose_display(&tool_lines, &text_buf); + let final_content = markdown::convert_tables(&final_content, table_mode); // If ACP returned both an error and partial text, show both. // This can happen when the agent started producing content before hitting an error // (e.g. context length limit, rate limit mid-stream). Showing both gives users diff --git a/src/format.rs b/src/format.rs index 841cf55..77efe4d 100644 --- a/src/format.rs +++ b/src/format.rs @@ -1,5 +1,9 @@ /// Split text into chunks at line boundaries, each <= limit Unicode characters (UTF-8 safe). /// Discord's message limit counts Unicode characters, not bytes. +/// +/// Fenced code blocks (``` ... ```) are handled specially: if a split falls inside a +/// code block, the current chunk is closed with ``` and the next chunk is reopened with +/// ```, so each chunk renders correctly in Discord. pub fn split_message(text: &str, limit: usize) -> Vec { if text.chars().count() <= limit { return vec![text.to_string()]; @@ -8,19 +12,38 @@ pub fn split_message(text: &str, limit: usize) -> Vec { let mut chunks = Vec::new(); let mut current = String::new(); let mut current_len: usize = 0; + let mut in_code_fence = false; for line in text.split('\n') { let line_chars = line.chars().count(); + let is_fence_marker = line.starts_with("```"); + // +1 for the newline if !current.is_empty() && current_len + line_chars + 1 > limit { + if in_code_fence && !is_fence_marker { + // Close the open code fence so this chunk renders correctly. + current.push_str("\n```"); + } chunks.push(current); current = String::new(); current_len = 0; + if in_code_fence && !is_fence_marker { + // Reopen the code fence in the new chunk. + // The newline separator below will join it to the first content line. + current.push_str("```"); + current_len = 3; + } } + if !current.is_empty() { current.push('\n'); current_len += 1; } + + if is_fence_marker { + in_code_fence = !in_code_fence; + } + // If a single line exceeds limit, hard-split on char boundaries if line_chars > limit { for ch in line.chars() { @@ -43,6 +66,53 @@ pub fn split_message(text: &str, limit: usize) -> Vec { chunks } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_under_limit_returns_single_chunk() { + let text = "hello world"; + assert_eq!(split_message(text, 2000), vec![text.to_string()]); + } + + #[test] + fn split_code_fence_closed_and_reopened_across_chunks() { + // Build a fenced code block whose lines exceed the limit when combined. + // Each data line is 100 chars; 21 lines = 2121 chars inside the fence, + // forcing a split mid-block. + let row = format!("| {} |\n", "x".repeat(95)); // 100 chars per row + let mut text = String::from("```\n"); + for _ in 0..21 { + text.push_str(&row); + } + text.push_str("```\n"); + + let chunks = split_message(&text, 2000); + assert!(chunks.len() >= 2, "expected multiple chunks"); + for (i, chunk) in chunks.iter().enumerate() { + let fence_count = chunk.lines().filter(|l| l.starts_with("```")).count(); + assert_eq!( + fence_count % 2, + 0, + "chunk {i} has unmatched code fences:\n{chunk}" + ); + } + } + + #[test] + fn split_does_not_corrupt_content_outside_fence() { + let mut text = String::new(); + for i in 0..30 { + text.push_str(&format!("Line number {i} with some padding text here.\n")); + } + let original_lines: Vec<&str> = text.lines().collect(); + let chunks = split_message(&text, 200); + let rejoined: Vec<&str> = chunks.iter().flat_map(|c| c.lines()).collect(); + assert_eq!(original_lines, rejoined); + } +} + /// Truncate a string to at most `limit` Unicode characters. /// Discord's message limit counts Unicode characters, not bytes. pub fn truncate_chars(s: &str, limit: usize) -> &str { diff --git a/src/main.rs b/src/main.rs index 225bf23..7ce135e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod config; mod discord; mod error_display; mod format; +mod markdown; mod reactions; mod stt; @@ -65,6 +66,7 @@ async fn main() -> anyhow::Result<()> { allowed_users, reactions_config: cfg.reactions, stt_config: cfg.stt.clone(), + markdown_config: cfg.markdown, }; let intents = GatewayIntents::GUILD_MESSAGES diff --git a/src/markdown.rs b/src/markdown.rs new file mode 100644 index 0000000..29d9713 --- /dev/null +++ b/src/markdown.rs @@ -0,0 +1,352 @@ +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; +use serde::Deserialize; +use std::fmt; +use unicode_width::UnicodeWidthStr; + +/// How to render markdown tables for a given channel. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TableMode { + /// Wrap the table in a fenced code block (default). + Code, + /// Convert each row into bullet points. + Bullets, + /// Pass through unchanged. + Off, +} + +impl Default for TableMode { + fn default() -> Self { + Self::Code + } +} + +impl fmt::Display for TableMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Code => write!(f, "code"), + Self::Bullets => write!(f, "bullets"), + Self::Off => write!(f, "off"), + } + } +} + +// ── IR types ──────────────────────────────────────────────────────── + +/// A parsed table: header row + data rows, each cell is plain text. +struct Table { + headers: Vec, + rows: Vec>, +} + +/// Segment of the document — either verbatim text or a parsed table. +enum Segment { + Text(String), + Table(Table), +} + +// ── Public API ────────────────────────────────────────────────────── + +/// Parse markdown, detect tables via pulldown-cmark, and render them +/// according to `mode`. Non-table content passes through unchanged. +pub fn convert_tables(markdown: &str, mode: TableMode) -> String { + if mode == TableMode::Off || markdown.is_empty() { + return markdown.to_string(); + } + + let segments = parse_segments(markdown, mode); + + let mut out = String::with_capacity(markdown.len()); + for seg in segments { + match seg { + Segment::Text(t) => out.push_str(&t), + Segment::Table(table) => match mode { + TableMode::Code => render_table_code(&table, &mut out), + TableMode::Bullets => render_table_bullets(&table, &mut out), + TableMode::Off => unreachable!(), + }, + } + } + out +} + +// ── Parser ────────────────────────────────────────────────────────── + +/// Walk the markdown source with pulldown-cmark and split it into +/// text segments and parsed Table segments. +fn parse_segments(markdown: &str, mode: TableMode) -> Vec { + let mut opts = Options::empty(); + opts.insert(Options::ENABLE_TABLES); + + let mut segments: Vec = Vec::new(); + let mut in_table = false; + let mut in_head = false; + let mut headers: Vec = Vec::new(); + let mut rows: Vec> = Vec::new(); + let mut current_row: Vec = Vec::new(); + let mut cell_buf = String::new(); + let mut last_table_end: usize = 0; + + // We need byte offsets to grab non-table text verbatim. + let parser_with_offsets = Parser::new_ext(markdown, opts).into_offset_iter(); + + for (event, range) in parser_with_offsets { + match event { + Event::Start(Tag::Table(_)) => { + // Flush text before this table + let before = &markdown[last_table_end..range.start]; + if !before.is_empty() { + push_text(&mut segments, before); + } + in_table = true; + headers.clear(); + rows.clear(); + } + Event::End(TagEnd::Table) => { + let table = Table { + headers: std::mem::take(&mut headers), + rows: std::mem::take(&mut rows), + }; + segments.push(Segment::Table(table)); + in_table = false; + last_table_end = range.end; + } + Event::Start(Tag::TableHead) => { + in_head = true; + current_row.clear(); + } + Event::End(TagEnd::TableHead) => { + headers = std::mem::take(&mut current_row); + in_head = false; + } + Event::Start(Tag::TableRow) => { + current_row.clear(); + } + Event::End(TagEnd::TableRow) => { + if !in_head { + rows.push(std::mem::take(&mut current_row)); + } + } + Event::Start(Tag::TableCell) => { + cell_buf.clear(); + } + Event::End(TagEnd::TableCell) => { + current_row.push(cell_buf.trim().to_string()); + cell_buf.clear(); + } + Event::Text(t) if in_table => { + cell_buf.push_str(&t); + } + Event::Code(t) if in_table => { + // In Code mode the table is already inside a fenced code block, + // so backticks would render as literal characters. Strip them. + if mode != TableMode::Code { + cell_buf.push('`'); + } + cell_buf.push_str(&t); + if mode != TableMode::Code { + cell_buf.push('`'); + } + } + // Inline markup inside cells: collect text, ignore tags + Event::SoftBreak if in_table => { + cell_buf.push(' '); + } + Event::HardBreak if in_table => { + cell_buf.push(' '); + } + // Start/End of inline tags (bold, italic, link, etc.) — skip the + // tag markers but keep processing their child text events above. + Event::Start(Tag::Emphasis) + | Event::Start(Tag::Strong) + | Event::Start(Tag::Strikethrough) + | Event::Start(Tag::Link { .. }) + | Event::End(TagEnd::Emphasis) + | Event::End(TagEnd::Strong) + | Event::End(TagEnd::Strikethrough) + | Event::End(TagEnd::Link) + if in_table => {} + _ => {} + } + } + + // Remaining text after last table + if last_table_end < markdown.len() { + let tail = &markdown[last_table_end..]; + if !tail.is_empty() { + push_text(&mut segments, tail); + } + } + + segments +} + +fn push_text(segments: &mut Vec, text: &str) { + if let Some(Segment::Text(ref mut prev)) = segments.last_mut() { + prev.push_str(text); + } else { + segments.push(Segment::Text(text.to_string())); + } +} + +// ── Renderers ─────────────────────────────────────────────────────── + +/// Render table as a fenced code block with aligned columns. +fn render_table_code(table: &Table, out: &mut String) { + let col_count = table + .headers + .len() + .max(table.rows.iter().map(|r| r.len()).max().unwrap_or(0)); + if col_count == 0 { + return; + } + + // Compute column widths (using display width for CJK/emoji) + let mut widths = vec![0usize; col_count]; + for (i, h) in table.headers.iter().enumerate() { + widths[i] = widths[i].max(UnicodeWidthStr::width(h.as_str())); + } + for row in &table.rows { + for (i, cell) in row.iter().enumerate() { + if i < col_count { + widths[i] = widths[i].max(UnicodeWidthStr::width(cell.as_str())); + } + } + } + // Minimum width 3 for the divider + for w in &mut widths { + *w = (*w).max(3); + } + + out.push_str("```\n"); + + // Header row + write_row(out, &table.headers, &widths, col_count); + // Divider + out.push('|'); + for w in &widths { + out.push(' '); + for _ in 0..*w { + out.push('-'); + } + out.push_str(" |"); + } + out.push('\n'); + // Data rows + for row in &table.rows { + write_row(out, row, &widths, col_count); + } + + out.push_str("```\n"); +} + +fn write_row(out: &mut String, cells: &[String], widths: &[usize], col_count: usize) { + out.push('|'); + for i in 0..col_count { + out.push(' '); + let cell = cells.get(i).map(|s| s.as_str()).unwrap_or(""); + out.push_str(cell); + let display_width = UnicodeWidthStr::width(cell); + let pad = widths[i].saturating_sub(display_width); + for _ in 0..pad { + out.push(' '); + } + out.push_str(" |"); + } + out.push('\n'); +} + +/// Render table as bullet points: `• header: value` per cell. +fn render_table_bullets(table: &Table, out: &mut String) { + for (row_idx, row) in table.rows.iter().enumerate() { + for (i, cell) in row.iter().enumerate() { + if cell.is_empty() { + continue; + } + out.push_str("• "); + if let Some(h) = table.headers.get(i) { + if !h.is_empty() { + out.push_str(h); + out.push_str(": "); + } + } + out.push_str(cell); + out.push('\n'); + } + // Blank line between rows, but not after the last one + if row_idx + 1 < table.rows.len() { + out.push('\n'); + } + } +} + +// ── Tests ─────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const TABLE_MD: &str = "\ +Some text before. + +| Name | Age | +|-------|-----| +| Alice | 30 | +| Bob | 25 | + +Some text after. +"; + + #[test] + fn off_mode_passes_through() { + let result = convert_tables(TABLE_MD, TableMode::Off); + assert_eq!(result, TABLE_MD); + } + + #[test] + fn code_mode_wraps_in_codeblock() { + let result = convert_tables(TABLE_MD, TableMode::Code); + assert!(result.contains("```\n")); + assert!(result.contains("| Alice")); + assert!(result.contains("Some text before.")); + assert!(result.contains("Some text after.")); + } + + #[test] + fn bullets_mode_converts_to_bullets() { + let result = convert_tables(TABLE_MD, TableMode::Bullets); + assert!(result.contains("• Name: Alice")); + assert!(result.contains("• Age: 30")); + assert!(!result.contains("```")); + } + + #[test] + fn no_table_passes_through() { + let plain = "Hello world\nNo tables here."; + let result = convert_tables(plain, TableMode::Code); + assert_eq!(result, plain); + } + + #[test] + fn code_mode_strips_backticks_from_code_cells() { + let md = "| col |\n|-----|\n| `value` |\n"; + let result = convert_tables(md, TableMode::Code); + // The table is inside a ``` block — backtick wrapping must be stripped. + assert!(result.contains("value"), "cell content should be present"); + // Only the fence markers themselves should contain backticks. + let inner = result + .trim_start_matches("```\n") + .trim_end_matches("```\n"); + assert!( + !inner.contains('`'), + "no backticks should appear inside the code fence: {result:?}" + ); + } + + #[test] + fn bullets_mode_keeps_backticks_in_code_cells() { + let md = "| col |\n|-----|\n| `value` |\n"; + let result = convert_tables(md, TableMode::Bullets); + assert!(result.contains("`value`"), "backticks should be kept in bullets mode"); + } +}