diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d848a4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +permissions: + contents: read + +jobs: + test: + name: Test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Zig + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.15.2 + + - name: Run tests + run: zig build test + + - name: Build project + run: zig build + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: repolicense-${{ matrix.os }} + path: zig-out/bin/repolicense* + + fmt-check: + name: Format Check + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Zig + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.15.2 + + - name: Check formatting + run: zig fmt --check src/ diff --git a/CI_CD.md b/CI_CD.md deleted file mode 100644 index 8d798ba..0000000 --- a/CI_CD.md +++ /dev/null @@ -1,218 +0,0 @@ -# CI/CD Setup for repolicense-cli - -## GitHub Actions Workflow - -To set up continuous integration for this project, create `.github/workflows/ci.yml`: - -```yaml -name: CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - build: - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/checkout@v4 - - - name: Setup Zig - uses: goto-bus-stop/setup-zig@v2 - with: - version: 0.13.0 - - - name: Build - run: zig build - - - name: Run tests (when available) - run: zig build test || echo "No tests configured yet" - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: repolicense-${{ matrix.os }} - path: zig-out/bin/repolicense* - - lint: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup Zig - uses: goto-bus-stop/setup-zig@v2 - with: - version: 0.13.0 - - - name: Check formatting - run: zig fmt --check src/ -``` - -## Release Workflow - -Create `.github/workflows/release.yml` for automated releases: - -```yaml -name: Release - -on: - push: - tags: - - 'v*' - -jobs: - build-release: - strategy: - matrix: - include: - - os: ubuntu-latest - target: x86_64-linux - - os: macos-latest - target: x86_64-macos - - os: windows-latest - target: x86_64-windows - - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/checkout@v4 - - - name: Setup Zig - uses: goto-bus-stop/setup-zig@v2 - with: - version: 0.13.0 - - - name: Build Release - run: zig build -Doptimize=ReleaseSafe - - - name: Create Archive - shell: bash - run: | - cd zig-out/bin - if [ "${{ runner.os }}" = "Windows" ]; then - 7z a ../../repolicense-${{ matrix.target }}.zip repolicense.exe - else - tar czf ../../repolicense-${{ matrix.target }}.tar.gz repolicense - fi - - - name: Upload Release Asset - uses: actions/upload-artifact@v4 - with: - name: repolicense-${{ matrix.target }} - path: repolicense-*.* - - create-release: - needs: build-release - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Download Artifacts - uses: actions/download-artifact@v4 - - - name: Create Release - uses: softprops/action-gh-release@v1 - with: - files: | - **/repolicense-* - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -``` - -## Local Development Scripts - -### Format Check -```bash -#!/bin/bash -zig fmt --check src/ -``` - -### Build and Test -```bash -#!/bin/bash -set -e -echo "Building..." -zig build -echo "Running tests..." -zig build test || echo "No tests yet" -echo "Success!" -``` - -### Cross-compilation Example -```bash -#!/bin/bash -# Build for multiple targets -zig build -Dtarget=x86_64-linux -zig build -Dtarget=x86_64-macos -zig build -Dtarget=x86_64-windows -zig build -Dtarget=aarch64-linux -zig build -Dtarget=aarch64-macos -``` - -## Docker Support - -Create a `Dockerfile` for containerized builds: - -```dockerfile -FROM alpine:latest - -# Install Zig -RUN apk add --no-cache wget xz && \ - wget https://ziglang.org/download/0.13.0/zig-linux-x86_64-0.13.0.tar.xz && \ - tar xf zig-linux-x86_64-0.13.0.tar.xz && \ - mv zig-linux-x86_64-0.13.0 /usr/local/zig && \ - ln -s /usr/local/zig/zig /usr/local/bin/zig && \ - rm zig-linux-x86_64-0.13.0.tar.xz - -WORKDIR /app -COPY . . - -RUN zig build - -ENTRYPOINT ["/app/zig-out/bin/repolicense"] -``` - -Build and run: -```bash -docker build -t repolicense-cli . -docker run -it repolicense-cli -``` - -## Pre-commit Hooks - -Create `.git/hooks/pre-commit`: - -```bash -#!/bin/bash -echo "Running pre-commit checks..." - -# Format check -if ! zig fmt --check src/; then - echo "Error: Code is not formatted. Run 'zig fmt src/' to fix." - exit 1 -fi - -# Build check -if ! zig build; then - echo "Error: Build failed." - exit 1 -fi - -echo "Pre-commit checks passed!" -``` - -Make it executable: -```bash -chmod +x .git/hooks/pre-commit -``` diff --git a/COMPARISON.md b/COMPARISON.md index c366a6e..1a6a7a5 100644 --- a/COMPARISON.md +++ b/COMPARISON.md @@ -17,6 +17,7 @@ This CLI tool (`repolicense-cli`) is a command-line implementation of the web-ba | Mermaid Diagram Export | ✅ Copy to clipboard | ❌ Not implemented | | Download Tree | ✅ JSON download | ❌ Not implemented | | Visual Design | ✅ Modern UI with Shoelace | ✅ Clean text output | +| License Compatibility Checker | ❌ Not implemented | ✅ New feature for checking compatible licenses when forking/combining projects | | Platform | 🌐 Browser-based | 💻 Native CLI | ## Architecture Differences diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87c8127..4dc5a28 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ Thank you for your interest in contributing to repolicense-cli! ## Prerequisites -- Zig 0.13.0 or later: [Download Zig](https://ziglang.org/download/) +- Zig 0.15.2 or later: [Download Zig](https://ziglang.org/download/) ## Building @@ -27,7 +27,7 @@ zig build test ## Code Style - Follow Zig standard library conventions -- Use 4 spaces for indentation +- Use 2 spaces for indentation - Keep functions focused and concise - Add comments for complex logic @@ -35,7 +35,7 @@ zig build test To add a new license to the decision tree: -1. Open `src/main.zig` +1. Open `src/tree.zig` 2. Add a new `Node` constant in the `buildDecisionTree()` function 3. Update the tree structure to include your new license 4. Update the README with the new license information diff --git a/EXAMPLES.md b/EXAMPLES.md deleted file mode 100644 index 525af03..0000000 --- a/EXAMPLES.md +++ /dev/null @@ -1,162 +0,0 @@ -# Usage Examples - -## Example 1: Choosing the MIT License - -``` -=== Repolicense CLI === -Welcome to Repolicense! This tool helps you choose the right open-source license. -Answer the questions with 'yes', 'no', 'back', 'reset', or 'quit'. - ---- Question --- -Do you want to open-source your project? - -Elaboration: Do you want to make your source code publicly available and allow others to use, modify, and distribute it. - -Your answer (yes/no/back/reset/quit): yes - ---- Question --- -Do you want a permissive license? - -Elaboration: Do you want a more lenient license and allow others to use, modify, and distribute your code with minimal restrictions. These licenses are generally business-friendly and encourage wider use. - -Your answer (yes/no/back/reset/quit): yes - ---- Question --- -Do you require minimal conditions? - -Elaboration: Do you want minimal conditions meaning that there are very few requirements placed on the use of your code. This typically includes providing attribution to the original authors. - -Your answer (yes/no/back/reset/quit): yes - ---- Question --- -Do you want the simplest and most permissive license possible? - -Elaboration: Do you want a license that is very permissive and has minimal requirements, making it as simple as possible for others to use your code. - -Your answer (yes/no/back/reset/quit): yes - -=== RESULT === -License: MIT - -For more information, visit: -https://api.github.com/licenses/MIT -https://docs.github.com/en/rest/licenses/licenses - -Options: back/reset/quit: quit - -Thank you for using Repolicense! -``` - -## Example 2: Exploring with Back and Reset - -``` ---- Question --- -Do you want to open-source your project? - -Your answer (yes/no/back/reset/quit): yes - ---- Question --- -Do you want a permissive license? - -Your answer (yes/no/back/reset/quit): no - ---- Question --- -Do you want a copyleft license? - -Your answer (yes/no/back/reset/quit): back - -[Moved back] - ---- Question --- -Do you want a permissive license? - -Your answer (yes/no/back/reset/quit): reset - -[Reset to beginning] - ---- Question --- -Do you want to open-source your project? - -Your answer (yes/no/back/reset/quit): quit - -Thank you for using Repolicense! -``` - -## Example 3: Choosing GPL-3.0 - -``` ---- Question --- -Do you want to open-source your project? -Your answer: yes - ---- Question --- -Do you want a permissive license? -Your answer: no - ---- Question --- -Do you want a copyleft license? -Your answer: yes - ---- Question --- -Do you want strong copyleft? -Your answer: yes - ---- Question --- -Do you want network server protection? -Your answer: no - ---- Question --- -Do you want to use the latest version of the GPL license? -Your answer: yes - -=== RESULT === -License: GPL-3.0 - -For more information, visit: -https://api.github.com/licenses/GPL-3.0 -https://docs.github.com/en/rest/licenses/licenses -``` - -## Example 4: Deciding Not to Open Source - -``` ---- Question --- -Do you want to open-source your project? - -Elaboration: Do you want to make your source code publicly available and allow others to use, modify, and distribute it. - -Your answer (yes/no/back/reset/quit): no - -=== RESULT === -License: You should consider keeping your project closed-source. - -You should consider keeping your project closed-source to protect your intellectual property by not sharing the source code. - -Options: back/reset/quit: quit - -Thank you for using Repolicense! -``` - -## Quick Reference: License Decision Paths - -### Permissive Licenses -- **MIT**: yes → yes → yes → yes -- **BSD-2-Clause**: yes → yes → yes → no -- **Apache-2.0**: yes → yes → no → yes -- **BSD-3-Clause**: yes → yes → no → no → yes -- **0BSD**: yes → yes → no → no → no → yes -- **ISC**: yes → yes → no → no → no → no - -### Copyleft Licenses -- **AGPL-3.0**: yes → no → yes → yes → yes -- **GPL-3.0**: yes → no → yes → yes → no → yes -- **GPL-2.0**: yes → no → yes → yes → no → no -- **LGPL-3.0**: yes → no → yes → no → yes -- **MPL-2.0**: yes → no → yes → no → no → yes -- **EPL-2.0**: yes → no → yes → no → no → no → yes -- **EPL-1.0**: yes → no → yes → no → no → no → no - -### Other -- **Unlicense**: yes → no → no → yes -- **OFL-1.1**: yes → no → no → no → yes -- **Closed Source**: no diff --git a/PROJECT_SUMMARY.txt b/PROJECT_SUMMARY.txt deleted file mode 100644 index 36fa5f8..0000000 --- a/PROJECT_SUMMARY.txt +++ /dev/null @@ -1,167 +0,0 @@ -================================================================================ - REPOLICENSE-CLI PROJECT SUMMARY -================================================================================ - -PROJECT: repolicense-cli -DESCRIPTION: Command-line tool to help choose open-source licenses -LANGUAGE: Zig -VERSION: 1.0.0 -MINIMUM ZIG VERSION: 0.13.0 - -================================================================================ -IMPLEMENTATION STATUS: ✅ COMPLETE -================================================================================ - -The entire logic from the repolicense web application (github.com/lucasfth/repolicense) -has been successfully transferred to this Zig CLI application. - -KEY FEATURES: -- Interactive question/answer interface -- Complete decision tree with 17+ licenses -- Navigation commands (back, reset, quit) -- Elaborations for each question -- GitHub API links for license information -- No external dependencies -- Cross-platform support (Linux, macOS, Windows) - -================================================================================ -PROJECT STRUCTURE -================================================================================ - -repolicense-cli/ -├── src/ -│ └── main.zig (380 lines) - Main application code -├── build.zig (25 lines) - Build configuration -├── build.zig.zon (10 lines) - Package manifest -├── install.sh (40 lines) - Installation helper -├── .gitignore (15 lines) - Git ignore rules -├── README.md (93 lines) - Main documentation -├── CONTRIBUTING.md (47 lines) - Contribution guide -├── TEST_PLAN.md (120 lines) - Testing checklist -├── EXAMPLES.md (160 lines) - Usage examples -├── COMPARISON.md (130 lines) - Web vs CLI comparison -├── TREE_STRUCTURE.md (86 lines) - Decision tree visual -├── CI_CD.md (218 lines) - CI/CD workflows -└── LICENSE (674 lines) - GNU GPL v3.0 - -Total: 12 files, ~2,000 lines - -================================================================================ -SUPPORTED LICENSES -================================================================================ - -PERMISSIVE (6): -✓ MIT -✓ BSD-2-Clause -✓ BSD-3-Clause -✓ Apache-2.0 -✓ 0BSD -✓ ISC - -STRONG COPYLEFT (3): -✓ AGPL-3.0 -✓ GPL-3.0 -✓ GPL-2.0 - -WEAK COPYLEFT (4): -✓ LGPL-3.0 -✓ MPL-2.0 -✓ EPL-2.0 -✓ EPL-1.0 - -OTHER (3): -✓ Unlicense (public domain) -✓ OFL-1.1 (fonts) -✓ Advisory results (proprietary/closed-source) - -================================================================================ -BUILD & RUN -================================================================================ - -Prerequisites: - - Zig 0.13.0 or later - -Build: - $ zig build - -Run: - $ ./zig-out/bin/repolicense - -Or build and run: - $ zig build run - -================================================================================ -DOCUMENTATION HIGHLIGHTS -================================================================================ - -✓ Comprehensive README with installation instructions -✓ Detailed usage examples with screenshots -✓ Complete test plan for manual and automated testing -✓ Contribution guidelines for open-source contributors -✓ CI/CD workflow examples for GitHub Actions -✓ Decision tree visualization -✓ Comparison document explaining web vs CLI differences - -================================================================================ -CODE QUALITY -================================================================================ - -✓ Clean, idiomatic Zig code -✓ No external dependencies (Zig stdlib only) -✓ Proper memory management with allocator -✓ Compile-time guarantees for tree structure -✓ No unused imports or dead code -✓ Code review completed and feedback addressed - -================================================================================ -TESTING -================================================================================ - -Manual testing checklist provided in TEST_PLAN.md covering: -- All 17 license decision paths -- Navigation commands -- Input validation -- Edge cases -- Output formatting - -Users can verify the tool by following the test plan. - -================================================================================ -NEXT STEPS FOR USERS -================================================================================ - -1. Install Zig from https://ziglang.org/download/ -2. Clone this repository -3. Run ./install.sh or manually run: zig build -4. Execute: ./zig-out/bin/repolicense -5. Follow the interactive prompts -6. Choose your license! - -================================================================================ -COMPATIBILITY -================================================================================ - -✓ Linux (x86_64, ARM64) -✓ macOS (Intel, Apple Silicon) -✓ Windows (x86_64) -✓ Any platform supported by Zig - -================================================================================ -LICENSE -================================================================================ - -This project is licensed under GNU General Public License v3.0 -Same as the original repolicense web application. - -================================================================================ -ACKNOWLEDGMENTS -================================================================================ - -Based on the original repolicense web application: -https://github.com/lucasfth/repolicense - -Decision tree logic faithfully ported from JavaScript to Zig. - -================================================================================ - END OF SUMMARY -================================================================================ diff --git a/README.md b/README.md index de695c3..3c8945f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # repolicense-cli +

+ License badge showing the repository is licensed under MIT + Zig version 0.15.2 +

+ A command-line tool to help you choose the right open-source license for your project, implemented in Zig. This is a CLI version of [repolicense](https://github.com/lucasfth/repolicense), which guides you through a series of questions to determine the most suitable license for your needs. @@ -11,10 +16,12 @@ This is a CLI version of [repolicense](https://github.com/lucasfth/repolicense), - Reset at any time to start over - Get detailed information about each license - Links to GitHub API for license details +- **NEW**: License compatibility checker for forking projects + This feature allows you to input a list of existing licenses and find out which licenses are compatible with all of them when combining code from multiple projects. ## Prerequisites -- [Zig](https://ziglang.org/download/) version 0.13.0 or later +- [Zig](https://ziglang.org/download/) version 0.15.2 or later ## Building @@ -36,38 +43,54 @@ Or build and run in one command: zig build run ``` +### License Compatibility + +To check which licenses you can use when forking or combining projects: + +```bash +./zig-out/bin/repolicense --compat +``` + +This mode helps you determine compatible licenses when you want to combine code from multiple projects with different licenses. + ## Usage +### License Selection Mode (Default) + The tool will ask you a series of yes/no questions about your project requirements. Based on your answers, it will recommend an appropriate open-source license. Available commands: + - `yes` or `y` - Answer yes to the current question - `no` or `n` - Answer no to the current question - `back` or `b` - Go back to the previous question - `reset` or `r` - Start over from the beginning - `quit` or `q` - Exit the program -## Example - -``` -=== Repolicense CLI === -Welcome to Repolicense! This tool helps you choose the right open-source license. -Answer the questions with 'yes', 'no', 'back', 'reset', or 'quit'. +### License Compatibility Mode ---- Question --- -Do you want to open-source your project? +Run with the `--compat` flag (or `-c`) to check license compatibility: -Elaboration: Do you want to make your source code publicly available and allow others to use, modify, and distribute it. +```bash +./zig-out/bin/repolicense --compat +``` -Your answer (yes/no/back/reset/quit): yes +This mode allows you to: ---- Question --- -Do you want a permissive license? +- Enter a comma-separated list of licenses from projects you want to combine +- See which licenses are compatible with all of them +- Understand why certain combinations are compatible or not -Elaboration: Do you want a more lenient license and allow others to use, modify, and distribute your code with minimal restrictions. These licenses are generally business-friendly and encourage wider use. +Example: -Your answer (yes/no/back/reset/quit): yes -... +```bash +Enter licenses: MIT, Apache-2.0 +You can use any of these licenses for your combined work: + • MIT + • BSD-2-Clause + • Apache-2.0 + • GPL-3.0 + ... ``` ## Supported Licenses @@ -79,9 +102,25 @@ The tool helps you choose from the following licenses: - **Public Domain**: Unlicense - **Specialized**: OFL-1.1 (for fonts) +> ℹ️ Note: If you wish to add more licenses, please submit a Pull Request with the new license details and update the decision tree accordingly. + +## Testing + +Run the test suite: + +```bash +zig build test +``` + +The test suite includes: + +- Decision tree structure validation +- License compatibility logic tests +- Navigation and path verification + ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Contributions are welcome! Please feel free to submit a Pull Request and follow the guidelines in [CONTRIBUTING.md](CONTRIBUTING.md). ## Acknowledgments diff --git a/build.zig b/build.zig index 4e0bb9c..19de171 100644 --- a/build.zig +++ b/build.zig @@ -4,13 +4,17 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - const exe = b.addExecutable(.{ - .name = "repolicense", + const exe_module = b.addModule("repolicense", .{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, }); + const exe = b.addExecutable(.{ + .name = "repolicense", + .root_module = exe_module, + }); + b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); @@ -22,4 +26,47 @@ pub fn build(b: *std.Build) void { const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); + + // Test step + const tree_test_module = b.addModule("tree_test", .{ + .root_source_file = b.path("src/tree_test.zig"), + .target = target, + .optimize = optimize, + }); + + const tree_tests = b.addTest(.{ + .name = "tree_test", + .root_module = tree_test_module, + }); + + const compatibility_test_module = b.addModule("compatibility_test", .{ + .root_source_file = b.path("src/compatibility_test.zig"), + .target = target, + .optimize = optimize, + }); + + const compatibility_tests = b.addTest(.{ + .name = "compatibility_test", + .root_module = compatibility_test_module, + }); + + const ui_test_module = b.addModule("ui_test", .{ + .root_source_file = b.path("src/ui_test.zig"), + .target = target, + .optimize = optimize, + }); + + const ui_tests = b.addTest(.{ + .name = "ui_test", + .root_module = ui_test_module, + }); + + const run_tree_tests = b.addRunArtifact(tree_tests); + const run_compatibility_tests = b.addRunArtifact(compatibility_tests); + const run_ui_tests = b.addRunArtifact(ui_tests); + + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_tree_tests.step); + test_step.dependOn(&run_compatibility_tests.step); + test_step.dependOn(&run_ui_tests.step); } diff --git a/build.zig.zon b/build.zig.zon index e993b3b..a13b558 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,13 +1,13 @@ .{ - .name = "repolicense-cli", - .version = "1.0.0", - .minimum_zig_version = "0.13.0", - .dependencies = .{}, + .name = .repolicense_cli, + .version = "0.0.0", + .fingerprint = 0x38a819a8db54f0a5, // Changing this has security and trust implications. + .minimum_zig_version = "0.15.2", + .dependencies = .{ + }, .paths = .{ "build.zig", "build.zig.zon", "src", - "README.md", - "LICENSE", }, } diff --git a/src/compatibility.zig b/src/compatibility.zig new file mode 100644 index 0000000..a575184 --- /dev/null +++ b/src/compatibility.zig @@ -0,0 +1,244 @@ +const std = @import("std"); + +/// License compatibility checker for determining which licenses can be used +/// when forking/combining projects with existing licenses. +/// +/// Compatibility rules are based on common open-source license compatibility: +/// - Permissive licenses (MIT, BSD, Apache) are generally compatible with everything +/// - Copyleft licenses (GPL, AGPL) require derivatives to use the same or compatible license +/// - Some licenses (Apache-2.0 + GPL-2.0) have known incompatibilities +pub const License = enum { + MIT, + BSD_2_Clause, + BSD_3_Clause, + Apache_2_0, + @"0BSD", + ISC, + AGPL_3_0, + GPL_3_0, + GPL_2_0, + LGPL_3_0, + MPL_2_0, + EPL_2_0, + EPL_1_0, + Unlicense, + OFL_1_1, + + pub fn fromString(s: []const u8) ?License { + // Convert to uppercase for case-insensitive matching + var buf: [32]u8 = undefined; + if (s.len > buf.len) return null; + + const upper = std.ascii.upperString(&buf, s); + + const table = [_]struct { name: []const u8, value: License }{ + .{ .name = "MIT", .value = .MIT }, + .{ .name = "BSD-2-CLAUSE", .value = .BSD_2_Clause }, + .{ .name = "BSD-3-CLAUSE", .value = .BSD_3_Clause }, + .{ .name = "APACHE-2.0", .value = .Apache_2_0 }, + .{ .name = "0BSD", .value = .@"0BSD" }, + .{ .name = "ISC", .value = .ISC }, + .{ .name = "AGPL-3.0", .value = .AGPL_3_0 }, + .{ .name = "GPL-3.0", .value = .GPL_3_0 }, + .{ .name = "GPL-2.0", .value = .GPL_2_0 }, + .{ .name = "LGPL-3.0", .value = .LGPL_3_0 }, + .{ .name = "MPL-2.0", .value = .MPL_2_0 }, + .{ .name = "EPL-2.0", .value = .EPL_2_0 }, + .{ .name = "EPL-1.0", .value = .EPL_1_0 }, + .{ .name = "UNLICENSE", .value = .Unlicense }, + .{ .name = "OFL-1.1", .value = .OFL_1_1 }, + }; + + for (table) |entry| { + if (std.mem.eql(u8, upper, entry.name)) return entry.value; + } + + return null; + } + + pub fn toString(self: License) []const u8 { + return switch (self) { + .MIT => "MIT", + .BSD_2_Clause => "BSD-2-Clause", + .BSD_3_Clause => "BSD-3-Clause", + .Apache_2_0 => "Apache-2.0", + .@"0BSD" => "0BSD", + .ISC => "ISC", + .AGPL_3_0 => "AGPL-3.0", + .GPL_3_0 => "GPL-3.0", + .GPL_2_0 => "GPL-2.0", + .LGPL_3_0 => "LGPL-3.0", + .MPL_2_0 => "MPL-2.0", + .EPL_2_0 => "EPL-2.0", + .EPL_1_0 => "EPL-1.0", + .Unlicense => "Unlicense", + .OFL_1_1 => "OFL-1.1", + }; + } + + pub fn getCategory(self: License) LicenseCategory { + return switch (self) { + .MIT, .BSD_2_Clause, .BSD_3_Clause, .Apache_2_0, .@"0BSD", .ISC => .Permissive, + .AGPL_3_0, .GPL_3_0, .GPL_2_0 => .StrongCopyleft, + .LGPL_3_0, .MPL_2_0, .EPL_2_0, .EPL_1_0 => .WeakCopyleft, + .Unlicense => .PublicDomain, + .OFL_1_1 => .Specialized, + }; + } +}; + +pub const LicenseCategory = enum { + Permissive, + StrongCopyleft, + WeakCopyleft, + PublicDomain, + Specialized, +}; + +/// Checks if a new license can be used when combining with existing licenses +/// Returns true if the combination is compatible +pub fn isCompatible(existing: License, new_license: License) bool { + // Public domain and permissive licenses can be combined with anything + if (existing.getCategory() == .PublicDomain or existing == .Unlicense) { + return true; + } + + // Same license is always compatible + if (existing == new_license) { + return true; + } + + // Check compatibility based on existing license + return switch (existing) { + // Permissive licenses accept most things, but derivatives must respect their terms + .MIT, .BSD_2_Clause, .BSD_3_Clause, .ISC, .@"0BSD" => switch (new_license.getCategory()) { + .Permissive, .PublicDomain, .WeakCopyleft, .StrongCopyleft => true, + .Specialized => false, // Font licenses are specialized + }, + + // Apache-2.0 has patent grant issues with GPL-2.0 + .Apache_2_0 => switch (new_license) { + .GPL_2_0 => false, // Known incompatibility + else => switch (new_license.getCategory()) { + .Permissive, .PublicDomain => true, + .WeakCopyleft => true, + .StrongCopyleft => new_license == .GPL_3_0 or new_license == .AGPL_3_0, + .Specialized => false, + }, + }, + + // Strong copyleft licenses require derivative works to be under compatible copyleft + .GPL_3_0 => switch (new_license) { + .GPL_3_0, .AGPL_3_0 => true, + else => false, + }, + + .GPL_2_0 => switch (new_license) { + .GPL_2_0, .GPL_3_0 => true, + else => false, + }, + + .AGPL_3_0 => switch (new_license) { + .AGPL_3_0 => true, + else => false, + }, + + // LGPL allows linking with other software + .LGPL_3_0 => switch (new_license.getCategory()) { + .Permissive, .PublicDomain, .WeakCopyleft => true, + .StrongCopyleft => new_license == .GPL_3_0 or new_license == .AGPL_3_0, + .Specialized => false, + }, + + // Weak copyleft licenses are file-level copyleft + .MPL_2_0 => switch (new_license.getCategory()) { + .Permissive, .PublicDomain, .WeakCopyleft => true, + .StrongCopyleft => new_license == .GPL_3_0 or new_license == .AGPL_3_0, + .Specialized => false, + }, + + .EPL_2_0, .EPL_1_0 => switch (new_license.getCategory()) { + .Permissive, .PublicDomain, .WeakCopyleft => true, + .StrongCopyleft => false, + .Specialized => false, + }, + + .Unlicense => true, // Public domain + + .OFL_1_1 => switch (new_license) { + .OFL_1_1 => true, + else => false, + }, + }; +} + +/// Find all compatible licenses given a list of existing licenses +/// Allocates and returns a list of compatible licenses +pub fn findCompatibleLicenses(allocator: std.mem.Allocator, existing_licenses: []const License) ![]const License { + var compatible = try std.ArrayList(License).initCapacity(allocator, 0); + defer compatible.deinit(allocator); + + // All possible licenses to check + const all_licenses = [_]License{ + .MIT, + .BSD_2_Clause, + .BSD_3_Clause, + .Apache_2_0, + .@"0BSD", + .ISC, + .AGPL_3_0, + .GPL_3_0, + .GPL_2_0, + .LGPL_3_0, + .MPL_2_0, + .EPL_2_0, + .EPL_1_0, + .Unlicense, + .OFL_1_1, + }; + + // Check each possible license against all existing licenses + for (all_licenses) |candidate| { + var is_compatible_with_all = true; + for (existing_licenses) |existing| { + if (!isCompatible(existing, candidate)) { + is_compatible_with_all = false; + break; + } + } + + if (is_compatible_with_all) { + try compatible.append(allocator, candidate); + } + } + + return compatible.toOwnedSlice(allocator); +} + +/// Get a brief explanation of why licenses are compatible or not +pub fn getCompatibilityReason(existing: License, new_license: License) []const u8 { + if (existing == new_license) { + return "Same license - fully compatible"; + } + + const compatible = isCompatible(existing, new_license); + + if (compatible) { + return switch (existing.getCategory()) { + .PublicDomain => "Public domain allows any license", + .Permissive => "Permissive licenses are broadly compatible", + .WeakCopyleft => "Weak copyleft allows compatible combinations", + .StrongCopyleft => "Strong copyleft requires compatible copyleft license", + .Specialized => "Specialized licenses have specific rules", + }; + } else { + if (existing == .Apache_2_0 and new_license == .GPL_2_0) { + return "Apache-2.0 and GPL-2.0 have patent grant incompatibility"; + } + return switch (existing.getCategory()) { + .StrongCopyleft => "Strong copyleft requires derivative under same/compatible license", + .Specialized => "Specialized licenses restrict combination with other types", + else => "License terms are incompatible", + }; + } +} diff --git a/src/compatibility_test.zig b/src/compatibility_test.zig new file mode 100644 index 0000000..dded970 --- /dev/null +++ b/src/compatibility_test.zig @@ -0,0 +1,187 @@ +const std = @import("std"); +const testing = std.testing; +const compatibility = @import("compatibility.zig"); + +test "License fromString and toString roundtrip" { + const licenses = [_][]const u8{ + "MIT", + "BSD-2-Clause", + "Apache-2.0", + "GPL-3.0", + "LGPL-3.0", + }; + + for (licenses) |license_str| { + const lic = compatibility.License.fromString(license_str); + try testing.expect(lic != null); + try testing.expectEqualStrings(license_str, lic.?.toString()); + } +} + +test "License fromString returns null for invalid input" { + try testing.expect(compatibility.License.fromString("Invalid") == null); + try testing.expect(compatibility.License.fromString("") == null); +} + +test "License fromString is case-insensitive" { + try testing.expect(compatibility.License.fromString("mit") != null); + try testing.expect(compatibility.License.fromString("MIT") != null); + try testing.expect(compatibility.License.fromString("Mit") != null); + try testing.expectEqual(compatibility.License.MIT, compatibility.License.fromString("mit").?); + try testing.expectEqual(compatibility.License.Apache_2_0, compatibility.License.fromString("apache-2.0").?); +} + +test "getCategory returns correct categories" { + try testing.expect(compatibility.License.MIT.getCategory() == .Permissive); + try testing.expect(compatibility.License.Apache_2_0.getCategory() == .Permissive); + try testing.expect(compatibility.License.GPL_3_0.getCategory() == .StrongCopyleft); + try testing.expect(compatibility.License.AGPL_3_0.getCategory() == .StrongCopyleft); + try testing.expect(compatibility.License.LGPL_3_0.getCategory() == .WeakCopyleft); + try testing.expect(compatibility.License.MPL_2_0.getCategory() == .WeakCopyleft); + try testing.expect(compatibility.License.Unlicense.getCategory() == .PublicDomain); + try testing.expect(compatibility.License.OFL_1_1.getCategory() == .Specialized); +} + +test "same license is always compatible" { + try testing.expect(compatibility.isCompatible(.MIT, .MIT)); + try testing.expect(compatibility.isCompatible(.GPL_3_0, .GPL_3_0)); + try testing.expect(compatibility.isCompatible(.Apache_2_0, .Apache_2_0)); +} + +test "Unlicense is compatible with everything" { + try testing.expect(compatibility.isCompatible(.Unlicense, .MIT)); + try testing.expect(compatibility.isCompatible(.Unlicense, .GPL_3_0)); + try testing.expect(compatibility.isCompatible(.Unlicense, .Apache_2_0)); + try testing.expect(compatibility.isCompatible(.Unlicense, .LGPL_3_0)); +} + +test "MIT is broadly compatible" { + try testing.expect(compatibility.isCompatible(.MIT, .MIT)); + try testing.expect(compatibility.isCompatible(.MIT, .Apache_2_0)); + try testing.expect(compatibility.isCompatible(.MIT, .GPL_3_0)); + try testing.expect(compatibility.isCompatible(.MIT, .BSD_3_Clause)); + try testing.expect(!compatibility.isCompatible(.MIT, .OFL_1_1)); // Font license is specialized +} + +test "Apache-2.0 and GPL-2.0 are incompatible" { + try testing.expect(!compatibility.isCompatible(.Apache_2_0, .GPL_2_0)); +} + +test "Apache-2.0 is compatible with GPL-3.0" { + try testing.expect(compatibility.isCompatible(.Apache_2_0, .GPL_3_0)); +} + +test "GPL-3.0 is restrictive in compatibility" { + try testing.expect(compatibility.isCompatible(.GPL_3_0, .GPL_3_0)); + try testing.expect(compatibility.isCompatible(.GPL_3_0, .AGPL_3_0)); + try testing.expect(!compatibility.isCompatible(.GPL_3_0, .MIT)); + try testing.expect(!compatibility.isCompatible(.GPL_3_0, .Apache_2_0)); + try testing.expect(!compatibility.isCompatible(.GPL_3_0, .LGPL_3_0)); +} + +test "AGPL-3.0 is most restrictive" { + try testing.expect(compatibility.isCompatible(.AGPL_3_0, .AGPL_3_0)); + try testing.expect(!compatibility.isCompatible(.AGPL_3_0, .GPL_3_0)); + try testing.expect(!compatibility.isCompatible(.AGPL_3_0, .MIT)); +} + +test "LGPL-3.0 allows more combinations" { + try testing.expect(compatibility.isCompatible(.LGPL_3_0, .MIT)); + try testing.expect(compatibility.isCompatible(.LGPL_3_0, .Apache_2_0)); + try testing.expect(compatibility.isCompatible(.LGPL_3_0, .GPL_3_0)); + try testing.expect(compatibility.isCompatible(.LGPL_3_0, .MPL_2_0)); +} + +test "MPL-2.0 weak copyleft compatibility" { + try testing.expect(compatibility.isCompatible(.MPL_2_0, .MIT)); + try testing.expect(compatibility.isCompatible(.MPL_2_0, .Apache_2_0)); + try testing.expect(compatibility.isCompatible(.MPL_2_0, .GPL_3_0)); + try testing.expect(!compatibility.isCompatible(.MPL_2_0, .OFL_1_1)); +} + +test "OFL-1.1 is specialized and restrictive" { + try testing.expect(compatibility.isCompatible(.OFL_1_1, .OFL_1_1)); + try testing.expect(!compatibility.isCompatible(.OFL_1_1, .MIT)); + try testing.expect(!compatibility.isCompatible(.OFL_1_1, .GPL_3_0)); +} + +test "findCompatibleLicenses with single MIT license" { + const allocator = testing.allocator; + const existing = [_]compatibility.License{.MIT}; + + const compatible = try compatibility.findCompatibleLicenses(allocator, &existing); + defer allocator.free(compatible); + + // MIT should allow many licenses + try testing.expect(compatible.len > 0); + + // Should include common permissive licenses + var found_mit = false; + var found_apache = false; + var found_gpl3 = false; + + for (compatible) |lic| { + if (lic == .MIT) found_mit = true; + if (lic == .Apache_2_0) found_apache = true; + if (lic == .GPL_3_0) found_gpl3 = true; + } + + try testing.expect(found_mit); + try testing.expect(found_apache); + try testing.expect(found_gpl3); +} + +test "findCompatibleLicenses with GPL-3.0" { + const allocator = testing.allocator; + const existing = [_]compatibility.License{.GPL_3_0}; + + const compatible = try compatibility.findCompatibleLicenses(allocator, &existing); + defer allocator.free(compatible); + + // GPL-3.0 is restrictive, should only allow GPL-3.0 and AGPL-3.0 + try testing.expect(compatible.len == 2); + + var found_gpl3 = false; + var found_agpl3 = false; + + for (compatible) |lic| { + if (lic == .GPL_3_0) found_gpl3 = true; + if (lic == .AGPL_3_0) found_agpl3 = true; + } + + try testing.expect(found_gpl3); + try testing.expect(found_agpl3); +} + +test "findCompatibleLicenses with MIT and Apache-2.0" { + const allocator = testing.allocator; + const existing = [_]compatibility.License{ .MIT, .Apache_2_0 }; + + const compatible = try compatibility.findCompatibleLicenses(allocator, &existing); + defer allocator.free(compatible); + + // Both are permissive, should allow many licenses + try testing.expect(compatible.len > 0); +} + +test "findCompatibleLicenses with conflicting licenses" { + const allocator = testing.allocator; + const existing = [_]compatibility.License{ .GPL_3_0, .OFL_1_1 }; + + const compatible = try compatibility.findCompatibleLicenses(allocator, &existing); + defer allocator.free(compatible); + + // These are incompatible, should find no compatible licenses + try testing.expect(compatible.len == 0); +} + +test "getCompatibilityReason returns non-empty string" { + const reason1 = compatibility.getCompatibilityReason(.MIT, .MIT); + try testing.expect(reason1.len > 0); + + const reason2 = compatibility.getCompatibilityReason(.MIT, .GPL_3_0); + try testing.expect(reason2.len > 0); + + const reason3 = compatibility.getCompatibilityReason(.Apache_2_0, .GPL_2_0); + try testing.expect(reason3.len > 0); +} diff --git a/src/main.zig b/src/main.zig index 1f34e7f..0943e0e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,341 +1,206 @@ const std = @import("std"); -const io = std.io; const mem = std.mem; +const tree = @import("tree.zig"); +const compatibility = @import("compatibility.zig"); -// Node types in the decision tree -const NodeType = enum { - Question, - Answer, -}; - -const Node = struct { - node_type: NodeType, - content: []const u8, - elaboration: []const u8, - yes: ?*const Node, - no: ?*const Node, -}; - -// Decision tree structure matching the original JavaScript logic -const decision_tree = buildDecisionTree(); - -fn buildDecisionTree() Node { - // Leaf nodes (answers) - const mit_node = Node{ - .node_type = .Answer, - .content = "MIT", - .elaboration = "", - .yes = null, - .no = null, - }; - - const bsd_2_clause = Node{ - .node_type = .Answer, - .content = "BSD-2-Clause", - .elaboration = "", - .yes = null, - .no = null, - }; - - const apache_2_0 = Node{ - .node_type = .Answer, - .content = "Apache-2.0", - .elaboration = "", - .yes = null, - .no = null, - }; - - const bsd_3_clause = Node{ - .node_type = .Answer, - .content = "BSD-3-Clause", - .elaboration = "", - .yes = null, - .no = null, - }; - - const zero_bsd = Node{ - .node_type = .Answer, - .content = "0BSD", - .elaboration = "", - .yes = null, - .no = null, - }; - - const isc = Node{ - .node_type = .Answer, - .content = "ISC", - .elaboration = "", - .yes = null, - .no = null, - }; - - const agpl_3_0 = Node{ - .node_type = .Answer, - .content = "AGPL-3.0", - .elaboration = "", - .yes = null, - .no = null, - }; - - const gpl_3_0 = Node{ - .node_type = .Answer, - .content = "GPL-3.0", - .elaboration = "", - .yes = null, - .no = null, - }; - - const gpl_2_0 = Node{ - .node_type = .Answer, - .content = "GPL-2.0", - .elaboration = "", - .yes = null, - .no = null, - }; - - const lgpl_3_0 = Node{ - .node_type = .Answer, - .content = "LGPL-3.0", - .elaboration = "", - .yes = null, - .no = null, - }; - - const mpl_2_0 = Node{ - .node_type = .Answer, - .content = "MPL-2.0", - .elaboration = "", - .yes = null, - .no = null, - }; - - const epl_2_0 = Node{ - .node_type = .Answer, - .content = "EPL-2.0", - .elaboration = "", - .yes = null, - .no = null, - }; - - const epl_1_0 = Node{ - .node_type = .Answer, - .content = "EPL-1.0", - .elaboration = "", - .yes = null, - .no = null, - }; - - const unlicense = Node{ - .node_type = .Answer, - .content = "Unlicense", - .elaboration = "", - .yes = null, - .no = null, - }; - - const ofl_1_1 = Node{ - .node_type = .Answer, - .content = "OFL-1.1", - .elaboration = "", - .yes = null, - .no = null, - }; - - const proprietary = Node{ - .node_type = .Answer, - .content = "Consider using a proprietary license or another specialized license.", - .elaboration = "You should consider using a proprietary license or another specialized license by consulting with a legal expert.", - .yes = null, - .no = null, - }; - - const closed_source = Node{ - .node_type = .Answer, - .content = "You should consider keeping your project closed-source.", - .elaboration = "You should consider keeping your project closed-source to protect your intellectual property by not sharing the source code.", - .yes = null, - .no = null, - }; - - // Build tree from bottom up - const simplest_permissive = Node{ - .node_type = .Question, - .content = "Do you want the simplest and most permissive license possible?", - .elaboration = "Do you want a license that is very permissive and has minimal requirements, making it as simple as possible for others to use your code.", - .yes = &mit_node, - .no = &bsd_2_clause, - }; - - const simplest_no_conditions = Node{ - .node_type = .Question, - .content = "Do you want the simplest permissive license with no conditions?", - .elaboration = "Do you want the simplest permissive license with no conditions, providing complete freedom to use the code without any restrictions.", - .yes = &zero_bsd, - .no = &isc, - }; - - const permissive_with_conditions = Node{ - .node_type = .Question, - .content = "Do you want a permissive license with some conditions?", - .elaboration = "Do you want a permissive license that includes some conditions such as providing attribution and not using the name of the project or its contributors for promotion without permission.", - .yes = &bsd_3_clause, - .no = &simplest_no_conditions, - }; - - const explicit_patent_grants = Node{ - .node_type = .Question, - .content = "Do you want explicit patent grants?", - .elaboration = "Do you want to include explicit grants of patent rights, which can protect users from patent litigation. This is an important consideration for projects that may involve patented technology.", - .yes = &apache_2_0, - .no = &permissive_with_conditions, - }; - - const minimal_conditions = Node{ - .node_type = .Question, - .content = "Do you require minimal conditions?", - .elaboration = "Do you want minimal conditions meaning that there are very few requirements placed on the use of your code. This typically includes providing attribution to the original authors.", - .yes = &simplest_permissive, - .no = &explicit_patent_grants, - }; - - const latest_gpl = Node{ - .node_type = .Question, - .content = "Do you want to use the latest version of the GPL license?", - .elaboration = "Do you prefer using the latest version of the GPL license, which includes additional protections and clarifications compared to older versions.", - .yes = &gpl_3_0, - .no = &gpl_2_0, - }; - - const network_server_protection = Node{ - .node_type = .Question, - .content = "Do you want network server protection?", - .elaboration = "Do you want to extend copyleft requirements to software provided over a network. This means that users who interact with the software over a network (e.g., web applications) must also have access to the source code.", - .yes = &agpl_3_0, - .no = &latest_gpl, - }; - - const business_friendly_copyleft = Node{ - .node_type = .Question, - .content = "Do you prefer a copyleft license with a focus on business-friendly terms?", - .elaboration = "Do you prefer a copyleft license that has a focus on business-friendly terms, making it easier for companies to adopt.", - .yes = &epl_2_0, - .no = &epl_1_0, - }; - - const weaker_copyleft = Node{ - .node_type = .Question, - .content = "Do you want a copyleft license with weaker requirements?", - .elaboration = "Do you want a copyleft license that has weaker requirements compared to the GPL, such as allowing proprietary modules in your project.", - .yes = &mpl_2_0, - .no = &business_friendly_copyleft, - }; - - const allow_linking = Node{ - .node_type = .Question, - .content = "Do you want to allow linking with non-(L)GPL software?", - .elaboration = "Do you want to allow linking with non-(L)GPL software, making it easier to use your code as a library in proprietary software while keeping modifications to the library itself open source.", - .yes = &lgpl_3_0, - .no = &weaker_copyleft, - }; - - const strong_copyleft = Node{ - .node_type = .Question, - .content = "Do you want strong copyleft?", - .elaboration = "Do you want to require that any distributed modifications (or in some cases, software that interacts with the copylefted code) also be open-sourced. This ensures that improvements to the code are shared with the community.", - .yes = &network_server_protection, - .no = &allow_linking, - }; - - const font_license = Node{ - .node_type = .Question, - .content = "Do you want a license for fonts?", - .elaboration = "Do you want a license specifically designed for fonts, allowing embedding, modifying, and redistributing the font.", - .yes = &ofl_1_1, - .no = &proprietary, - }; - - const public_domain = Node{ - .node_type = .Question, - .content = "Do you want to dedicate your work to the public domain?", - .elaboration = "Do you want to dedicate your work to the public domain, allowing anyone to use, modify, and distribute your work without any restrictions.", - .yes = &unlicense, - .no = &font_license, - }; - - const copyleft_license = Node{ - .node_type = .Question, - .content = "Do you want a copyleft license?", - .elaboration = "Do you want to require that any modified versions of your code be distributed under the same license, ensuring that the code (and its derivatives) remain open source.", - .yes = &strong_copyleft, - .no = &public_domain, - }; - - const permissive_license = Node{ - .node_type = .Question, - .content = "Do you want a permissive license?", - .elaboration = "Do you want a more lenient license and allow others to use, modify, and distribute your code with minimal restrictions. These licenses are generally business-friendly and encourage wider use.", - .yes = &minimal_conditions, - .no = ©left_license, - }; - - const root = Node{ - .node_type = .Question, - .content = "Do you want to open-source your project?", - .elaboration = "Do you want to make your source code publicly available and allow others to use, modify, and distribute it.", - .yes = &permissive_license, - .no = &closed_source, - }; - - return root; -} +const Node = tree.Node; +const NodeType = tree.NodeType; +const decision_tree = tree.decision_tree; const History = std.ArrayList(*const Node); +const BOLD = "\x1b[1m"; +const ITALIC = "\x1b[3m"; +const DIM = "\x1b[2m"; +const UNDERLINE = "\x1b[4m"; +const RESET = "\x1b[0m"; + pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); const allocator = gpa.allocator(); - const stdout = io.getStdOut().writer(); - const stdin = io.getStdIn().reader(); + const stdout = std.fs.File.stdout().deprecatedWriter(); + + // Check for command-line arguments + const args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, args); + + // Check if compatibility mode is requested via flag (`--compat` or `-c`) + var is_compat: bool = false; + for (args[1..]) |a| { + if (mem.eql(u8, a, "--compat") or mem.eql(u8, a, "-c")) { + is_compat = true; + break; + } + } + + if (is_compat) { + try runCompatibilityMode(allocator, stdout); + return; + } + + // Run normal decision tree mode + try runDecisionTree(allocator, stdout); +} + +fn runCompatibilityMode(allocator: std.mem.Allocator, stdout: anytype) !void { + const stdin = std.fs.File.stdin().deprecatedReader(); + + try stdout.print("\n=== {s}License Compatibility Checker{s} ===\n", .{ BOLD, RESET }); + try stdout.print("This tool helps you find compatible licenses when forking or combining projects.\n\n", .{}); + try stdout.print("Enter the licenses of the projects you want to combine (comma-separated).\n", .{}); + try stdout.print("Example: MIT, Apache-2.0, BSD-3-Clause\n\n", .{}); + try stdout.print("Supported licenses:\n", .{}); + try stdout.print(" Permissive: MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, 0BSD, ISC\n", .{}); + try stdout.print(" Copyleft: GPL-2.0, GPL-3.0, AGPL-3.0, LGPL-3.0\n", .{}); + try stdout.print(" Weak Copyleft: MPL-2.0, EPL-2.0, EPL-1.0\n", .{}); + try stdout.print(" Other: Unlicense, OFL-1.1\n\n", .{}); + try stdout.print("Enter licenses (or 'quit' to exit): ", .{}); + + var buf: [1024]u8 = undefined; + + while (true) { + const line = (try stdin.readUntilDelimiterOrEof(&buf, '\n')) orelse break; + const trimmed = mem.trim(u8, line, &std.ascii.whitespace); + + if (trimmed.len == 0) { + try stdout.print("\nEnter licenses (or 'quit' to exit): ", .{}); + continue; + } + + if (mem.eql(u8, trimmed, "quit") or mem.eql(u8, trimmed, "q")) { + try stdout.print("\nThank you for using the compatibility checker of Repolicense\n", .{}); + break; + } + + // Parse comma-separated licenses + var licenses = try std.ArrayList(compatibility.License).initCapacity(allocator, 0); + defer licenses.deinit(allocator); + + var iter = std.mem.tokenizeAny(u8, trimmed, ","); + var has_error = false; + + while (iter.next()) |license_str| { + const clean = mem.trim(u8, license_str, &std.ascii.whitespace); + if (clean.len == 0) continue; + + if (compatibility.License.fromString(clean)) |lic| { + try licenses.append(allocator, lic); + } else { + try stdout.print("\nError: Unknown license '{s}'\n", .{clean}); + has_error = true; + break; + } + } + + if (has_error) { + try stdout.print("\nEnter licenses (or 'quit' to exit): ", .{}); + continue; + } + + if (licenses.items.len == 0) { + try stdout.print("\nError: No valid licenses entered\n", .{}); + try stdout.print("\nEnter licenses (or 'quit' to exit): ", .{}); + continue; + } + + // Find compatible licenses + const compatible_licenses = try compatibility.findCompatibleLicenses(allocator, licenses.items); + defer allocator.free(compatible_licenses); + + try stdout.print("\n--- {s}Results{s} ---\n", .{ BOLD, RESET }); + try stdout.print("Given licenses: ", .{}); + for (licenses.items, 0..) |lic, i| { + if (i > 0) try stdout.print(", ", .{}); + try stdout.print("{s}", .{lic.toString()}); + } + try stdout.print("\n\n", .{}); + + if (compatible_licenses.len == 0) { + try stdout.print("No compatible licenses found!\n", .{}); + try stdout.print("The licenses you selected have conflicting requirements.\n\n", .{}); + } else { + try stdout.print("You can use any of these licenses for your combined work:\n\n", .{}); + for (compatible_licenses) |lic| { + try stdout.print(" • {s}\n", .{lic.toString()}); + } + try stdout.print("\n", .{}); + } + + // Show pairwise compatibility details for better understanding + if (licenses.items.len > 1) { + try stdout.print("Compatibility details:\n", .{}); + var i: usize = 0; + while (i < licenses.items.len - 1) : (i += 1) { + var j: usize = i + 1; + while (j < licenses.items.len) : (j += 1) { + const compat = compatibility.isCompatible(licenses.items[i], licenses.items[j]); + const reason = compatibility.getCompatibilityReason(licenses.items[i], licenses.items[j]); + const symbol = if (compat) "✓" else "✗"; + try stdout.print(" {s} {s} + {s}: {s}\n", .{ + symbol, + licenses.items[i].toString(), + licenses.items[j].toString(), + reason, + }); + } + } + try stdout.print("\n", .{}); + } + + try stdout.print("Enter licenses (or 'quit' to exit): ", .{}); + } +} + +fn runDecisionTree(allocator: std.mem.Allocator, stdout: anytype) !void { + const stdin = std.fs.File.stdin().deprecatedReader(); + const ui = @import("ui.zig"); - var history = History.init(allocator); - defer history.deinit(); + var screen = ui.Screen.init(); + + var history = try History.initCapacity(allocator, 0); + defer history.deinit(allocator); var current_node = &decision_tree; - try history.append(current_node); + try history.append(allocator, current_node); - // Print welcome message - try stdout.print("\n=== Repolicense CLI ===\n", .{}); - try stdout.print("Welcome to Repolicense! This tool helps you choose the right open-source license.\n", .{}); - try stdout.print("Answer the questions with 'yes', 'no', 'back', 'reset', or 'quit'.\n\n", .{}); + // Print welcome message (kept as regular prints so it doesn't get cleared) + try stdout.print("\n=== {s}Repolicense CLI{s} ===\n", .{ BOLD, RESET }); + try stdout.print("Answer the questions with 'yes', 'no', 'back', 'reset', or 'quit'\n to find the best license for your project.\n", .{}); + try stdout.print("\nRun with {s}'--compat'{s} flag (or {s}'-c'{s}) to check license compatibility for forking projects.\n\n", .{ UNDERLINE, RESET, UNDERLINE, RESET }); var buf: [256]u8 = undefined; while (true) { if (current_node.node_type == .Question) { - try stdout.print("\n--- Question ---\n", .{}); - try stdout.print("{s}\n", .{current_node.content}); - if (current_node.elaboration.len > 0) { - try stdout.print("\nElaboration: {s}\n", .{current_node.elaboration}); - } - try stdout.print("\nYour answer (yes/no/back/reset/quit): ", .{}); + const content = if (current_node.elaboration.len > 0) + try std.fmt.allocPrint(allocator, "\n--- {s}Question{s} ---\n{s}\n\n{s}Elaboration: {s}\n{s}\nYour answer (yes/no/back/reset/quit): ", .{ ITALIC, RESET, current_node.content, DIM, current_node.elaboration, RESET }) + else + try std.fmt.allocPrint(allocator, "\n--- {s}Question{s} ---\n{s}\nYour answer (yes/no/back/reset/quit): ", .{ ITALIC, RESET, current_node.content }); + + try screen.render(stdout, content); + allocator.free(content); } else { - try stdout.print("\n=== RESULT ===\n", .{}); - try stdout.print("License: {s}\n", .{current_node.content}); - if (current_node.elaboration.len > 0) { - try stdout.print("\n{s}\n", .{current_node.elaboration}); - } - - // For official licenses, provide GitHub API link - if (!mem.startsWith(u8, current_node.content, "Consider") and - !mem.startsWith(u8, current_node.content, "You should")) { - try stdout.print("\nFor more information, visit:\n", .{}); - try stdout.print("https://api.github.com/licenses/{s}\n", .{current_node.content}); - try stdout.print("https://docs.github.com/en/rest/licenses/licenses\n", .{}); + var content = if (current_node.elaboration.len > 0) + try std.fmt.allocPrint(allocator, "\n--- {s}RESULT{s} ---\nLicense: {s}{s}{s}\n\n{s}{s}{s}\n", .{ BOLD, RESET, BOLD, current_node.content, RESET, DIM, current_node.elaboration, RESET }) + else + try std.fmt.allocPrint(allocator, "\n--- {s}RESULT{s} ---\nLicense: {s}{s}{s}\n", .{ BOLD, RESET, BOLD, current_node.content, RESET }); + + if (!mem.startsWith(u8, current_node.content, "Consider") and !mem.startsWith(u8, current_node.content, "You should")) { + const links = try std.fmt.allocPrint(allocator, "\nFor more information, visit:\nhttps://api.github.com/licenses/{s}\nhttps://docs.github.com/en/rest/licenses/licenses\n", .{current_node.content}); + const combined = try std.fmt.allocPrint(allocator, "{s}{s}", .{ content, links }); + allocator.free(content); + allocator.free(links); + content = combined; } - - try stdout.print("\nOptions: back/reset/quit: ", .{}); + + const content_with_options = try std.fmt.allocPrint(allocator, "{s}\nOptions: back/reset/quit: ", .{content}); + allocator.free(content); + content = content_with_options; + + try screen.render(stdout, content); + allocator.free(content); } const line = (try stdin.readUntilDelimiterOrEof(&buf, '\n')) orelse break; @@ -343,38 +208,44 @@ pub fn main() !void { const lower = try std.ascii.allocLowerString(allocator, trimmed); defer allocator.free(lower); + // Clear previous rendered block and the input line the user just entered + try screen.clearIncludingInput(stdout); + if (mem.eql(u8, lower, "quit") or mem.eql(u8, lower, "q") or mem.eql(u8, lower, "exit")) { - try stdout.print("\nThank you for using Repolicense!\n", .{}); + // Clear last rendered block before exiting + try screen.clear(stdout); + try stdout.print("\nThank you for using Repolicense\n", .{}); break; } else if (mem.eql(u8, lower, "back") or mem.eql(u8, lower, "b")) { if (history.items.len > 1) { _ = history.pop(); current_node = history.items[history.items.len - 1]; - try stdout.print("\n[Moved back]\n", .{}); + // Show a short status message inside the same screen block so it will be overwritten next render + try screen.render(stdout, "[Moved back]\n"); } else { - try stdout.print("\n[Already at the beginning]\n", .{}); + try screen.render(stdout, "[Already at the beginning]\n"); } } else if (mem.eql(u8, lower, "reset") or mem.eql(u8, lower, "r")) { history.clearRetainingCapacity(); current_node = &decision_tree; - try history.append(current_node); - try stdout.print("\n[Reset to beginning]\n", .{}); + try history.append(allocator, current_node); + try screen.render(stdout, "[Reset to beginning]\n"); } else if (current_node.node_type == .Question) { if (mem.eql(u8, lower, "yes") or mem.eql(u8, lower, "y")) { if (current_node.yes) |next_node| { current_node = next_node; - try history.append(current_node); + try history.append(allocator, current_node); } } else if (mem.eql(u8, lower, "no") or mem.eql(u8, lower, "n")) { if (current_node.no) |next_node| { current_node = next_node; - try history.append(current_node); + try history.append(allocator, current_node); } } else { - try stdout.print("\n[Invalid input. Please enter yes, no, back, reset, or quit]\n", .{}); + try screen.render(stdout, "[Invalid input. Please enter yes, no, back, reset, or quit]\n"); } } else { - try stdout.print("\n[Invalid command. Use back, reset, or quit]\n", .{}); + try screen.render(stdout, "[Invalid command. Use back, reset, or quit]\n"); } } } diff --git a/src/tree.zig b/src/tree.zig new file mode 100644 index 0000000..7b9c959 --- /dev/null +++ b/src/tree.zig @@ -0,0 +1,288 @@ +const std = @import("std"); + +// Node types in the decision tree +pub const NodeType = enum { + Question, + Answer, +}; + +pub const Node = struct { + node_type: NodeType, + content: []const u8, + elaboration: []const u8, + yes: ?*const Node, + no: ?*const Node, +}; + +// Decision tree structure matching the original JavaScript logic +pub const decision_tree = buildDecisionTree(); + +fn buildDecisionTree() Node { + // Leaf nodes (answers) + const mit_node = Node{ + .node_type = .Answer, + .content = "MIT", + .elaboration = "", + .yes = null, + .no = null, + }; + + const bsd_2_clause = Node{ + .node_type = .Answer, + .content = "BSD-2-Clause", + .elaboration = "", + .yes = null, + .no = null, + }; + + const apache_2_0 = Node{ + .node_type = .Answer, + .content = "Apache-2.0", + .elaboration = "", + .yes = null, + .no = null, + }; + + const bsd_3_clause = Node{ + .node_type = .Answer, + .content = "BSD-3-Clause", + .elaboration = "", + .yes = null, + .no = null, + }; + + const zero_bsd = Node{ + .node_type = .Answer, + .content = "0BSD", + .elaboration = "", + .yes = null, + .no = null, + }; + + const isc = Node{ + .node_type = .Answer, + .content = "ISC", + .elaboration = "", + .yes = null, + .no = null, + }; + + const agpl_3_0 = Node{ + .node_type = .Answer, + .content = "AGPL-3.0", + .elaboration = "", + .yes = null, + .no = null, + }; + + const gpl_3_0 = Node{ + .node_type = .Answer, + .content = "GPL-3.0", + .elaboration = "", + .yes = null, + .no = null, + }; + + const gpl_2_0 = Node{ + .node_type = .Answer, + .content = "GPL-2.0", + .elaboration = "", + .yes = null, + .no = null, + }; + + const lgpl_3_0 = Node{ + .node_type = .Answer, + .content = "LGPL-3.0", + .elaboration = "", + .yes = null, + .no = null, + }; + + const mpl_2_0 = Node{ + .node_type = .Answer, + .content = "MPL-2.0", + .elaboration = "", + .yes = null, + .no = null, + }; + + const epl_2_0 = Node{ + .node_type = .Answer, + .content = "EPL-2.0", + .elaboration = "", + .yes = null, + .no = null, + }; + + const epl_1_0 = Node{ + .node_type = .Answer, + .content = "EPL-1.0", + .elaboration = "", + .yes = null, + .no = null, + }; + + const unlicense = Node{ + .node_type = .Answer, + .content = "Unlicense", + .elaboration = "", + .yes = null, + .no = null, + }; + + const ofl_1_1 = Node{ + .node_type = .Answer, + .content = "OFL-1.1", + .elaboration = "", + .yes = null, + .no = null, + }; + + const proprietary = Node{ + .node_type = .Answer, + .content = "Consider using a proprietary license or another specialized license.", + .elaboration = "You should consider using a proprietary license or another specialized license by consulting with a legal expert.", + .yes = null, + .no = null, + }; + + const closed_source = Node{ + .node_type = .Answer, + .content = "You should consider keeping your project closed-source.", + .elaboration = "You should consider keeping your project closed-source to protect your intellectual property by not sharing the source code.", + .yes = null, + .no = null, + }; + + // Build tree from bottom up + const simplest_permissive = Node{ + .node_type = .Question, + .content = "Do you want the simplest and most permissive license possible?", + .elaboration = "Do you want a license that is very permissive and has minimal requirements, making it as simple as possible for others to use your code.", + .yes = &mit_node, + .no = &bsd_2_clause, + }; + + const simplest_no_conditions = Node{ + .node_type = .Question, + .content = "Do you want the simplest permissive license with no conditions?", + .elaboration = "Do you want the simplest permissive license with no conditions, providing complete freedom to use the code without any restrictions.", + .yes = &zero_bsd, + .no = &isc, + }; + + const permissive_with_conditions = Node{ + .node_type = .Question, + .content = "Do you want a permissive license with some conditions?", + .elaboration = "Do you want a permissive license that includes some conditions such as providing attribution and not using the name of the project or its contributors for promotion without permission.", + .yes = &bsd_3_clause, + .no = &simplest_no_conditions, + }; + + const explicit_patent_grants = Node{ + .node_type = .Question, + .content = "Do you want explicit patent grants?", + .elaboration = "Do you want to include explicit grants of patent rights, which can protect users from patent litigation. This is an important consideration for projects that may involve patented technology.", + .yes = &apache_2_0, + .no = &permissive_with_conditions, + }; + + const minimal_conditions = Node{ + .node_type = .Question, + .content = "Do you require minimal conditions?", + .elaboration = "Do you want minimal conditions meaning that there are very few requirements placed on the use of your code. This typically includes providing attribution to the original authors.", + .yes = &simplest_permissive, + .no = &explicit_patent_grants, + }; + + const latest_gpl = Node{ + .node_type = .Question, + .content = "Do you want to use the latest version of the GPL license?", + .elaboration = "Do you prefer using the latest version of the GPL license, which includes additional protections and clarifications compared to older versions.", + .yes = &gpl_3_0, + .no = &gpl_2_0, + }; + + const network_server_protection = Node{ + .node_type = .Question, + .content = "Do you want network server protection?", + .elaboration = "Do you want to extend copyleft requirements to software provided over a network. This means that users who interact with the software over a network (e.g., web applications) must also have access to the source code.", + .yes = &agpl_3_0, + .no = &latest_gpl, + }; + + const business_friendly_copyleft = Node{ + .node_type = .Question, + .content = "Do you prefer a copyleft license with a focus on business-friendly terms?", + .elaboration = "Do you prefer a copyleft license that has a focus on business-friendly terms, making it easier for companies to adopt.", + .yes = &epl_2_0, + .no = &epl_1_0, + }; + + const weaker_copyleft = Node{ + .node_type = .Question, + .content = "Do you want a copyleft license with weaker requirements?", + .elaboration = "Do you want a copyleft license that has weaker requirements compared to the GPL, such as allowing proprietary modules in your project.", + .yes = &mpl_2_0, + .no = &business_friendly_copyleft, + }; + + const allow_linking = Node{ + .node_type = .Question, + .content = "Do you want to allow linking with non-(L)GPL software?", + .elaboration = "Do you want to allow linking with non-(L)GPL software, making it easier to use your code as a library in proprietary software while keeping modifications to the library itself open source.", + .yes = &lgpl_3_0, + .no = &weaker_copyleft, + }; + + const strong_copyleft = Node{ + .node_type = .Question, + .content = "Do you want strong copyleft?", + .elaboration = "Do you want to require that any distributed modifications (or in some cases, software that interacts with the copylefted code) also be open-sourced. This ensures that improvements to the code are shared with the community.", + .yes = &network_server_protection, + .no = &allow_linking, + }; + + const font_license = Node{ + .node_type = .Question, + .content = "Do you want a license for fonts?", + .elaboration = "Do you want a license specifically designed for fonts, allowing embedding, modifying, and redistributing the font.", + .yes = &ofl_1_1, + .no = &proprietary, + }; + + const public_domain = Node{ + .node_type = .Question, + .content = "Do you want to dedicate your work to the public domain?", + .elaboration = "Do you want to dedicate your work to the public domain, allowing anyone to use, modify, and distribute your work without any restrictions.", + .yes = &unlicense, + .no = &font_license, + }; + + const copyleft_license = Node{ + .node_type = .Question, + .content = "Do you want a copyleft license?", + .elaboration = "Do you want to require that any modified versions of your code be distributed under the same license, ensuring that the code (and its derivatives) remain open source.", + .yes = &strong_copyleft, + .no = &public_domain, + }; + + const permissive_license = Node{ + .node_type = .Question, + .content = "Do you want a permissive license?", + .elaboration = "Do you want a more lenient license and allow others to use, modify, and distribute your code with minimal restrictions. These licenses are generally business-friendly and encourage wider use.", + .yes = &minimal_conditions, + .no = ©left_license, + }; + + const root = Node{ + .node_type = .Question, + .content = "Do you want to open-source your project?", + .elaboration = "Do you want to make your source code publicly available and allow others to use, modify, and distribute it.", + .yes = &permissive_license, + .no = &closed_source, + }; + + return root; +} diff --git a/src/tree_test.zig b/src/tree_test.zig new file mode 100644 index 0000000..e9f344c --- /dev/null +++ b/src/tree_test.zig @@ -0,0 +1,114 @@ +const std = @import("std"); +const testing = std.testing; +const tree = @import("tree.zig"); + +test "decision tree root exists and is a question" { + try testing.expect(tree.decision_tree.node_type == .Question); + try testing.expect(tree.decision_tree.content.len > 0); +} + +test "decision tree root has both yes and no branches" { + try testing.expect(tree.decision_tree.yes != null); + try testing.expect(tree.decision_tree.no != null); +} + +test "all answer nodes have null yes/no pointers" { + // Test some known answer nodes by traversing the tree + const closed_source = tree.decision_tree.no.?; + try testing.expect(closed_source.node_type == .Answer); + try testing.expect(closed_source.yes == null); + try testing.expect(closed_source.no == null); +} + +test "MIT license is reachable from root" { + // Path: yes > yes > yes > yes + const permissive = tree.decision_tree.yes.?; + try testing.expect(permissive.node_type == .Question); + + const minimal = permissive.yes.?; + try testing.expect(minimal.node_type == .Question); + + const simplest = minimal.yes.?; + try testing.expect(simplest.node_type == .Question); + + const mit = simplest.yes.?; + try testing.expect(mit.node_type == .Answer); + try testing.expect(std.mem.eql(u8, mit.content, "MIT")); +} + +test "Apache-2.0 license is reachable from root" { + // Path: yes > yes > no > yes + const permissive = tree.decision_tree.yes.?; + const minimal = permissive.yes.?; + const explicit_patents = minimal.no.?; + try testing.expect(explicit_patents.node_type == .Question); + + const apache = explicit_patents.yes.?; + try testing.expect(apache.node_type == .Answer); + try testing.expect(std.mem.eql(u8, apache.content, "Apache-2.0")); +} + +test "GPL-3.0 license is reachable from root" { + // Path: yes > no > yes > yes > no > yes + const permissive = tree.decision_tree.yes.?; + const copyleft = permissive.no.?; + try testing.expect(copyleft.node_type == .Question); + + const strong = copyleft.yes.?; + try testing.expect(strong.node_type == .Question); + + const network = strong.yes.?; + try testing.expect(network.node_type == .Question); + + const latest_gpl = network.no.?; + try testing.expect(latest_gpl.node_type == .Question); + + const gpl3 = latest_gpl.yes.?; + try testing.expect(gpl3.node_type == .Answer); + try testing.expect(std.mem.eql(u8, gpl3.content, "GPL-3.0")); +} + +test "tree has proper structure with no null dereferencing" { + // Walk through several paths to ensure no crashes + var paths_tested: usize = 0; + + // Test permissive branch + if (tree.decision_tree.yes) |permissive| { + paths_tested += 1; + try testing.expect(permissive.node_type == .Question); + + if (permissive.yes) |minimal| { + paths_tested += 1; + try testing.expect(minimal.node_type == .Question); + } + + if (permissive.no) |copyleft| { + paths_tested += 1; + try testing.expect(copyleft.node_type == .Question); + } + } + + // Test closed source branch + if (tree.decision_tree.no) |closed| { + paths_tested += 1; + try testing.expect(closed.node_type == .Answer); + } + + try testing.expect(paths_tested == 4); +} + +test "all question nodes have content and elaboration" { + // Test root node + try testing.expect(tree.decision_tree.content.len > 0); + try testing.expect(tree.decision_tree.elaboration.len > 0); + + // Test a few more nodes + const permissive = tree.decision_tree.yes.?; + try testing.expect(permissive.content.len > 0); + try testing.expect(permissive.elaboration.len > 0); +} + +test "NodeType enum has correct values" { + try testing.expect(@TypeOf(tree.NodeType.Question) == tree.NodeType); + try testing.expect(@TypeOf(tree.NodeType.Answer) == tree.NodeType); +} diff --git a/src/ui.zig b/src/ui.zig new file mode 100644 index 0000000..fa5caf4 --- /dev/null +++ b/src/ui.zig @@ -0,0 +1,58 @@ +const std = @import("std"); + +pub const Screen = struct { + lines_printed: usize, + + pub fn init() Screen { + return Screen{ .lines_printed = 0 }; + } + + /// Render the given content by clearing the previously-rendered + /// block and printing the new content. This uses simple ANSI + /// escape sequences and assumes a terminal supporting them. + pub fn render(self: *Screen, stdout: anytype, content: []const u8) !void { + // Clear previous block by moving the cursor up and clearing lines + if (self.lines_printed > 0) { + var i: usize = 0; + while (i < self.lines_printed) : (i += 1) { + // Move cursor up 1 line, then clear the entire line + try stdout.print("\x1b[1A\x1b[2K", .{}); + } + } + + // Print new content + try stdout.print("{s}", .{content}); + + // Count lines in content to know how many to clear next time. + var count: usize = 1; + for (content) |c| { + if (c == '\n') count += 1; + } + self.lines_printed = count; + } + + /// Explicitly clear the currently-rendered block. + pub fn clear(self: *Screen, stdout: anytype) !void { + if (self.lines_printed > 0) { + var i: usize = 0; + while (i < self.lines_printed) : (i += 1) { + try stdout.print("\x1b[1A\x1b[2K", .{}); + } + self.lines_printed = 0; + } + } + + /// Clear the rendered block and the user input line that followed it. + pub fn clearIncludingInput(self: *Screen, stdout: anytype) !void { + // Always clear the previously-rendered block lines first + if (self.lines_printed > 0) { + var i: usize = 0; + while (i < self.lines_printed) : (i += 1) { + try stdout.print("\x1b[1A\x1b[2K", .{}); + } + self.lines_printed = 0; + } + // Also clear the user's input line (the line after the rendered block) + try stdout.print("\x1b[1A\x1b[2K", .{}); + } +}; diff --git a/src/ui_test.zig b/src/ui_test.zig new file mode 100644 index 0000000..c465891 --- /dev/null +++ b/src/ui_test.zig @@ -0,0 +1,48 @@ +const std = @import("std"); +const testing = std.testing; +const ui = @import("ui.zig"); + +// A tiny writer that collects printed bytes into an ArrayList(u8). +const CaptureWriter = struct { + allocator: std.mem.Allocator, + buf: *std.ArrayList(u8), + + pub fn init(allocator: std.mem.Allocator, buf: *std.ArrayList(u8)) CaptureWriter { + return CaptureWriter{ .allocator = allocator, .buf = buf }; + } + + pub fn print(self: *CaptureWriter, comptime fmt: []const u8, args: anytype) !void { + const s = try std.fmt.allocPrint(self.allocator, fmt, args); + defer self.allocator.free(s); + for (s) |c| { + try self.buf.append(self.allocator, c); + } + } +}; + +test "Screen.render overwrites previous content" { + const allocator = testing.allocator; + + var buf = try std.ArrayList(u8).initCapacity(allocator, 0); + defer buf.deinit(allocator); + + var writer = CaptureWriter.init(allocator, &buf); + + var screen = ui.Screen.init(); + + // First render: should simply print the content (no clear sequences) + try screen.render(&writer, "first line\nsecond line\n"); + const out1 = try buf.toOwnedSlice(allocator); + defer allocator.free(out1); + + try testing.expect(std.mem.indexOf(u8, out1, "first line\n") != null); + + // Second render: should include clear sequences before printing new content + try screen.render(&writer, "only line\n"); + const out2 = try buf.toOwnedSlice(allocator); + defer allocator.free(out2); + + const esc = "\x1b[1A\x1b[2K"; + try testing.expect(std.mem.indexOf(u8, out2, esc) != null); + try testing.expect(std.mem.indexOf(u8, out2, "only line\n") != null); +}