diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..144db46 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,28 @@ + + +### Rust Version + +* Use the output of `rustc -V` + +### Affected Version of clap + +* Can be found in Cargo.lock of your project (i.e. `grep clap Cargo.lock`) + +### Bug or Feature Request Summary + + +### Expected Behavior Summary + + +### Actual Behavior Summary + + +### Steps to Reproduce the issue + + +### Sample Code or Link to Sample Code + + +### Debug output diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..8d4910c --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,28 @@ +name: Security Audit + +on: + push: + pull_request: + schedule: + - cron: '0 0 * * 0' # Run weekly on Sundays + +jobs: + audit: + name: Security Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-audit + run: cargo install cargo-audit + + - name: Run security audit + run: cargo audit + + - name: Check for vulnerable dependencies + run: cargo audit --deny warnings diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..64f756e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,56 @@ +name: Build + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout Source + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Build Release + run: cargo build --release + + - name: Create Release Archive + run: | + mkdir -p release + cp target/release/rsp release/ + cd release + tar -czf rsp-${{ runner.os }}.tar.gz rsp + + - name: Upload Build Artifact + uses: actions/upload-artifact@v4 + with: + name: rsp-${{ runner.os }} + path: release/rsp-${{ runner.os }}.tar.gz + retention-days: 7 + + - name: Create Release + if: github.ref == 'refs/heads/main' + uses: softprops/action-gh-release@v2 + with: + tag_name: latest + name: Latest Build + body: Automated build from main branch + draft: false + prerelease: true + files: release/rsp-${{ runner.os }}.tar.gz + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e2700e1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Build + run: cargo build --verbose + + - name: Run tests + run: cargo test --verbose + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Security audit + run: | + cargo install cargo-audit + cargo audit + + - name: Build release + run: cargo build --release --verbose \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..8d22c9e --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,39 @@ +name: Lint + +on: [push, pull_request] + +jobs: + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-lint-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-lint- + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Check documentation + run: cargo doc --no-deps --all-features + env: + RUSTDOCFLAGS: -D warnings diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3d09b40 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: Tests + +on: [push, pull_request] + +jobs: + test: + name: Test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ matrix.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ matrix.os }}-cargo-test- + + - name: Run tests + run: cargo test --all --verbose diff --git a/.github/workflows/toc.yml b/.github/workflows/toc.yml new file mode 100644 index 0000000..1d2e616 --- /dev/null +++ b/.github/workflows/toc.yml @@ -0,0 +1,32 @@ +name: Generate Table of Contents + +on: + push: + paths: + - 'README.md' + - 'docs/**/*.md' + +permissions: + contents: write + +jobs: + generate-toc: + name: Generate TOC + runs-on: ubuntu-latest + + steps: + - name: Checkout sources + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate TOC + uses: technote-space/toc-generator@v4 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TARGET_PATHS: 'README.md' + TOC_TITLE: '## Table of Contents' + CREATE_PR: false + COMMIT_MESSAGE: 'docs: update table of contents' + COMMIT_NAME: 'github-actions[bot]' + COMMIT_EMAIL: 'github-actions[bot]@users.noreply.github.com' diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..58231af --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +RSP (Raw String Peeler) is a Rust CLI tool that converts escaped strings embedded in YAML ConfigMaps into properly formatted multi-line strings using YAML's pipe (`|`) syntax. It transforms hard-to-read escaped JSON/YAML/TOML strings into human-readable format. + +## Commands + +**Build the project:** +```bash +cargo build +``` + +**Run the application:** +```bash +cargo run +``` + +**Run tests:** +```bash +cargo test +``` + +**Build for release:** +```bash +cargo build --release +``` + +**Check code without building:** +```bash +cargo check +``` + +## Architecture + +- **Entry point:** `src/main.rs` - Main CLI entry point +- **CLI module:** `src/cli.rs` - Command-line interface using clap +- **Core logic:** `src/peeler.rs` - YAML parsing and string processing +- **Error handling:** `src/error.rs` - Custom error types using thiserror +- **Specification:** `specs/README.md` - Contains detailed requirements and expected behavior +- **Package configuration:** `Cargo.toml` - Uses Rust 2024 edition with dependencies: clap, serde, serde_yaml, anyhow, thiserror + +## Key functionality + +1. Parses YAML files containing Kubernetes ConfigMaps +2. Detects escaped string values in the `data` section for keys ending with `.yaml`, `.yml`, `.json`, or `.toml` +3. Converts escaped strings to proper YAML multi-line format using pipe (`|`) syntax +4. Outputs formatted YAML to stdout or specified file + +## Testing + +Comprehensive test suite covering: + +**Unit Tests (`tests/peeler_tests.rs`):** +- String unescaping functionality (normal and edge cases) +- ConfigMap processing logic +- File extension detection +- YAML serialization with pipe syntax +- File I/O operations + +**Integration Tests (`tests/cli_tests.rs`):** +- CLI command execution (help, version, peel) +- File input/output handling +- Error conditions and edge cases +- Command-line argument parsing + +**Edge Case Tests (`tests/edge_cases_tests.rs`):** +- Empty and malformed YAML files +- Non-ConfigMap YAML documents +- Large strings and complex escaping +- Unicode content and special characters +- Binary file handling + +**Run tests:** +```bash +cargo test # All tests +cargo test --test peeler_tests # Unit tests only +cargo test --test cli_tests # CLI integration tests +cargo test --test edge_cases_tests # Edge case tests +``` + +**Sample data:** `tests/test_data/sample_configmap.yaml` contains example input for manual testing. + +## Contribution + +IMPORTANT: Please check same cases locally as CICD does, before commit any changes such as `cargo test --all --verbose` diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5f959bb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,456 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "clap" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rsp" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "serde", + "serde_yaml", + "tempfile", + "thiserror", +] + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] diff --git a/Cargo.toml b/Cargo.toml index 1223a47..7e88184 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,11 @@ version = "0.1.0" edition = "2024" [dependencies] +clap = { version = "4.0", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9" +anyhow = "1.0" +thiserror = "1.0" + +[dev-dependencies] +tempfile = "3.0" diff --git a/README.md b/README.md index e69de29..444b61a 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,163 @@ + + +## Table of Contents + +- [RSP (Raw String Peeler)](#rsp-raw-string-peeler) + - [Table of Contents](#table-of-contents) + - [Installation](#installation) + - [From Source](#from-source) + - [Prerequisites](#prerequisites) + - [Get Started](#get-started) + - [Basic Usage](#basic-usage) + - [What it does](#what-it-does) + - [Supported file types](#supported-file-types) + - [Testing](#testing) + - [CI/CD](#cicd) + - [License](#license) + - [Contribution](#contribution) + - [Project Structure](#project-structure) + + + +# RSP (Raw String Peeler) + +A Rust CLI tool that converts escaped strings embedded in YAML ConfigMaps into properly formatted multi-line strings using YAML's pipe (`|`) syntax. + +## Table of Contents + +## Installation + +### From Source + +1. Clone the repository: +```bash +git clone +cd rsp +``` + +2. Build the project: + +```bash +# Install from source +cargo install --path . + +# Or run directly +cargo run -- "search_term" +``` + +3. The binary will be available at `target/release/rsp` + +### Prerequisites + +- Rust 1.70+ (uses Rust 2024 edition) +- Cargo package manager + +## Get Started + +### Basic Usage + +Process a YAML ConfigMap file and output to stdout: +```bash +rsp input.yaml +``` + +Process a file and save to output file: +```bash +rsp input.yaml -o output.yaml +``` + +## What it does + +RSP transforms hard-to-read escaped strings in Kubernetes ConfigMaps into human-readable format: + +**Before:** +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: example-config +data: + config.json: "{\"hello\":\"test\",\n \"foo\":\"bar\"\n}" +``` + +**After:** +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: example-config +data: + config.json: | + {"hello":"test", + "foo":"bar" + } +``` + +## Supported file types + +RSP automatically processes string values for keys ending with: +- `.yaml` or `.yml` +- `.json` +- `.toml` + +## Testing + +Run the comprehensive test suite: + +```bash +# Run all tests +cargo test + +# Run specific test categories +cargo test --test peeler_tests # Core functionality +cargo test --test cli_tests # CLI integration +cargo test --test edge_cases_tests # Edge cases and error handling +``` + +## CI/CD + +The project uses GitHub Actions for continuous integration: + +- **Build**: Compiles the project +- **Test**: Runs all test suites +- **Lint**: Checks formatting (rustfmt) and code quality (clippy) +- **Audit**: Scans for security vulnerabilities + +## License + +This project is open source. Please check the repository for license information. + +## Contribution + +We welcome contributions! Please follow these guidelines: + +1. **Testing:** Run the full test suite before submitting changes: + ```bash + cargo test --all --verbose + ``` + +2. **Code Quality:** Ensure your code passes all checks: + ```bash + cargo check + cargo clippy + ``` + +3. **Documentation:** Update documentation for any new features or changes + +4. **Commit Messages:** Use clear, descriptive commit messages + +5. **Pull Requests:** + - Create feature branches from the main branch + - Include tests for new functionality + - Ensure all CI checks pass + +### Project Structure + +- `src/main.rs` - Main CLI entry point +- `src/cli.rs` - Command-line interface +- `src/peeler.rs` - Core YAML processing logic +- `src/error.rs` - Error handling +- `tests/` - Comprehensive test suite +- `specs/README.md` - Detailed specifications + +For more detailed development information, see `CLAUDE.md` in the project root. diff --git a/specs/README.md b/specs/README.md new file mode 100644 index 0000000..6d855fb --- /dev/null +++ b/specs/README.md @@ -0,0 +1,116 @@ +# SPECIFICATION of RSP(Raw String Peeler) + +## Motivation + + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: example-config +data: + raw-yaml-string.yaml: "\"hello\": \"test\"\n\"foo\"\": 22\n" + \ +``` + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: example-config +data: + raw-toml-string.toml: "hello = \"test\"\n foo + \ = \"bar\"\n" +``` + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: example-config +data: + raw-json-string.json: "{\n + \ \"hello\":\"test\",\n \"foo\":\"bar\"\n + } +``` + +Those inner yaml/toml/json is not good for human readers. So RSP is meant to be created to peel those inner file out of yaml. So the expected output would be like this. + + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: example-config +data: + raw-yaml-string.yaml: | + hello: test + foo: bar +``` + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: example-config +data: + raw-toml-string.toml: | + hello = "test" + foo = "bar" +``` + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: example-config +data: + raw-json-string.json: | + { + "hello": "test", + "foo": "bar" + } +``` + + +## APIs + +RSP also provide nicer CLI based api + +```sh +rsp peel test-configmap.yaml +``` + +Here is the common sub commands and the options + +``` +--help: show help of this cli + +--version: show version +``` + +## Directory structure +Follow Rust CLI tool best practice. + +## Testing + +Please create some test cases including both normal input and unexpected input and place the code under ./tests + +## CICD + +Use github Actions, and do those kind of jobs to sustain the quality product. You only need to run those feature in ubuntu-latest for the time being. + +- build +- lint (including rustfmt, clippy) +- tests +- audit + +## Development style + +Firstly create a branch and the PR for each fix or feature enhancement. Then you can't merge this till human review approve it. You can also create an issue if you find a bug or points to be refactored. + +IMPORTANT: Please check same things as CICD locally, before pushing any changes such as cargo test --all --verbose + +## License + +MIT diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..f95e3ff --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,62 @@ +use crate::error::RspError; +use crate::peeler::Peeler; +use clap::{Arg, ArgMatches, Command}; + +pub struct Cli; + +impl Default for Cli { + fn default() -> Self { + Self::new() + } +} + +impl Cli { + pub fn new() -> Self { + Self + } + + pub fn run(&self) -> Result<(), RspError> { + let matches = self.build_cli().get_matches(); + + match matches.subcommand() { + Some(("peel", sub_matches)) => self.handle_peel_command(sub_matches), + _ => { + eprintln!("No command provided. Use --help for available commands."); + Ok(()) + } + } + } + + fn build_cli(&self) -> Command { + Command::new("rsp") + .about("Raw String Peeler - Convert escaped strings in YAML to readable format") + .version("0.1.0") + .subcommand( + Command::new("peel") + .about("Peel raw strings from YAML files") + .arg( + Arg::new("file") + .help("The YAML file to process (use stdin if not provided)") + .required(false) + .value_name("FILE"), + ) + .arg( + Arg::new("output") + .short('o') + .long("output") + .help("Output file (default: stdout)") + .value_name("OUTPUT_FILE"), + ), + ) + } + + fn handle_peel_command(&self, matches: &ArgMatches) -> Result<(), RspError> { + let output_file = matches.get_one::("output"); + let peeler = Peeler::new(); + + match matches.get_one::("file") { + Some(input_file) => peeler.peel_file(input_file, output_file), + None => peeler.peel_stdin(output_file), + } + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..61d32e7 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum RspError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("YAML parsing error: {0}")] + Yaml(#[from] serde_yaml::Error), + + #[error("Invalid file format: {0}")] + InvalidFormat(String), + + #[error("File not found: {0}")] + FileNotFound(String), + + #[error("Processing error: {0}")] + Processing(String), +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..593124b --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +pub mod cli; +pub mod error; +pub mod peeler; + +pub use cli::Cli; +pub use error::RspError; +pub use peeler::Peeler; diff --git a/src/main.rs b/src/main.rs index e7a11a9..079bf60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,11 @@ -fn main() { - println!("Hello, world!"); +mod cli; +mod error; +mod peeler; + +use cli::Cli; +use error::RspError; + +fn main() -> Result<(), RspError> { + let cli = Cli::new(); + cli.run() } diff --git a/src/peeler.rs b/src/peeler.rs new file mode 100644 index 0000000..bb95567 --- /dev/null +++ b/src/peeler.rs @@ -0,0 +1,209 @@ +use crate::error::RspError; +use serde_yaml::{Mapping, Value}; +use std::fs; +use std::io::{self, Read}; + +pub struct Peeler; + +impl Default for Peeler { + fn default() -> Self { + Self::new() + } +} + +impl Peeler { + pub fn new() -> Self { + Self + } + + pub fn peel_file( + &self, + input_file: &str, + output_file: Option<&String>, + ) -> Result<(), RspError> { + let content = fs::read_to_string(input_file) + .map_err(|_| RspError::FileNotFound(input_file.to_string()))?; + + self.peel_content(&content, output_file) + } + + pub fn peel_stdin(&self, output_file: Option<&String>) -> Result<(), RspError> { + let mut content = String::new(); + io::stdin() + .read_to_string(&mut content) + .map_err(|e| RspError::Processing(format!("Failed to read from stdin: {e}")))?; + + self.peel_content(&content, output_file) + } + + fn peel_content(&self, content: &str, output_file: Option<&String>) -> Result<(), RspError> { + let mut yaml_value: Value = serde_yaml::from_str(content)?; + + self.process_yaml_value(&mut yaml_value)?; + + let output = self.serialize_yaml_with_pipes(&yaml_value)?; + + match output_file { + Some(file_path) => { + fs::write(file_path, output)?; + println!("Output written to {file_path}"); + } + None => { + print!("{output}"); + } + } + + Ok(()) + } + + pub fn process_yaml_value(&self, value: &mut Value) -> Result<(), RspError> { + match value { + Value::Mapping(map) => { + self.process_configmap(map)?; + } + _ => { + return Err(RspError::InvalidFormat( + "Expected YAML mapping at root level".to_string(), + )); + } + } + Ok(()) + } + + fn process_configmap(&self, map: &mut Mapping) -> Result<(), RspError> { + if let Some(Value::String(kind)) = map.get(Value::String("kind".to_string())) { + if kind == "ConfigMap" { + if let Some(Value::Mapping(data_map)) = + map.get_mut(Value::String("data".to_string())) + { + self.process_data_section(data_map)?; + } + } + } + Ok(()) + } + + fn process_data_section(&self, data_map: &mut Mapping) -> Result<(), RspError> { + let keys_to_process: Vec<_> = data_map + .iter() + .filter_map(|(key, value)| { + if let (Value::String(key_str), Value::String(value_str)) = (key, value) { + if self.should_process_key(key_str) { + Some((key.clone(), value_str.clone())) + } else { + None + } + } else { + None + } + }) + .collect(); + + for (key, value_str) in keys_to_process { + let processed = self.process_raw_string(&value_str)?; + data_map.insert(key, Value::String(processed)); + } + + Ok(()) + } + + pub fn should_process_key(&self, key: &str) -> bool { + key.ends_with(".yaml") + || key.ends_with(".yml") + || key.ends_with(".json") + || key.ends_with(".toml") + } + + fn process_raw_string(&self, raw_string: &str) -> Result { + let unescaped = self.unescape_string(raw_string)?; + Ok(unescaped) + } + + pub fn unescape_string(&self, escaped: &str) -> Result { + let mut result = String::new(); + let mut chars = escaped.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\\' { + match chars.next() { + Some('n') => result.push('\n'), + Some('t') => result.push('\t'), + Some('r') => result.push('\r'), + Some('\\') => result.push('\\'), + Some('"') => result.push('"'), + Some(c) => { + result.push('\\'); + result.push(c); + } + None => result.push('\\'), + } + } else { + result.push(ch); + } + } + + Ok(result) + } + + pub fn serialize_yaml_with_pipes(&self, value: &Value) -> Result { + let mut output = String::new(); + self.serialize_value(value, &mut output, 0, &mut std::collections::HashSet::new())?; + Ok(output) + } + + fn serialize_value( + &self, + value: &Value, + output: &mut String, + indent: usize, + _processed_keys: &mut std::collections::HashSet, + ) -> Result<(), RspError> { + match value { + Value::Mapping(map) => { + for (key, val) in map { + if let Value::String(key_str) = key { + self.write_indent(output, indent); + output.push_str(&format!("{key_str}:")); + + if let Value::String(string_val) = val { + if self.should_process_key(key_str) && string_val.contains('\n') { + output.push_str(" |\n"); + for line in string_val.lines() { + self.write_indent(output, indent + 1); + output.push_str(line); + output.push('\n'); + } + } else { + output.push(' '); + self.serialize_value(val, output, 0, _processed_keys)?; + output.push('\n'); + } + } else { + output.push('\n'); + self.serialize_value(val, output, indent + 1, _processed_keys)?; + } + } + } + } + Value::String(s) => { + if s.contains('\n') || s.contains('"') || s.starts_with(' ') || s.ends_with(' ') { + output.push_str(&format!("\"{}\"", s.replace('"', "\\\""))); + } else { + output.push_str(s); + } + } + _ => { + let serialized = serde_yaml::to_string(value)?; + output.push_str(serialized.trim()); + if !serialized.ends_with('\n') { + output.push('\n'); + } + } + } + Ok(()) + } + + fn write_indent(&self, output: &mut String, indent: usize) { + output.push_str(&" ".repeat(indent)); + } +} diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs new file mode 100644 index 0000000..32e3da8 --- /dev/null +++ b/tests/cli_tests.rs @@ -0,0 +1,226 @@ +use std::fs; +use std::io::Write; +use std::process::Command; +use tempfile::NamedTempFile; + +#[test] +fn test_cli_help_command() { + let output = Command::new("cargo") + .args(["run", "--", "--help"]) + .output() + .expect("Failed to execute command"); + + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("Raw String Peeler")); + assert!(stdout.contains("peel")); + assert!(stdout.contains("--help")); + assert!(stdout.contains("--version")); +} + +#[test] +fn test_cli_version_command() { + let output = Command::new("cargo") + .args(["run", "--", "--version"]) + .output() + .expect("Failed to execute command"); + + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("rsp 0.1.0")); +} + +#[test] +fn test_cli_peel_help() { + let output = Command::new("cargo") + .args(["run", "--", "peel", "--help"]) + .output() + .expect("Failed to execute command"); + + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("Peel raw strings from YAML files")); + assert!(stdout.contains("[FILE]")); + assert!(stdout.contains("--output")); +} + +#[test] +fn test_cli_peel_success() { + let yaml_content = r#"apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +data: + config.json: "{\"hello\":\"world\",\n\"foo\":\"bar\"}" +"#; + + // Create temporary input file + let mut input_file = NamedTempFile::new().unwrap(); + write!(input_file, "{yaml_content}").unwrap(); + let input_path = input_file.path().to_str().unwrap(); + + let output = Command::new("cargo") + .args(["run", "--", "peel", input_path]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("config.json: |")); + assert!(stdout.contains(" {\"hello\":\"world\",")); + assert!(stdout.contains(" \"foo\":\"bar\"}")); +} + +#[test] +fn test_cli_peel_with_output_file() { + let yaml_content = r#"apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +data: + config.json: "{\"hello\":\"world\",\n\"foo\":\"bar\"}" +"#; + + // Create temporary input file + let mut input_file = NamedTempFile::new().unwrap(); + write!(input_file, "{yaml_content}").unwrap(); + let input_path = input_file.path().to_str().unwrap(); + + // Create temporary output file + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_str().unwrap(); + + let output = Command::new("cargo") + .args(["run", "--", "peel", input_path, "-o", output_path]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success()); + + // Check that output file was created and contains expected content + let file_content = fs::read_to_string(output_path).unwrap(); + assert!(file_content.contains("config.json: |")); + assert!(file_content.contains(" {\"hello\":\"world\",")); +} + +#[test] +fn test_cli_peel_file_not_found() { + let output = Command::new("cargo") + .args(["run", "--", "peel", "nonexistent_file.yaml"]) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success()); + + let stderr = String::from_utf8(output.stderr).unwrap(); + // The error could be in stderr or stdout depending on implementation + let combined_output = format!( + "{}{}", + stderr, + String::from_utf8(output.stdout).unwrap_or_default() + ); + assert!( + combined_output.contains("File not found") + || combined_output.contains("No such file") + || combined_output.contains("nonexistent_file.yaml") + ); +} + +#[test] +fn test_cli_peel_invalid_yaml() { + let invalid_yaml = "invalid: yaml: content: [unclosed"; + + let mut input_file = NamedTempFile::new().unwrap(); + write!(input_file, "{invalid_yaml}").unwrap(); + let input_path = input_file.path().to_str().unwrap(); + + let output = Command::new("cargo") + .args(["run", "--", "peel", input_path]) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success()); +} + +#[test] +fn test_cli_no_command() { + let output = Command::new("cargo") + .args(["run", "--"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success()); + + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("No command provided") || stderr.contains("help")); +} + +#[test] +fn test_cli_peel_stdin() { + let yaml_content = r#"apiVersion: v1 +kind: ConfigMap +metadata: + name: example-config +data: + config.json: "{\n \"hello\":\"world\",\n \"foo\":\"bar\"\n}" +"#; + + let mut child = Command::new("cargo") + .args(["run", "--", "peel"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("Failed to start command"); + + if let Some(stdin) = child.stdin.as_mut() { + use std::io::Write; + stdin.write_all(yaml_content.as_bytes()).unwrap(); + } + + let output = child.wait_with_output().expect("Failed to read output"); + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("config.json: |")); + assert!(stdout.contains(" \"hello\":\"world\",")); + assert!(stdout.contains(" \"foo\":\"bar\"")); +} + +#[test] +fn test_cli_peel_stdin_with_output_file() { + let yaml_content = r#"apiVersion: v1 +kind: ConfigMap +metadata: + name: example-config +data: + config.yaml: "hello: \"world\"\nfoo: \"bar\"" +"#; + + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_str().unwrap(); + + let mut child = Command::new("cargo") + .args(["run", "--", "peel", "-o", output_path]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("Failed to start command"); + + if let Some(stdin) = child.stdin.as_mut() { + use std::io::Write; + stdin.write_all(yaml_content.as_bytes()).unwrap(); + } + + let output = child.wait_with_output().expect("Failed to read output"); + assert!(output.status.success()); + + // Check that the output was written to the file + let file_content = fs::read_to_string(output_path).unwrap(); + assert!(file_content.contains("config.yaml: |")); + assert!(file_content.contains("hello: \"world\"")); + assert!(file_content.contains("foo: \"bar\"")); + + // Check that success message was printed + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("Output written to")); +} diff --git a/tests/edge_cases_tests.rs b/tests/edge_cases_tests.rs new file mode 100644 index 0000000..232ca7a --- /dev/null +++ b/tests/edge_cases_tests.rs @@ -0,0 +1,236 @@ +use rsp::peeler::Peeler; +use serde_yaml::Value; +use std::io::Write; +use tempfile::NamedTempFile; + +#[test] +fn test_empty_configmap() { + let peeler = Peeler::new(); + + let yaml_content = r#" +apiVersion: v1 +kind: ConfigMap +metadata: + name: empty-config +data: {} +"#; + + let mut yaml_value: Value = serde_yaml::from_str(yaml_content).unwrap(); + let result = peeler.process_yaml_value(&mut yaml_value); + assert!(result.is_ok()); +} + +#[test] +fn test_configmap_without_data() { + let peeler = Peeler::new(); + + let yaml_content = r#" +apiVersion: v1 +kind: ConfigMap +metadata: + name: no-data-config +"#; + + let mut yaml_value: Value = serde_yaml::from_str(yaml_content).unwrap(); + let result = peeler.process_yaml_value(&mut yaml_value); + assert!(result.is_ok()); +} + +#[test] +fn test_configmap_with_non_string_data() { + let peeler = Peeler::new(); + + let yaml_content = r#" +apiVersion: v1 +kind: ConfigMap +metadata: + name: mixed-data-config +data: + number-value: 42 + boolean-value: true + config.json: "{\"hello\":\"world\"}" +"#; + + let mut yaml_value: Value = serde_yaml::from_str(yaml_content).unwrap(); + let result = peeler.process_yaml_value(&mut yaml_value); + assert!(result.is_ok()); + + // Check that only string values with proper extensions are processed + if let Value::Mapping(map) = &yaml_value { + if let Some(Value::Mapping(data)) = map.get(Value::String("data".to_string())) { + // Number and boolean should remain unchanged + assert!(matches!( + data.get(Value::String("number-value".to_string())), + Some(Value::Number(_)) + )); + assert!(matches!( + data.get(Value::String("boolean-value".to_string())), + Some(Value::Bool(_)) + )); + } + } +} + +#[test] +fn test_deeply_nested_escapes() { + let peeler = Peeler::new(); + + let complex_escaped = r#"{\n \"outer\": {\n \"inner\": \"value\\nwith\\nnewlines\",\n \"quoted\": \"say \\\"hello\\\"\"\n }\n}"#; + let result = peeler.unescape_string(complex_escaped).unwrap(); + + // Test that the basic structure is unescaped correctly + assert!(result.contains("outer")); + assert!(result.contains("inner")); + assert!(result.contains("quoted")); + assert!(result.contains("hello")); + // Test that newlines are properly converted from \n to actual newlines + assert!(result.contains('\n')); +} + +#[test] +fn test_yaml_with_special_characters() { + let peeler = Peeler::new(); + + let yaml_content = r#" +apiVersion: v1 +kind: ConfigMap +metadata: + name: special-chars-config +data: + config.json: "{\"unicode\":\"\\u00e9\\u00e8\",\n\"symbols\":\"@#$%^&*()\"}" + weird-file-name.yaml: "key: value\\nwith: symbols" +"#; + + let mut yaml_value: Value = serde_yaml::from_str(yaml_content).unwrap(); + let result = peeler.process_yaml_value(&mut yaml_value); + assert!(result.is_ok()); +} + +#[test] +fn test_very_large_string() { + let peeler = Peeler::new(); + + // Create a large string with many lines + let large_content = (0..1000) + .map(|i| format!("line{i}: this is line number {i}")) + .collect::>() + .join("\\n"); + + let result = peeler.unescape_string(&large_content).unwrap(); + assert!(result.lines().count() == 1000); + assert!(result.contains("line0: this is line number 0")); + assert!(result.contains("line999: this is line number 999")); +} + +#[test] +fn test_malformed_yaml_structure() { + let peeler = Peeler::new(); + + // YAML that parses but has unexpected structure + let yaml_content = r#" +not_a_configmap: + kind: ConfigMap + data: + config.json: "{\"hello\":\"world\"}" +"#; + + let mut yaml_value: Value = serde_yaml::from_str(yaml_content).unwrap(); + let result = peeler.process_yaml_value(&mut yaml_value); + assert!(result.is_ok()); + + // Should not process data since it's not a proper ConfigMap + if let Value::Mapping(map) = &yaml_value { + if let Some(Value::Mapping(inner)) = map.get(Value::String("not_a_configmap".to_string())) { + if let Some(Value::Mapping(_data)) = inner.get(Value::String("data".to_string())) { + if let Some(Value::String(json_content)) = + _data.get(Value::String("config.json".to_string())) + { + assert_eq!(json_content, "{\"hello\":\"world\"}"); // Should remain escaped + } + } + } + } +} + +#[test] +fn test_empty_file_processing() { + let peeler = Peeler::new(); + + let mut input_file = NamedTempFile::new().unwrap(); + write!(input_file, "").unwrap(); + let input_path = input_file.path().to_str().unwrap(); + + let result = peeler.peel_file(input_path, None); + // Empty file should result in YAML parsing error or other error + assert!(result.is_err()); +} + +#[test] +fn test_binary_file_processing() { + let peeler = Peeler::new(); + + let mut input_file = NamedTempFile::new().unwrap(); + // Write some binary data + input_file.write_all(&[0xFF, 0xFE, 0xFD, 0xFC]).unwrap(); + let input_path = input_file.path().to_str().unwrap(); + + let result = peeler.peel_file(input_path, None); + assert!(result.is_err()); // Should fail to parse as YAML or UTF-8 +} + +#[test] +fn test_extremely_nested_escapes() { + let peeler = Peeler::new(); + + // Test case with multiple levels of escaping + let nested_escaped = r#"\\n\\t\\r\\\"\\\\"#; + let result = peeler.unescape_string(nested_escaped).unwrap(); + assert_eq!(result, "\\n\\t\\r\\\"\\\\"); +} + +#[test] +fn test_unicode_in_escaped_strings() { + let peeler = Peeler::new(); + + let unicode_content = "Hello\\nWorld\\n🌍\\n世界"; + let result = peeler.unescape_string(unicode_content).unwrap(); + assert!(result.contains("Hello\nWorld\n🌍\n世界")); +} + +#[test] +fn test_mixed_file_extensions() { + let peeler = Peeler::new(); + + let yaml_content = r#" +apiVersion: v1 +kind: ConfigMap +metadata: + name: mixed-extensions +data: + config.json: "{\"json\":\"content\"}" + config.yaml: "yaml: content" + config.yml: "yml: content" + config.toml: "toml = \"content\"" + config.txt: "should not process" + config.xml: "not process" + no-extension: "should not process" +"#; + + let mut yaml_value: Value = serde_yaml::from_str(yaml_content).unwrap(); + peeler.process_yaml_value(&mut yaml_value).unwrap(); + + if let Value::Mapping(map) = &yaml_value { + if let Some(Value::Mapping(_data)) = map.get(Value::String("data".to_string())) { + // These should be processed (unescaped) + assert!(peeler.should_process_key("config.json")); + assert!(peeler.should_process_key("config.yaml")); + assert!(peeler.should_process_key("config.yml")); + assert!(peeler.should_process_key("config.toml")); + + // These should not be processed + assert!(!peeler.should_process_key("config.txt")); + assert!(!peeler.should_process_key("config.xml")); + assert!(!peeler.should_process_key("no-extension")); + } + } +} diff --git a/tests/peeler_tests.rs b/tests/peeler_tests.rs new file mode 100644 index 0000000..c846aed --- /dev/null +++ b/tests/peeler_tests.rs @@ -0,0 +1,259 @@ +use rsp::error::RspError; +use rsp::peeler::Peeler; +use serde_yaml::Value; +use std::fs; +use tempfile::NamedTempFile; + +#[test] +fn test_unescape_string_normal_cases() { + let peeler = Peeler::new(); + + // Test newline unescaping + let result = peeler.unescape_string("hello\\nworld").unwrap(); + assert_eq!(result, "hello\nworld"); + + // Test tab unescaping + let result = peeler.unescape_string("hello\\tworld").unwrap(); + assert_eq!(result, "hello\tworld"); + + // Test quote unescaping + let result = peeler.unescape_string("hello\\\"world").unwrap(); + assert_eq!(result, "hello\"world"); + + // Test backslash unescaping + let result = peeler.unescape_string("hello\\\\world").unwrap(); + assert_eq!(result, "hello\\world"); + + // Test carriage return unescaping + let result = peeler.unescape_string("hello\\rworld").unwrap(); + assert_eq!(result, "hello\rworld"); + + // Test multiple escapes + let result = peeler + .unescape_string("line1\\nline2\\tindented\\\"quoted\\\"") + .unwrap(); + assert_eq!(result, "line1\nline2\tindented\"quoted\""); +} + +#[test] +fn test_unescape_string_edge_cases() { + let peeler = Peeler::new(); + + // Test empty string + let result = peeler.unescape_string("").unwrap(); + assert_eq!(result, ""); + + // Test string with no escapes + let result = peeler.unescape_string("hello world").unwrap(); + assert_eq!(result, "hello world"); + + // Test backslash at end + let result = peeler.unescape_string("hello\\").unwrap(); + assert_eq!(result, "hello\\"); + + // Test unknown escape sequence + let result = peeler.unescape_string("hello\\zworld").unwrap(); + assert_eq!(result, "hello\\zworld"); + + // Test multiple consecutive backslashes + let result = peeler.unescape_string("hello\\\\\\\\world").unwrap(); + assert_eq!(result, "hello\\\\world"); +} + +#[test] +fn test_should_process_key() { + let peeler = Peeler::new(); + + // Test valid extensions + assert!(peeler.should_process_key("config.yaml")); + assert!(peeler.should_process_key("config.yml")); + assert!(peeler.should_process_key("config.json")); + assert!(peeler.should_process_key("config.toml")); + + // Test invalid extensions + assert!(!peeler.should_process_key("config.txt")); + assert!(!peeler.should_process_key("config.xml")); + assert!(!peeler.should_process_key("config")); + assert!(!peeler.should_process_key("")); + + // Test complex filenames + assert!(peeler.should_process_key("my-app-config.yaml")); + assert!(peeler.should_process_key("database.config.json")); + assert!(!peeler.should_process_key("config.yaml.backup")); +} + +#[test] +fn test_process_configmap_normal() { + let peeler = Peeler::new(); + + let yaml_content = r#" +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +data: + config.json: "{\"hello\":\"world\",\n\"foo\":\"bar\"}" + config.yaml: "key: value\nanother: test" + normal-file: "This should not be processed" +"#; + + let mut yaml_value: Value = serde_yaml::from_str(yaml_content).unwrap(); + peeler.process_yaml_value(&mut yaml_value).unwrap(); + + // Verify the processing worked + if let Value::Mapping(map) = &yaml_value { + if let Some(Value::Mapping(data)) = map.get(Value::String("data".to_string())) { + if let Some(Value::String(json_content)) = + data.get(Value::String("config.json".to_string())) + { + assert!(json_content.contains("\"hello\":\"world\"")); + assert!(json_content.contains('\n')); + } else { + panic!("config.json not found or not a string"); + } + + if let Some(Value::String(normal_content)) = + data.get(Value::String("normal-file".to_string())) + { + assert_eq!(normal_content, "This should not be processed"); + } else { + panic!("normal-file not found or not a string"); + } + } else { + panic!("data section not found"); + } + } else { + panic!("Root is not a mapping"); + } +} + +#[test] +fn test_process_yaml_invalid_format() { + let peeler = Peeler::new(); + + // Test with non-mapping root + let mut yaml_value = Value::String("not a mapping".to_string()); + let result = peeler.process_yaml_value(&mut yaml_value); + assert!(matches!(result, Err(RspError::InvalidFormat(_)))); + + // Test with array root + let mut yaml_value = Value::Sequence(vec![]); + let result = peeler.process_yaml_value(&mut yaml_value); + assert!(matches!(result, Err(RspError::InvalidFormat(_)))); +} + +#[test] +fn test_process_non_configmap() { + let peeler = Peeler::new(); + + let yaml_content = r#" +apiVersion: v1 +kind: Secret +metadata: + name: test-secret +data: + config.json: "{\"hello\":\"world\"}" +"#; + + let mut yaml_value: Value = serde_yaml::from_str(yaml_content).unwrap(); + peeler.process_yaml_value(&mut yaml_value).unwrap(); + + // Verify that Secret data is not processed (only ConfigMap should be processed) + if let Value::Mapping(map) = &yaml_value { + if let Some(Value::Mapping(data)) = map.get(Value::String("data".to_string())) { + if let Some(Value::String(json_content)) = + data.get(Value::String("config.json".to_string())) + { + assert_eq!(json_content, "{\"hello\":\"world\"}"); // Should remain escaped + } + } + } +} + +#[test] +fn test_serialize_yaml_with_pipes() { + let peeler = Peeler::new(); + + let yaml_content = r#" +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +data: + config.json: "{\"hello\":\"world\",\n\"foo\":\"bar\"}" + simple: "no newlines here" +"#; + + let mut yaml_value: Value = serde_yaml::from_str(yaml_content).unwrap(); + peeler.process_yaml_value(&mut yaml_value).unwrap(); + + let result = peeler.serialize_yaml_with_pipes(&yaml_value).unwrap(); + + // Check that multiline content uses pipe syntax + assert!(result.contains("config.json: |")); + assert!(result.contains(" {\"hello\":\"world\",")); + assert!(result.contains(" \"foo\":\"bar\"}")); + + // Check that simple content doesn't use pipe syntax + assert!(result.contains("simple: no newlines here")); + assert!(!result.contains("simple: |")); +} + +#[cfg(test)] +mod file_tests { + use super::*; + use std::io::Write; + + #[test] + fn test_peel_file_success() { + let peeler = Peeler::new(); + + let yaml_content = r#"apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +data: + config.json: "{\"hello\":\"world\",\n\"foo\":\"bar\"}" +"#; + + // Create temporary input file + let mut input_file = NamedTempFile::new().unwrap(); + write!(input_file, "{yaml_content}").unwrap(); + let input_path = input_file.path().to_str().unwrap(); + + // Create temporary output file + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_str().unwrap(); + + // Test processing + let result = peeler.peel_file(input_path, Some(&output_path.to_string())); + assert!(result.is_ok()); + + // Verify output file content + let output_content = fs::read_to_string(output_path).unwrap(); + assert!(output_content.contains("config.json: |")); + assert!(output_content.contains(" {\"hello\":\"world\",")); + } + + #[test] + fn test_peel_file_not_found() { + let peeler = Peeler::new(); + + let result = peeler.peel_file("nonexistent_file.yaml", None); + assert!(matches!(result, Err(RspError::FileNotFound(_)))); + } + + #[test] + fn test_peel_file_invalid_yaml() { + let peeler = Peeler::new(); + + let invalid_yaml = "invalid: yaml: content: [unclosed"; + + let mut input_file = NamedTempFile::new().unwrap(); + write!(input_file, "{invalid_yaml}").unwrap(); + let input_path = input_file.path().to_str().unwrap(); + + let result = peeler.peel_file(input_path, None); + assert!(matches!(result, Err(RspError::Yaml(_)))); + } +} diff --git a/tests/test_data/sample_configmap.yaml b/tests/test_data/sample_configmap.yaml new file mode 100644 index 0000000..7ee0bda --- /dev/null +++ b/tests/test_data/sample_configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: example-config +data: + raw-yaml-string.yaml: "\"hello\": \"test\"\n\"foo\": 22\n" + raw-json-string.json: "{\n \"hello\":\"test\",\n \"foo\":\"bar\"\n}" + raw-toml-string.toml: "hello = \"test\"\nfoo = \"bar\"\n" + normal-string: "This should not be processed" \ No newline at end of file